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