Overview
Threat intelligence is the practice of collecting, processing, and analyzing data about adversaries, their tactics, and indicators of compromise (IoCs) — then turning that raw information into actionable knowledge. Most organizations buy expensive commercial TIP (Threat Intelligence Platform) subscriptions; this project gives you a production-grade alternative for free.
OpenCTI (Open Cyber Threat Intelligence) is an open-source platform developed by Filigran and maintained in partnership with ANSSI (France's national cybersecurity agency). It models threat data using the STIX 2.1 standard, supports TAXII 2.1 feeds, and integrates with dozens of external sources via a connector ecosystem. The result is a searchable, graph-based knowledge base of threats, malware families, TTPs, vulnerabilities, and infrastructure.
In this project you will:
- Deploy the full OpenCTI stack with Docker Compose (platform, Elasticsearch, Redis, RabbitMQ, MinIO)
- Configure connectors for NVD/CVE, AlienVault OTX, MISP, and Shodan
- Ingest threat data and explore relationships in the graph view
- Integrate with Wazuh to enrich SIEM alerts with CTI context
- Set up a TAXII server so downstream tools can pull live indicators
This pairs naturally with a Wazuh XDR/SIEM homelab and a Shuffle SOAR deployment — together they form a complete detect-enrich-respond pipeline.
Architecture
┌─────────────────────────────────────────────────────────────┐
│ Docker Compose Stack │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ OpenCTI │◄──►│Elasticsearch │ │ MinIO │ │
│ │ :8080 │ │ :9200 │ │ (S3 store) │ │
│ └──────┬───────┘ └──────────────┘ └───────────────┘ │
│ │ │
│ ┌──────▼───────┐ ┌──────────────┐ │
│ │ RabbitMQ │ │ Redis │ │
│ │ :5672/:15672│ │ :6379 │ │
│ └──────┬───────┘ └──────────────┘ │
│ │ │
│ ┌──────▼────────────────────────────────────────────────┐ │
│ │ Connectors (workers) │ │
│ │ CVE/NVD │ AlienVault OTX │ MISP │ Shodan │ ... │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ │
┌──────▼──────┐ ┌────────▼──────┐
│ Wazuh │ │ Shuffle SOAR │
│ (SIEM/XDR) │ │ (playbooks) │
└─────────────┘ └───────────────┘
OpenCTI uses RabbitMQ as its message bus — connectors push intelligence as STIX bundles onto the queue, and the platform workers consume and persist them into Elasticsearch. Files (malware samples, reports, attachments) are stored in MinIO (S3-compatible object storage). Redis handles caching and session data. The web UI exposes a GraphQL API consumed both by the frontend and by third-party integrations.
Resource Requirements
| Component | RAM | Disk |
|---|---|---|
| OpenCTI platform | 1 GB | — |
| Elasticsearch | 4–8 GB | 20 GB+ |
| RabbitMQ | 512 MB | 1 GB |
| MinIO | 256 MB | 10 GB+ |
| Redis | 256 MB | — |
| Total recommended | 8 GB | 40 GB |
A single VM or dedicated Docker host with 8 GB RAM and a 60 GB data volume is sufficient for a homelab deployment ingesting a few feeds.
Step 1: System Preparation
1.1 Elasticsearch kernel parameter
Elasticsearch requires a higher virtual memory map count than most Linux defaults. Set this permanently:
# Apply immediately
sudo sysctl -w vm.max_map_count=1048575
# Persist across reboots
echo "vm.max_map_count=1048575" | sudo tee -a /etc/sysctl.confVerify:
sysctl vm.max_map_count
# vm.max_map_count = 10485751.2 Generate UUIDs
OpenCTI requires several UUIDs for tokens and secrets. Generate them all at once:
for i in $(seq 1 6); do cat /proc/sys/kernel/random/uuid; doneLabel and save these — you will use them in the .env file below.
Step 2: Docker Compose Setup
2.1 Create project directory
mkdir -p ~/opencti/connectors
cd ~/opencti2.2 Environment file
Create .env (replace the UUIDs with your generated values):
# OpenCTI core
OPENCTI_ADMIN_EMAIL=admin@homelab.local
OPENCTI_ADMIN_PASSWORD=ChangeMe2026!
OPENCTI_ADMIN_TOKEN=<uuid-1>
OPENCTI_BASE_URL=http://localhost:8080
OPENCTI_HEALTHCHECK_ACCESS_KEY=<uuid-2>
# Elasticsearch
ELASTIC_MEMORY_SIZE=4G
# MinIO
MINIO_ROOT_USER=<uuid-3>
MINIO_ROOT_PASSWORD=<uuid-4>
# RabbitMQ
RABBITMQ_DEFAULT_USER=opencti
RABBITMQ_DEFAULT_PASS=<uuid-5>
# Connector secret (shared by all connectors)
CONNECTOR_EXPORT_FILE_STIX_ID=<uuid-6>
# Feed API keys (fill in later)
ALIENVAULT_API_KEY=
SHODAN_API_KEY=2.3 Docker Compose file
Create docker-compose.yml:
version: "3.8"
services:
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redis_data:/data
command: redis-server --appendonly yes
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.14.0
restart: unless-stopped
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- "ES_JAVA_OPTS=-Xms${ELASTIC_MEMORY_SIZE} -Xmx${ELASTIC_MEMORY_SIZE}"
volumes:
- es_data:/usr/share/elasticsearch/data
ulimits:
memlock:
soft: -1
hard: -1
nofile:
soft: 65536
hard: 65536
minio:
image: minio/minio:latest
restart: unless-stopped
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
rabbitmq:
image: rabbitmq:3.12-management-alpine
restart: unless-stopped
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS}
volumes:
- rabbitmq_data:/var/lib/rabbitmq
ports:
- "15672:15672" # Management UI (optional, remove in prod)
opencti:
image: opencti/platform:latest
restart: unless-stopped
environment:
NODE_OPTIONS: --max-old-space-size=8096
APP__PORT: 8080
APP__BASE_URL: ${OPENCTI_BASE_URL}
APP__ADMIN__EMAIL: ${OPENCTI_ADMIN_EMAIL}
APP__ADMIN__PASSWORD: ${OPENCTI_ADMIN_PASSWORD}
APP__ADMIN__TOKEN: ${OPENCTI_ADMIN_TOKEN}
APP__APP_LOGS__LOGS_LEVEL: error
REDIS__HOSTNAME: redis
REDIS__PORT: 6379
ELASTICSEARCH__URL: http://elasticsearch:9200
MINIO__ENDPOINT: minio
MINIO__PORT: 9000
MINIO__USE_SSL: "false"
MINIO__ACCESS_KEY: ${MINIO_ROOT_USER}
MINIO__SECRET_KEY: ${MINIO_ROOT_PASSWORD}
RABBITMQ__HOSTNAME: rabbitmq
RABBITMQ__PORT: 5672
RABBITMQ__USERNAME: ${RABBITMQ_DEFAULT_USER}
RABBITMQ__PASSWORD: ${RABBITMQ_DEFAULT_PASS}
SMTP__HOSTNAME: localhost
SMTP__PORT: 25
ports:
- "8080:8080"
depends_on:
- redis
- elasticsearch
- minio
- rabbitmq
volumes:
redis_data:
es_data:
minio_data:
rabbitmq_data:2.4 Start the core stack
docker compose up -d
# Watch logs until OpenCTI reports it is listening
docker compose logs -f opencti
# Look for: "🚀 OpenCTI is now started"Initial startup takes 3–5 minutes while Elasticsearch initialises its indices and OpenCTI runs migrations. Open http://localhost:8080 and log in with OPENCTI_ADMIN_EMAIL / OPENCTI_ADMIN_PASSWORD.
Step 3: Connectors
Connectors are standalone Docker containers that pull data from external sources and push STIX bundles to OpenCTI via RabbitMQ. Add them to a second compose file so you can manage them independently.
Create connectors/docker-compose.yml:
version: "3.8"
# Shared connector environment — avoids repeating connection params
x-connector-base: &connector-base
networks:
- opencti_default
restart: unless-stopped
environment: &connector-env
OPENCTI_URL: http://opencti:8080
OPENCTI_TOKEN: ${OPENCTI_ADMIN_TOKEN}
CONNECTOR_CONFIDENCE_LEVEL: "75"
CONNECTOR_LOG_LEVEL: error
networks:
opencti_default:
external: true
services:
# ── CVE / NVD ──────────────────────────────────────────────
connector-cve:
<<: *connector-base
image: opencti/connector-cve:latest
environment:
<<: *connector-env
CONNECTOR_ID: "<uuid-cve>" # generate: cat /proc/sys/kernel/random/uuid
CONNECTOR_NAME: "Common Vulnerabilities and Exposures"
CONNECTOR_SCOPE: identity,attack-pattern,course-of-action,vulnerability,x_opencti_asset_type
CVE_BASE_URL: https://services.nvd.nist.gov/rest/json/cves/2.0
CVE_ENABLE_EPSS: "true"
CONNECTOR_RUN_AND_TERMINATE: "false"
CONNECTOR_DURATION_PERIOD: PT6H # sync every 6 hours
# ── AlienVault OTX ─────────────────────────────────────────
connector-alienvault:
<<: *connector-base
image: opencti/connector-alienvault:latest
environment:
<<: *connector-env
CONNECTOR_ID: "<uuid-otx>"
CONNECTOR_NAME: "AlienVault OTX"
CONNECTOR_SCOPE: "alienvault"
ALIENVAULT_BASE_URL: https://otx.alienvault.com
ALIENVAULT_API_KEY: ${ALIENVAULT_API_KEY}
ALIENVAULT_TLP: White
ALIENVAULT_CREATE_OBSERVABLES: "true"
ALIENVAULT_CREATE_INDICATORS: "true"
ALIENVAULT_PULSE_START_TIMESTAMP: 2024-01-01T00:00:00 # start date
ALIENVAULT_INTERVAL: 30 # minutes
# ── MISP ───────────────────────────────────────────────────
# Uncomment and configure if you have a MISP instance
# connector-misp:
# <<: *connector-base
# image: opencti/connector-misp:latest
# environment:
# <<: *connector-env
# CONNECTOR_ID: "<uuid-misp>"
# CONNECTOR_NAME: "MISP"
# CONNECTOR_SCOPE: misp
# MISP_URL: http://misp.homelab.local
# MISP_REFERENCE_URL: http://misp.homelab.local
# MISP_KEY: <your-misp-api-key>
# MISP_SSL: "false"
# MISP_CREATE_REPORTS: "true"
# MISP_REPORT_TYPE: misp-event
# MISP_INTERVAL: 5
# ── Shodan ─────────────────────────────────────────────────
connector-shodan-internetdb:
<<: *connector-base
image: opencti/connector-shodan-internetdb:latest
environment:
<<: *connector-env
CONNECTOR_ID: "<uuid-shodan>"
CONNECTOR_NAME: "Shodan InternetDB"
CONNECTOR_SCOPE: IPv4-Addr
SHODAN_MAX_TLP: TLP:AMBER
# Free Shodan InternetDB — no API key requiredGenerate a UUID for each connector:
for name in cve otx shodan; do
echo "$name: $(cat /proc/sys/kernel/random/uuid)"
doneStart connectors:
cd connectors
docker compose up -d
# Verify connectors registered with OpenCTI
docker compose logs connector-cve --tail 20In the OpenCTI UI navigate to Data → Connectors — you should see each connector listed as Connected.
Step 4: Get an AlienVault OTX API Key
AlienVault OTX is free and provides one of the richest public threat intelligence feeds:
- Register at
otx.alienvault.com - Navigate to Settings → API Integration
- Copy your OTX Key (a 64-character hex string)
- Add it to your
.env:ALIENVAULT_API_KEY=<your-64-char-key> - Restart the connector:
cd connectors && docker compose restart connector-alienvault
Step 5: Exploring Threat Intelligence
5.1 Dashboard
After 10–15 minutes the CVE connector will start populating data. The OpenCTI dashboard shows:
- Ingested entities — vulnerabilities, malware, threat actors, campaigns
- Latest ingested — a timeline of recent additions
- Relationship graph — linkages between entities
5.2 Vulnerability investigation
Search for a recent CVE (e.g., CVE-2025-32433):
- Go to Arsenal → Vulnerabilities
- Search by CVE ID
- Open the result — you will see CVSS score, EPSS probability, affected CPEs, and any linked threat actors or campaigns from OTX
5.3 Indicator search
Go to Observations → Indicators to browse IOCs (IPs, domains, hashes, URLs). Use the filter panel to narrow by:
valid_until > now— only active indicatorsconfidence >= 75— high-confidence onlytlp = White— public intelligence
5.4 Knowledge graph
Open any entity and click the Knowledge tab, then Graph — this renders an interactive D3 graph of relationships. You can visually explore how a malware family relates to specific threat actors, campaigns, and targeted sectors.
5.5 TAXII server
OpenCTI includes a built-in TAXII 2.1 server. Navigate to Data → Sharing → TAXII Collections and create a collection filtered to your desired indicators. Other tools (Palo Alto, Wazuh, custom scripts) can then poll this endpoint to pull live IOCs:
https://<opencti-host>:8080/taxii2/root/collections/<collection-id>/objects/
Authorization: Bearer <OPENCTI_ADMIN_TOKEN>
Step 6: Wazuh Integration
Push OpenCTI indicators into Wazuh so SIEM alerts are automatically enriched with threat context.
6.1 Wazuh custom integration script
On your Wazuh manager, create /var/ossec/integrations/custom-opencti.py:
#!/usr/bin/env python3
"""Enrich Wazuh alerts with OpenCTI indicator lookups."""
import json
import sys
import urllib.request
import urllib.error
OPENCTI_URL = "http://opencti.homelab.local:8080"
OPENCTI_TOKEN = "YOUR_ADMIN_TOKEN"
GRAPHQL_QUERY = """
query IndicatorByValue($value: String!) {
indicators(filters: {
mode: and
filters: [{ key: "value", values: [$value] }]
filterGroups: []
}) {
edges {
node {
id
name
description
confidence
valid_until
pattern
x_opencti_main_observable_type
killChainPhases { phase_name }
}
}
}
}
"""
def lookup_indicator(value: str) -> dict | None:
payload = json.dumps({"query": GRAPHQL_QUERY, "variables": {"value": value}}).encode()
req = urllib.request.Request(
f"{OPENCTI_URL}/graphql",
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {OPENCTI_TOKEN}",
},
)
try:
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read())
edges = data.get("data", {}).get("indicators", {}).get("edges", [])
return edges[0]["node"] if edges else None
except (urllib.error.URLError, KeyError, IndexError):
return None
def main():
alert = json.loads(sys.stdin.read())
observables = [
alert.get("data", {}).get("srcip"),
alert.get("data", {}).get("dstip"),
alert.get("data", {}).get("url"),
alert.get("data", {}).get("md5"),
]
for obs in filter(None, observables):
indicator = lookup_indicator(obs)
if indicator:
print(json.dumps({
"opencti": {
"matched_observable": obs,
"indicator_name": indicator["name"],
"confidence": indicator["confidence"],
"valid_until": indicator["valid_until"],
"kill_chain_phases": [p["phase_name"] for p in indicator.get("killChainPhases", [])],
}
}))
return
print(json.dumps({"opencti": {"matched_observable": None}}))
if __name__ == "__main__":
main()6.2 Register in ossec.conf
<integration>
<name>custom-opencti</name>
<hook_url>http://opencti.homelab.local:8080</hook_url>
<level>3</level>
<alert_format>json</alert_format>
</integration>Restart Wazuh manager:
systemctl restart wazuh-managerWazuh alerts will now include opencti.* fields when an observable matches a known indicator.
Testing
Verify connector health
# In the core stack directory
docker compose ps
# All services should show "healthy" or "running"
# Check connector logs for successful pushes
docker compose -f connectors/docker-compose.yml logs connector-alienvault | grep -i "bundle\|push\|success"API smoke test
curl -s -X POST http://localhost:8080/graphql \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${OPENCTI_ADMIN_TOKEN}" \
-d '{"query": "{ about { version } }"}' | python3 -m json.toolExpected output:
{
"data": {
"about": {
"version": "6.x.x"
}
}
}TAXII endpoint test
curl -s http://localhost:8080/taxii2/root/ \
-H "Authorization: Bearer ${OPENCTI_ADMIN_TOKEN}" \
-H "Accept: application/taxii+json;version=2.1" | python3 -m json.toolIndicator count
After connectors have been running for 30+ minutes, check entity counts:
curl -s -X POST http://localhost:8080/graphql \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${OPENCTI_ADMIN_TOKEN}" \
-d '{"query": "{ indicators { pageInfo { globalCount } } }"}' | python3 -m json.toolA healthy OTX connector populates thousands of indicators within the first hour.
Deployment Notes
Reverse proxy with Traefik
If you have Traefik running in your homelab, add these labels to the opencti service:
labels:
- "traefik.enable=true"
- "traefik.http.routers.opencti.rule=Host(`cti.homelab.local`)"
- "traefik.http.routers.opencti.entrypoints=websecure"
- "traefik.http.routers.opencti.tls.certresolver=letsencrypt"
- "traefik.http.services.opencti.loadbalancer.server.port=8080"Backup strategy
The critical persistent volumes are es_data (all entity data) and minio_data (file attachments). Back these up with:
# Stop Elasticsearch before snapshotting to ensure consistency
docker stop elasticsearch
tar -czf opencti-backup-$(date +%Y%m%d).tar.gz \
$(docker volume inspect opencti_es_data -f '{{.Mountpoint}}') \
$(docker volume inspect opencti_minio_data -f '{{.Mountpoint}}')
docker start elasticsearchPerformance tuning
For larger feeds or a fleet environment, consider:
- Increase
ELASTIC_MEMORY_SIZEto8Gif you have the RAM - Add dedicated Elasticsearch data nodes (scale the ES service)
- Run connectors on a separate Docker host to avoid competing for I/O with Elasticsearch
Extensions & Next Steps
More connectors to explore
| Connector | Source | Value |
|---|---|---|
connector-mitre | MITRE ATT&CK | Full TTP framework |
connector-urlscan | urlscan.io | URL/domain intelligence |
connector-virustotal | VirusTotal | Hash and URL enrichment |
connector-abuse-ssl | abuse.ch | SSL certificate IOCs |
connector-feodotracker | abuse.ch | Botnet C2 IPs |
connector-malwarebazaar | abuse.ch | Malware samples + hashes |
connector-hybrid-analysis | Hybrid Analysis | Dynamic malware analysis |
Add any of these by including a new service block in connectors/docker-compose.yml with a fresh UUID and the appropriate image/environment from the OpenCTI connector catalog.
Bi-directional MISP sync
If you deploy MISP alongside OpenCTI, configure connector-misp to pull MISP events into OpenCTI and connector-misp-export to push back enriched IoCs. This creates a closed-loop intelligence sharing workflow.
Threat hunting with GraphQL
OpenCTI's GraphQL API enables powerful automated queries. For example, identify all indicators related to ransomware groups active in the last 30 days:
{
indicators(
filters: {
mode: and
filters: [
{ key: "valid_until", values: ["now"], operator: gt }
{ key: "createdBy", values: ["ransomware"] }
]
filterGroups: []
}
) {
edges {
node { name pattern confidence valid_until }
}
}
}Shuffle SOAR playbook
Wire OpenCTI's TAXII feed into a Shuffle workflow that:
- Polls the TAXII collection every 15 minutes for new high-confidence indicators
- Pushes them to Wazuh as custom threat rules
- Syncs them to Pi-hole as blocklist entries for C2 domains
- Posts a summary to a Discord channel
This gives you automated threat response without manual intervention — the defining feature of a mature SOC automation pipeline.
OpenCTI is one of the best freely available tools in the blue team arsenal. Once populated with a few weeks of feeds, your knowledge graph becomes a powerful investigative resource for any incident that lands in your SIEM.