diff --git a/integrations/fortinet_fortigate-active-response/.gitignore b/integrations/fortinet_fortigate-active-response/.gitignore new file mode 100644 index 00000000..8e28554d --- /dev/null +++ b/integrations/fortinet_fortigate-active-response/.gitignore @@ -0,0 +1,27 @@ +# Live config contains API token — never commit it +active-response/fortigate-ar.conf +!active-response/fortigate-ar.conf.example + +# Local override configs +*.local.conf +.env +*.token + +# Logs / runtime artifacts +*.log +*.tmp +debug-output/ + +# Editor / OS artifacts +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# IDEs +.vscode/ +.idea/ + +# Python cache +__pycache__/ \ No newline at end of file diff --git a/integrations/fortinet_fortigate-active-response/README.md b/integrations/fortinet_fortigate-active-response/README.md new file mode 100644 index 00000000..23a35f22 --- /dev/null +++ b/integrations/fortinet_fortigate-active-response/README.md @@ -0,0 +1,257 @@ +# Fortinet FortiGate — Wazuh Active Response Integration + +Automatically blocks malicious source IPs on a Fortinet FortiGate firewall in response to Wazuh alerts. When a matching rule fires, the Wazuh Manager executes the active response script, which creates a `/32` host address object on the FortiGate and appends it to a pre-configured block group via the REST API — all within seconds, without modifying any existing firewall policies. + +--- + +## How it works + +``` +Wazuh Alert (e.g. SSH brute force, web attack) + │ + ▼ + wazuh-analysisd - rule match fires + │ + ▼ + wazuh-execd - dispatches active response, full alert JSON via STDIN + │ + ▼ + fortigate-block.sh - extracts srcip, calls FortiGate REST API + │ + ├─ POST /api/v2/cmdb/firewall/address + │ creates host object "wazuh-{ip}" + │ + └─ POST /api/v2/cmdb/firewall/addrgrp/{group}/member + appends to block group (never overwrites existing members) + + [timeout expires — Wazuh calls script with "delete"/optional] + │ + ├─ DELETE /api/v2/cmdb/firewall/addrgrp/{group}/member/{addr} + └─ DELETE /api/v2/cmdb/firewall/address/{addr} +``` + +> **Key design note:** The member-append endpoint (`POST .../addrgrp/{group}/member`) is used deliberately. Using `PUT` on the group object replaces all existing members — a common mistake in earlier implementations. The append endpoint only adds the new member without touching others. + +--- + +## Requirements + +| Component | Minimum version | +|-----------|----------------| +| Wazuh Manager | 4.2+ | +| FortiOS | 6.4+ | +| bash | 4.0+ | +| curl | Any recent version | +| jq | 1.5+ | + +--- + +## FortiGate prerequisites + +### 1. Create the block address group + +**Policy & Objects - Addresses - Create New - Address Group** + +| Field | Value | +|-------|-------| +| Name | `Wazuh-Blocked-IPs` *(must match `FGT_BLOCK_GROUP` in config)* | +| Members | Add a placeholder (e.g. `FIREWALL`) — FortiGate requires at least one member | + +### 2. Create a DENY policy referencing the group + +**Policy & Objects - Firewall Policy - Create New** + +| Field | Value | +|-------|-------| +| Source | `Wazuh-Blocked-IPs` | +| Destination | `all` | +| Action | **DENY** | +| Position | **Above** any ALLOW rules for the same traffic | +| Logging | Enable | + +> Policy order matters on FortiGate — the deny rule must be evaluated before permissive rules. + +### 3. Create a REST API administrator + +**System - Administrators - Create New REST API Admin** + +| Field | Value | +|-------|-------| +| Username | any name | +| Profile | Custom profile with **Read/Write** on Firewall Address + Firewall Address Group | +| Trusted Hosts | **Add the Wazuh Manager IP** — required or all API calls return 403 | + +Copy the generated token — to be placed on the config file. + +--- + +## Installation + + +```bash +# 1. Script +sudo cp integrations/fortinet_fortigate-active-response/active-response/fortigate-block.sh \ + /var/ossec/active-response/bin/ +sudo chown root:wazuh /var/ossec/active-response/bin/fortigate-block.sh +sudo chmod 750 /var/ossec/active-response/bin/fortigate-block.sh + +# 2. Config +sudo cp integrations/fortinet_fortigate-active-response/active-response/fortigate-ar.conf.example \ + /var/ossec/etc/fortigate-ar.conf +sudo chown root:wazuh /var/ossec/etc/fortigate-ar.conf +sudo chmod 640 /var/ossec/etc/fortigate-ar.conf + +# 3. Whitelist +sudo install -o root -g wazuh -m 640 /dev/null \ + /var/ossec/etc/lists/fortigate-ar-whitelist +# Add one IP per line to exempt trusted sources (scanners, jump hosts, etc.) +``` + +--- + +## Configuration + +### Edit `/var/ossec/etc/fortigate-ar.conf` + +Minimum required settings: + +```bash +FGT_HOST="1.1.1.1" # FortiGate management IP or FQDN +FGT_API_TOKEN="xxxxxxxxxxxxxxxxxxxx" # Token from REST API admin +FGT_BLOCK_GROUP="Wazuh-Blocked-IPs" # Must already exist on FortiGate +FGT_VDOM="root" # VDOM name (root if not using VDOMs) +``` + +All available options are documented in +[`active-response/fortigate-ar.conf`](active-response/fortigate-ar.conf). + +### Add to `/var/ossec/etc/ossec.conf` + +```xml + + + fortigate-block + fortigate-block.sh + yes + + + + + fortigate-block + server + 5960 + 3600 + +``` + +`server` is required — the script runs on the Manager because it needs to reach the FortiGate API. See [`active-response/fortigate-ar.conf`](active-response/fortigate-ar.conf) for more trigger examples. + +### Whitelist your infrastructure + +Edit `/var/ossec/etc/lists/fortigate-ar-whitelist` — one IP per line. Loopback addresses are always exempt regardless of this file. + +### Restart Wazuh Manager + +```bash +sudo systemctl restart wazuh-manager +``` + +--- + +## Testing + +```bash +# Dry-run — prints the JSON without calling the API +sudo bash integrations/fortinet_fortigate-active-response/tests/test-ar.sh block 198.51.100.99 dry + +# Live block test +sudo bash integrations/fortinet_fortigate-active-response/tests/test-ar.sh block 198.51.100.99 + +# Verify on FortiGate +curl -sk -H "Authorization: Bearer YOUR_TOKEN" \ + "https://YOUR_FGT/api/v2/cmdb/firewall/addrgrp/Wazuh-Blocked-IPs" \ + | jq '.results[0].member[].name' + +# Live unblock test +sudo bash integrations/fortinet_fortigate-active-response/tests/test-ar.sh unblock 198.51.100.99 + +# Monitor the AR log +sudo tail -f /var/ossec/logs/active-responses.log +``` + +--- + +## Repository structure + +``` +integrations/ +└── fortinet_fortigate-active-response/ + ├── active-response/ + │ ├── fortigate-block.sh - AR script - /var/ossec/active-response/bin/ + │ ├── fortigate-ar.conf.example - config template - /var/ossec/etc/ + │ ├── fortigate-ar-whitelist - whitelist template + │ └── fortigate-ar.conf - ossec.conf snippets + ├── tests/ + │ └── test-ar.sh - manual test + └── README.md +``` + +--- + +## Common use cases: + +``` +- SSH brute force +- Web attacks +- Port scans +- Threat intelligence matches +- Repeated authentication failures + +``` + +--- + +## Security considerations + +- **Protect the API token** — `fortigate-ar.conf` is `root:wazuh 640`. Do not commit the live file; it is in `.gitignore`. +- **Least-privilege API profile** — scope the FortiGate REST API admin to only Address and Address Group objects, not `super_admin`. +- **Restrict Trusted Hosts** — only the Wazuh Manager IP should be listed in the FortiGate API admin's Trusted Hosts. +- **use timeouts** — set `` in ossec.conf so blocks automatically expire. Permanent blocks (`timeout=0`) require manual cleanup (optional). +- **Whitelist your infrastructure** — scanners, monitoring servers, and jump hosts should be in the whitelist to prevent self-lockout. +- **Enable SSL verification** — set `FGT_VERIFY_SSL=true` with a CA-signed certificate on the FortiGate management interface for production environments. + +--- + +## Troubleshooting + +Enable debug mode on the Wazuh Manager: + +```bash +echo "execd.debug=2" >> /var/ossec/etc/local_internal_options.conf +systemctl restart wazuh-manager +tail -f /var/ossec/logs/ossec.log | grep -E "execd|fortigate" +cat /var/ossec/logs/active-responses.log +``` + +Common errors: + +| Symptom | Likely cause | Fix | +|---------|-------------|-----| +| HTTP 401 | Wrong API token | Regenerate token in FortiGate GUI | +| HTTP 403 | Wazuh Manager IP not in Trusted Hosts | Add IP to REST API admin Trusted Hosts | +| `curl failed exit 7` | FortiGate unreachable | Check routing/firewall between Manager and FortiGate | +| `Duplicate block attempts are safely ignored using Wazuh check_keys handling.` | Duplicate in-flight block | Normal — Wazuh deduplication working correctly | + +--- + +## Tested environment + +- FortiOS 7.4.11 (FG201FT) +- Wazuh Manager 4.14 on Ubuntu 22.04 +- Triggered by rule 5760 (SSH authentication failure) + +--- + +## License + +MIT — see [LICENSE](../../LICENSE). diff --git a/integrations/fortinet_fortigate-active-response/active-response/fortigate-ar.conf b/integrations/fortinet_fortigate-active-response/active-response/fortigate-ar.conf new file mode 100644 index 00000000..07607b99 --- /dev/null +++ b/integrations/fortinet_fortigate-active-response/active-response/fortigate-ar.conf @@ -0,0 +1,80 @@ + + + + + + fortigate-block + fortigate-block.sh + yes + + + + + + + + + fortigate-block + server + 5551,5712,5720,5763 + 3600 + + + + + + + + + + + + + + diff --git a/integrations/fortinet_fortigate-active-response/active-response/fortigate-ar.conf.example b/integrations/fortinet_fortigate-active-response/active-response/fortigate-ar.conf.example new file mode 100644 index 00000000..fa5e7745 --- /dev/null +++ b/integrations/fortinet_fortigate-active-response/active-response/fortigate-ar.conf.example @@ -0,0 +1,65 @@ +# ============================================================================= +# fortigate-ar.conf — Wazuh FortiGate Active Response Configuration +# +# Deploy to : /var/ossec/etc/fortigate-ar.conf +# Owner : root:wazuh Permissions: 0640 +# +# sudo chown root:wazuh /var/ossec/etc/fortigate-ar.conf +# sudo chmod 640 /var/ossec/etc/fortigate-ar.conf +# +# SECURITY: This file contains your API token. Ensure you do not commit it to version +# control. The .gitignore in this repo excludes the live file by default. +# ============================================================================= + +# ── FortiGate Connection ───────────────────────────────────────────────────── + +# Management IP or FQDN of your FortiGate +FGT_HOST="1.1.1.1" + +# HTTPS management port (default 443) +FGT_PORT="443" + +# REST API bearer token +# Generate: FortiGate GUI - System - Administrators - Create REST API Admin +# The API admin profile needs Read/Write on: Firewall - Address and Address Group +# Add the Wazuh Manager IP to the admin's "Trusted Hosts" list if necessary +FGT_API_TOKEN="YOUR_API_TOKEN_HERE" + +# Verify FortiGate TLS certificate (true/false) +# Use "true" with a CA-signed cert; "false" for self-signed +FGT_VERIFY_SSL="false" + +# VDOM name — use "root" if VDOMs are not configured +FGT_VDOM="root" + +# ── Block Group ─────────────────────────────────────────────────────────────── + +# Name of the FortiGate address group that is already: +# 1. Created in Policy & Objects - Addresses +# 2. Referenced as Source in a DENY policy positioned above ALLOW rules +# This group MUST exist before the script runs. +FGT_BLOCK_GROUP="Wazuh-Blocked-IPs" + +# ── Address Object Settings ─────────────────────────────────────────────────── + +# Prefix for dynamically created address object names +# Example: prefix "wazuh-" + IP "1.1.1.1" - object name "wazuh-1-1-1-1" + +FGT_ADDR_PREFIX="wazuh-" + +# Comment added to each created address object +FGT_ADDR_COMMENT="Auto-blocked by Wazuh Active Response" + +# Delete the address object when the block expires (Wazuh "delete" command)? +# "true" = clean up objects after timeout +# "false" = leave objects on FortiGate for manual audit +FGT_CLEANUP_ADDR="true" + +# ── Tuning ──────────────────────────────────────────────────────────────────── + +# curl timeout per API call (seconds) +FGT_CURL_TIMEOUT="15" + +# Path to the local IP whitelist file +# RFC1918, loopback, and link-local are always exempt regardless of this file +LOCAL_WHITELIST_FILE="/var/ossec/etc/lists/fortigate-ar-whitelist" diff --git a/integrations/fortinet_fortigate-active-response/active-response/fortigate-block.sh b/integrations/fortinet_fortigate-active-response/active-response/fortigate-block.sh new file mode 100644 index 00000000..86256f75 --- /dev/null +++ b/integrations/fortinet_fortigate-active-response/active-response/fortigate-block.sh @@ -0,0 +1,394 @@ +#!/usr/bin/env bash +# ============================================================================= +# fortigate-block.sh +# Wazuh Active Response — Fortinet FortiGate IP Block/Unblock via REST API +# +# Version : 2.1.0 +# Tested on: FortiOS 7.4.x, Wazuh Manager 4.14 +# Requires : bash 4+, curl, jq +# +# Deploy to: /var/ossec/active-response/bin/fortigate-block.sh +# Owner : root:wazuh Permissions: 750 +# +# DESCRIPTION +# ----------- +# When Wazuh detects a malicious source IP this script is invoked by +# wazuh-execd via STDIN (Wazuh 4.2+ JSON protocol). It: +# ADD - creates a host address object on FortiGate and appends +# it to a pre-existing block group using the member-append API +# (POST .../addrgrp/{group}/member) so existing members are +# never overwritten. +# DELETE - removes the address object from the group and optionally +# deletes the object. +# +# CONFIGURATION +# ------------- +# All site-specific settings are read from: +# /var/ossec/etc/fortigate-ar.conf +# +# LOGGING +# ------- +# All activity - /var/ossec/logs/active-responses.log +# stderr is also redirected there so it never reaches wazuh-execd. +# ============================================================================= + +readonly SCRIPT_NAME="fortigate-block" +readonly SCRIPT_VERSION="2.1.0" +readonly AR_LOG="/var/ossec/logs/active-responses.log" +readonly CONFIG_FILE="/var/ossec/etc/fortigate-ar.conf" + +# Redirect stderr into the AR log — keeps wazuh-execd's STDIN/STDOUT clean +exec 2>>"${AR_LOG}" + +# --------------------------------------------------------------------------- +# Logging helper +# --------------------------------------------------------------------------- +log() { + local level="$1"; shift + printf '%s [%s][%s] %s\n' \ + "$(date -u '+%Y/%m/%d %H:%M:%S')" "${SCRIPT_NAME}" "${level}" "$*" \ + >> "${AR_LOG}" +} + +log "INFO" "======= Script ${SCRIPT_VERSION} started PID=$$ =======" + +# --------------------------------------------------------------------------- +# 1. Dependency check +# --------------------------------------------------------------------------- +for cmd in curl jq; do + if ! command -v "${cmd}" &>/dev/null; then + log "ERROR" "Required command '${cmd}' not found. Install it and retry." + exit 1 + fi +done + +# --------------------------------------------------------------------------- +# 2. Load configuration +# --------------------------------------------------------------------------- +if [[ ! -f "${CONFIG_FILE}" ]]; then + log "ERROR" "Config file not found: ${CONFIG_FILE}" + exit 1 +fi +# shellcheck source=/dev/null +source "${CONFIG_FILE}" + +# Validate required keys +for var in FGT_HOST FGT_API_TOKEN FGT_BLOCK_GROUP; do + if [[ -z "${!var:-}" ]]; then + log "ERROR" "Required config variable '${var}' is not set in ${CONFIG_FILE}" + exit 1 + fi +done + +# Apply defaults for optional keys +FGT_PORT="${FGT_PORT:-443}" +FGT_VDOM="${FGT_VDOM:-root}" +FGT_VERIFY_SSL="${FGT_VERIFY_SSL:-false}" +FGT_ADDR_PREFIX="${FGT_ADDR_PREFIX:-wazuh-}" +FGT_ADDR_COMMENT="${FGT_ADDR_COMMENT:-Auto-blocked by Wazuh Active Response}" +FGT_CLEANUP_ADDR="${FGT_CLEANUP_ADDR:-true}" +FGT_CURL_TIMEOUT="${FGT_CURL_TIMEOUT:-15}" +LOCAL_WHITELIST_FILE="${LOCAL_WHITELIST_FILE:-/var/ossec/etc/lists/fortigate-ar-whitelist}" + +BASE_URL="https://${FGT_HOST}:${FGT_PORT}/api/v2/cmdb" +CURL_SSL_FLAG="" + +# --------------------------------------------------------------------------- +# 3a. Validate config values that appear in URL paths +# FortiGate names allow: letters, digits, hyphens, underscores, dots. +# Spaces or special characters break the REST API URL — reject early. +# --------------------------------------------------------------------------- +validate_name_field() { + local field_name="$1" value="$2" + if [[ ! "${value}" =~ ^[a-zA-Z0-9._-]+$ ]]; then + log "ERROR" "Config '${field_name}' contains characters not safe for URL paths: '${value}'" + log "ERROR" "Allowed: letters, digits, hyphen, underscore, dot. No spaces." + exit 1 + fi +} + +validate_name_field "FGT_VDOM" "${FGT_VDOM}" +validate_name_field "FGT_BLOCK_GROUP" "${FGT_BLOCK_GROUP}" +validate_name_field "FGT_ADDR_PREFIX" "${FGT_ADDR_PREFIX}" + +if [[ "${FGT_VERIFY_SSL}" == "false" ]]; then + CURL_SSL_FLAG="--insecure" + log "WARN" "SSL verification disabled — enable FGT_VERIFY_SSL=true in production" +fi + +# --------------------------------------------------------------------------- +# 3. Read alert JSON from STDIN +# --------------------------------------------------------------------------- +if ! read -r -t 30 INPUT; then + log "ERROR" "Timed out or empty STDIN — aborting." + exit 1 +fi +log "DEBUG" "Raw STDIN received (${#INPUT} bytes)" + +if ! echo "${INPUT}" | jq -e . &>/dev/null; then + log "ERROR" "STDIN is not valid JSON — aborting." + exit 1 +fi + +# --------------------------------------------------------------------------- +# 4. Parse command and extract source IP +# --------------------------------------------------------------------------- +AR_COMMAND=$(echo "${INPUT}" | jq -r '.command // empty') +ALERT_JSON=$(echo "${INPUT}" | jq -c '.parameters.alert // {}') + +if [[ -z "${AR_COMMAND}" ]]; then + log "ERROR" "No 'command' field in JSON input." + exit 1 +fi + +# Try all common field paths used by different Wazuh decoders +SRCIP=$(echo "${ALERT_JSON}" | jq -r ' + .data.srcip // + .data.src_ip // + .data.src // + .data.attacker // + .data["src-ip"] // + .data.source_ip // + empty +' 2>/dev/null | head -1 | tr -d '[:space:]') + +# Last resort: use agent IP (log a warning so the operator knows) +if [[ -z "${SRCIP}" || "${SRCIP}" == "null" ]]; then + SRCIP=$(echo "${ALERT_JSON}" | jq -r '.agent.ip // empty' 2>/dev/null | tr -d '[:space:]') + [[ -n "${SRCIP}" && "${SRCIP}" != "null" ]] && \ + log "WARN" "srcip not in data fields — using agent.ip: ${SRCIP}" +fi + +if [[ -z "${SRCIP}" || "${SRCIP}" == "null" ]]; then + log "ERROR" "Cannot extract source IP from alert. Check your decoder extracts srcip." + exit 1 +fi + +RULE_ID=$(echo "${ALERT_JSON}" | jq -r '.rule.id // "unknown"') +RULE_DESC=$(echo "${ALERT_JSON}" | jq -r '.rule.description // "unknown"') +AGENT_NAME=$(echo "${ALERT_JSON}"| jq -r '.agent.name // "unknown"') + +log "INFO" "Command=${AR_COMMAND} | IP=${SRCIP} | Rule=${RULE_ID} | Agent=${AGENT_NAME}" + +# --------------------------------------------------------------------------- +# 5. Validate IPv4 format +# --------------------------------------------------------------------------- +ip_is_valid() { + local ip="$1" IFS='.' octets + read -r -a octets <<< "${ip}" + [[ ${#octets[@]} -eq 4 ]] || return 1 + local o + for o in "${octets[@]}"; do + [[ "${o}" =~ ^[0-9]+$ ]] || return 1 + (( o >= 0 && o <= 255 )) || return 1 + done +} + +if ! ip_is_valid "${SRCIP}"; then + log "ERROR" "Invalid IPv4 format: '${SRCIP}' — aborting." + exit 1 +fi + +# --------------------------------------------------------------------------- +# 6. Whitelist check +# --------------------------------------------------------------------------- +is_whitelisted() { + local ip="$1" + local first="${ip%%.*}" + local second; second="${ip#*.}"; second="${second%%.*}" + [[ "${ip}" == 127.* ]] && return 0 + [[ "${ip}" == 169.254.* ]] && return 0 + (( first == 10 )) && return 0 + (( first == 172 && second >= 16 && second <= 31 )) && return 0 + [[ "${ip}" == 192.168.* ]] && return 0 + if [[ -f "${LOCAL_WHITELIST_FILE}" ]]; then + while IFS= read -r line; do + [[ "${line}" =~ ^[[:space:]]*# ]] && continue + [[ -z "${line// }" ]] && continue + [[ "${ip}" == "${line// }" ]] && return 0 + done < "${LOCAL_WHITELIST_FILE}" + fi + return 1 +} + +if is_whitelisted "${SRCIP}"; then + log "WARN" "IP ${SRCIP} is whitelisted — no action taken." + exit 0 +fi + +# --------------------------------------------------------------------------- +# 7. Wazuh execd stateful handshake (deduplication) +# Script writes check_keys - reads "continue" or "abort" +# --------------------------------------------------------------------------- +ADDR_NAME="${FGT_ADDR_PREFIX}$(echo "${SRCIP}" | tr '.' '-')" + +CONTROL_MSG=$(jq -cn \ + --arg name "${SCRIPT_NAME}" \ + --arg key "${SRCIP}" \ + '{version:1,origin:{name:$name,module:"active-response"}, + command:"check_keys",parameters:{keys:[$key]}}') +echo "${CONTROL_MSG}" +log "DEBUG" "Sent check_keys for key=${SRCIP}" + +if ! read -r -t 30 EXECD_RESPONSE; then + log "ERROR" "Timed out waiting for execd response." + exit 1 +fi + +EXECD_CMD=$(echo "${EXECD_RESPONSE}" | jq -r '.command // empty') +if [[ "${EXECD_CMD}" != "continue" ]]; then + log "INFO" "execd responded '${EXECD_CMD}' — skipping (likely duplicate in-flight block)." + exit 0 +fi + +# --------------------------------------------------------------------------- +# 8. FortiGate API helper +# Returns the response body; logs request + HTTP status; returns 1 on error +# --------------------------------------------------------------------------- +VDOM_PARAM="vdom=${FGT_VDOM}" + +fgt_api() { + local method="$1" endpoint="$2" data="${3:-}" + local url="${BASE_URL}/${endpoint}?${VDOM_PARAM}" + + local cmd=( + curl --silent --show-error --max-time "${FGT_CURL_TIMEOUT}" + ${CURL_SSL_FLAG} + -w "\n__STATUS__%{http_code}" + -X "${method}" + -H "Authorization: Bearer ${FGT_API_TOKEN}" + -H "Content-Type: application/json" + ) + [[ -n "${data}" ]] && cmd+=(-d "${data}") + cmd+=("${url}") + + log "DEBUG" "API ${method} ${endpoint}" + local raw exit_code + raw=$("${cmd[@]}" 2>&1); exit_code=$? + + if (( exit_code != 0 )); then + log "ERROR" "curl failed (exit ${exit_code}) ${method} ${endpoint}: ${raw}" + return 1 + fi + + local body="${raw%__STATUS__*}" + local http="${raw##*__STATUS__}" + log "DEBUG" "API response HTTP=${http}: ${body}" + + if (( http >= 500 )); then + log "ERROR" "FortiGate HTTP ${http} on ${method} ${endpoint} — body: ${body}" + return 1 + fi + + echo "${body}" +} + +# --------------------------------------------------------------------------- +# 9. Address object helpers +# --------------------------------------------------------------------------- +addr_exists() { + local name="$1" + local resp; resp=$(fgt_api "GET" "firewall/address/${name}") || return 1 + [[ "$(echo "${resp}" | jq -r '.status // empty')" == "success" ]] +} + +create_addr_object() { + local name="$1" ip="$2" + # Truncate comment to 255 chars (FortiGate limit) + local comment="${FGT_ADDR_COMMENT} | Rule:${RULE_ID} | ${RULE_DESC}" + comment="${comment:0:255}" + + local payload + payload=$(jq -cn \ + --arg n "${name}" --arg s "${ip}/32" --arg c "${comment}" \ + '{name:$n,type:"ipmask",subnet:$s,comment:$c,color:6}') + + log "INFO" "Creating address object '${name}' for ${ip}/32" + local resp; resp=$(fgt_api "POST" "firewall/address" "${payload}") || return 1 + local status; status=$(echo "${resp}" | jq -r '.status // empty') + + if [[ "${status}" == "success" ]]; then + log "INFO" "Address object '${name}' created successfully." + return 0 + fi + # 409-equivalent: object already exists — safe to continue + if addr_exists "${name}"; then + log "WARN" "Address object '${name}' already exists — proceeding." + return 0 + fi + log "ERROR" "Failed to create address object '${name}': ${resp}" + return 1 +} + +delete_addr_object() { + local name="$1" + log "INFO" "Deleting address object '${name}'" + local resp; resp=$(fgt_api "DELETE" "firewall/address/${name}") || return 1 + local status; status=$(echo "${resp}" | jq -r '.status // empty') + if [[ "${status}" == "success" ]]; then + log "INFO" "Address object '${name}' deleted." + else + log "WARN" "Could not delete '${name}' (may still be referenced): ${resp}" + fi +} + +# --------------------------------------------------------------------------- +# 10. Address group helpers — uses the APPEND endpoint to avoid overwriting +# existing group members (this was the root cause of earlier failures) +# +# CORRECT: POST .../addrgrp/{group}/member {"name":""} +# WRONG: PUT .../addrgrp/{group} {"member":[...]} ← wipes group +# --------------------------------------------------------------------------- +group_add_member() { + local group="$1" addr_name="$2" + local payload; payload=$(jq -cn --arg n "${addr_name}" '{"name":$n}') + log "INFO" "Adding '${addr_name}' to group '${group}'" + local resp; resp=$(fgt_api "POST" "firewall/addrgrp/${group}/member" "${payload}") || return 1 + local status; status=$(echo "${resp}" | jq -r '.status // empty') + if [[ "${status}" == "success" ]]; then + log "INFO" "Successfully added '${addr_name}' to group '${group}'." + return 0 + fi + log "ERROR" "Failed to add '${addr_name}' to group '${group}': ${resp}" + return 1 +} + +group_remove_member() { + local group="$1" addr_name="$2" + log "INFO" "Removing '${addr_name}' from group '${group}'" + local resp; resp=$(fgt_api "DELETE" "firewall/addrgrp/${group}/member/${addr_name}") || return 1 + local status; status=$(echo "${resp}" | jq -r '.status // empty') + if [[ "${status}" == "success" ]]; then + log "INFO" "Removed '${addr_name}' from group '${group}'." + else + log "WARN" "Could not remove '${addr_name}' from group (may not be a member): ${resp}" + fi + return 0 # non-fatal — group membership may have already been cleaned +} + +# --------------------------------------------------------------------------- +# 11. Execute action +# --------------------------------------------------------------------------- +case "${AR_COMMAND}" in + add) + log "INFO" "=== BLOCK action for ${SRCIP} ===" + create_addr_object "${ADDR_NAME}" "${SRCIP}" || exit 1 + group_add_member "${FGT_BLOCK_GROUP}" "${ADDR_NAME}" || exit 1 + log "INFO" "=== BLOCK complete for ${SRCIP} (object=${ADDR_NAME}, group=${FGT_BLOCK_GROUP}) ===" + ;; + + delete) + log "INFO" "=== UNBLOCK action for ${SRCIP} ===" + group_remove_member "${FGT_BLOCK_GROUP}" "${ADDR_NAME}" + [[ "${FGT_CLEANUP_ADDR}" == "true" ]] && delete_addr_object "${ADDR_NAME}" + log "INFO" "=== UNBLOCK complete for ${SRCIP} ===" + ;; + + *) + log "WARN" "Unknown command '${AR_COMMAND}' — no action taken." + exit 0 + ;; +esac + +log "INFO" "======= Script completed PID=$$ =======" +exit 0 diff --git a/integrations/fortinet_fortigate-active-response/screenshots/example1.png b/integrations/fortinet_fortigate-active-response/screenshots/example1.png new file mode 100644 index 00000000..8c42fb94 Binary files /dev/null and b/integrations/fortinet_fortigate-active-response/screenshots/example1.png differ diff --git a/integrations/fortinet_fortigate-active-response/screenshots/example2.png b/integrations/fortinet_fortigate-active-response/screenshots/example2.png new file mode 100644 index 00000000..1216209a Binary files /dev/null and b/integrations/fortinet_fortigate-active-response/screenshots/example2.png differ diff --git a/integrations/fortinet_fortigate-active-response/tests/test-ar.sh b/integrations/fortinet_fortigate-active-response/tests/test-ar.sh new file mode 100644 index 00000000..ef5aec60 --- /dev/null +++ b/integrations/fortinet_fortigate-active-response/tests/test-ar.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# ============================================================================= +# test-ar.sh — Manual test harness for fortigate-block.sh +# +# Usage: +# bash tests/test-ar.sh block # test block action +# bash tests/test-ar.sh unblock # test unblock action +# bash tests/test-ar.sh block dry # print JSON only, no API calls +# +# Must be run as root on the Wazuh Manager with fortigate-ar.conf configured. + +# ============================================================================= +set -euo pipefail + +ACTION="${1:-block}" +TEST_IP="${2:-198.51.100.99}" +DRY_RUN="${3:-}" + +# Locate the AR script (works from repo root or after install) +SCRIPT_PATH="${SCRIPT_PATH:-/var/ossec/active-response/bin/fortigate-block.sh}" +if [[ ! -f "${SCRIPT_PATH}" ]]; then + SCRIPT_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/active-response/fortigate-block.sh" +fi +[[ -f "${SCRIPT_PATH}" ]] || { echo "ERROR: Script not found at ${SCRIPT_PATH}"; exit 1; } + +case "${ACTION}" in + block|add) AR_CMD="add" ;; + unblock|delete) AR_CMD="delete" ;; + *) echo "Usage: $0 [block|unblock] [IP] [dry]"; exit 1 ;; +esac + +AR_LOG="/var/ossec/logs/active-responses.log" + +echo "========================================================" +echo " Wazuh FortiGate AR — Manual Test" +echo " Action : ${AR_CMD}" +echo " Test IP : ${TEST_IP}" +echo " Script : ${SCRIPT_PATH}" +[[ -n "${DRY_RUN}" ]] && echo " Mode : DRY RUN" +echo "========================================================" +echo "" + +# Build a compact single-line alert JSON (the script's read call reads one line) +ALERT_JSON=$(jq -cn \ + --arg cmd "${AR_CMD}" \ + --arg ts "$(date -u '+%Y-%m-%dT%H:%M:%S.000+0000')" \ + --arg ip "${TEST_IP}" \ + '{ + version: 1, + origin: {name:"test-node", module:"wazuh-execd"}, + command: $cmd, + parameters: { + extra_args: [], + alert: { + timestamp: $ts, + rule: {level:10, description:"SSH brute force", id:"5712"}, + agent: {id:"001", name:"test-agent", ip:"10.0.0.5"}, + manager: {name:"wazuh-manager"}, + data: {srcip:$ip, dstip:"10.0.0.5", dstport:"22"} + } + } + }') + +# The "continue" message that wazuh-execd sends after check_keys +CONTINUE_MSG='{"version":1,"origin":{"name":"test-node","module":"wazuh-execd"},"command":"continue","parameters":{}}' + +# --------------------------------------------------------------------------- +# Dry-run +# --------------------------------------------------------------------------- +if [[ -n "${DRY_RUN}" ]]; then + echo "--- Alert JSON (compact, one line as script receives it): ---" + echo "${ALERT_JSON}" | jq . + echo "" + echo "--- Continue message (sent after check_keys): ---" + echo "${CONTINUE_MSG}" | jq . + echo "" + echo "Dry-run complete. No API calls were made." + exit 0 +fi + +# --------------------------------------------------------------------------- +# Live run — pre-feed approach (see header comment for explanation) +# --------------------------------------------------------------------------- +echo "--- Feeding input to script ---" +echo " Line 1 → alert JSON (command=${AR_CMD}, srcip=${TEST_IP})" +echo " Line 2 → continue (sent after script writes check_keys)" +echo "" + +# Record current log line count so we only show new output from this run +LOG_START=$(wc -l < "${AR_LOG}" 2>/dev/null || echo 0) + +EXIT_CODE=0 +if printf '%s\n%s\n' "${ALERT_JSON}" "${CONTINUE_MSG}" \ + | bash "${SCRIPT_PATH}" > /dev/null; then + EXIT_CODE=0 +else + EXIT_CODE=$? +fi + +echo "--- Script exited with code: ${EXIT_CODE} ---" +echo "" +echo "========================================================" +echo " Active-responses.log (this run only):" +echo "========================================================" +if [[ -f "${AR_LOG}" ]]; then + tail -n "+$((LOG_START + 1))" "${AR_LOG}" +else + echo "(log not found at ${AR_LOG})" +fi +echo ""