Skip to main content
Glama
INFOKOM-KI

Wazuh MCP Server

by INFOKOM-KI

Blue Team MCP Server (Wazuh SIEM)

Wazuh-MCP-Server MCP server

Wazuh-MCP-Server MCP server

A defensive MCP server for Claude Desktop / any MCP client — the blue-team counterpart to offensive tooling. 144 tools + 4 resources (118 when WAZUH_READ_ONLY=true) across Wazuh SIEM, multi-provider threat intelligence, MITRE-driven 3-Sum APT correlation, attack graphing, LangGraph investigation workflows, local case RAG, and host forensics. Read-only by default.

Programmer: NAuliajati (csirt[at]tangerangkota[.]go[.]id)


Architecture

main.py -> mcp_server/  (package)
                 ├─ core/          HTTP client, redaction, audit, config, attack graph, IOC store
                 ├─ wazuh/         Indexer (OpenSearch) + Manager API (JWT auth)
                 ├─ correlation/   3-Sum engine (pure computation, MITRE-driven)
                 ├─ threat_intel/  CrowdSec, ThreatFox, OTX, URLhaus, GreyNoise + shared cache
                 ├─ agents/        LangGraph investigation + playbook workflows
                 └─ tools/         53 tool modules

Every tool call flows through a single pipeline in the @blueteam_tool decorator — the three most-connected nodes in the code graph:

audit (_audit_log) -> call -> redact (_redact_alert_data) -> truncate (_truncate_if_needed)

All outbound HTTP flows through a per-pool circuit breaker (http_client.CircuitBreaker: 5 consecutive failures -> open, 60s cooldown, single half-open trial). 429 and 4xx never count as failures, so an outage on one upstream fails fast instead of stacking retries across tools.

Transport

Use case

stdio

Local subprocess / SSH pipe (default)

streamable_http

Remote HTTP service (http://<host>:<port>/mcp) — requires MCP_API_KEY beyond 127.0.0.1 (bind guard enforced)


Related MCP server: wrg-mcp-server

Quick Start

git clone <repo> && cd Wazuh-MCP-Server
sudo bash setup.sh                    # deps, venv, wrapper at /opt/blue-team-mcp

# configure (edit /opt/blue-team-mcp/config.env)
export WAZUH_INDEXER_URL="https://<host>:9200"
export WAZUH_INDEXER_USER="admin"
export WAZUH_INDEXER_PASSWORD="<indexer-password>"
export WAZUH_API_URL="https://<host>:55000"      # optional — Manager API tools
export WAZUH_API_USER="wazuh-wui"
export WAZUH_API_PASSWORD="<api-password>"
export CROWDSEC_API_KEY="<key>"                  # optional — threat intel (free)
# inbound auth for the HTTP transport (REQUIRED when binding beyond 127.0.0.1)
export MCP_API_KEY="btm_<43-char-base64>"        # generate: python3 -c "import secrets; print('btm_' + secrets.token_urlsafe(32))"
export MCP_API_KEY_SCOPES="wazuh:read wazuh:write"   # optional — default wazuh:read (read-only)

# run (stdio)
mcp-server-blueteam

# or remote HTTP (MCP_API_KEY is mandatory here — the server refuses to bind otherwise)
MCP_TRANSPORT=streamable_http MCP_HOST=0.0.0.0 MCP_PORT=8001 \
  MCP_API_KEY="btm_<43-char-base64>" mcp-server-blueteam

Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json):

{
  "mcpServers": {
    "blue-team-mcp": {
      "command": "ssh",
      "args": ["-i", "~/.ssh/id_ed25519", "user@DEFENDER_HOST", "mcp-server-blueteam"],
      "transport": "stdio"
    }
  }
}

Configuration

Credentials come from environment variables, validated at startup. Every threat-intel key is optional — tools degrade gracefully without them.

Area

Variables

Notes

Wazuh Indexer

WAZUH_INDEXER_URL / _USER / _PASSWORD

OpenSearch (9200) — alert/event data

Wazuh Manager

WAZUH_API_URL / _USER / _PASSWORD

Manager API (55000) — rules/agents/config

TLS

WAZUH_INDEXER_VERIFY_SSL, WAZUH_API_VERIFY_SSL

default true

Threat intel

CROWDSEC_API_KEY, THREATFOX_API_KEY, OTX_API_KEY, URLHAUS_API_KEY, ABUSEIPDB_API_KEY, VIRUSTOTAL_API_KEY, NETRA_API_KEY, ARGUS_API_KEY, RAPIDAPI_KEY, HUDSONROCK_API_KEY

9 providers + RapidAPI + HudsonRock; all optional

MISP

MISP_URL, MISP_API_KEY, MISP_VERIFYCERT, MISP_CACHE_TTL, MISP_MIN_INTERVAL, MISP_MAX_CONCURRENT, MISP_TIMEOUT

internal sharing instance; read-only key. MISP_URL without MISP_API_KEY fails startup. VERIFYCERT defaults true and is scoped to the MISP pool only

Outbound lookup spacing

NETRA_MIN_INTERVAL, ARGUS_MIN_INTERVAL, SANGFOR_MIN_INTERVAL

seconds between upstream lookups — default 30/30/5

Outbound HTTP timeout

HTTP_TIMEOUT

seconds per upstream request — default 30. Netra overrides it per request at 90s because its fan-out measured ~34s. A timeout counts as a breaker failure, so a budget below real latency trips the breaker for that upstream

ATT&CK STIX bundle

MITRE_ATTACK_STIX, BLUETEAM_STIX_CACHE, BLUETEAM_STIX_MAX_AGE_DAYS, BLUETEAM_STIX_MAX_MB, BLUETEAM_STIX_RETRY_S

https:// URL or a local path (no file:///ftp://), cache path (default /var/log/blue-team-mcp/mitre_enterprise_attack.json), refresh TTL (7 days), fetch cap (100 MB — the corpus is 40 MB), retry after a failed first load (60s). A failed refresh keeps the last good bundle

Redaction

BLUETEAM_REDACTION_POLICY, BLUETEAM_OWNED_DOMAINS, BLUETEAM_REDACT_*

see Security & Privacy

Forensic gate

BLUETEAM_ALLOW_FORENSIC_BYPASS, BLUETEAM_FORENSIC_TOKEN

default false / empty

SSRF allowlist

ALLOWED_INTERNAL_DOMAINS

comma-separated internal domains blueteam_check_webshell may reach on non-public IPs (default: reject all non-public hosts)

Inbound auth

MCP_API_KEY, MCP_API_KEY_SCOPES

pre-shared API key + scopes for streamable_http

Inbound hardening

BLUETEAM_HTTP_RATE_LIMIT, BLUETEAM_ALLOWED_ORIGINS

per-IP sliding-window rate limit (req/min, 0=off) + Origin allowlist (loopback always allowed)

Audit & persistence

BLUETEAM_AUDIT_LOG, BLUETEAM_IOC_STORE, BLUETEAM_ATTACKER_REGISTRY, BLUETEAM_FALSE_POSITIVE_KB, BLUETEAM_CASE_STORE, BLUETEAM_CMDB_FILE

JSONL audit trail + stores (optional)

Local case RAG

BLUETEAM_RAG_ENABLED, BLUETEAM_RAG_DB, BLUETEAM_RAG_MODEL, BLUETEAM_RAG_CACHE_PATH, BLUETEAM_RAG_MAX_CANDIDATES, BLUETEAM_RAG_TOP_K, BLUETEAM_RAG_MAX_CHUNKS, BLUETEAM_RAG_CHUNK_CHARS, BLUETEAM_RAG_CHUNK_OVERLAP, BLUETEAM_RAG_ALLOW_DOWNLOAD, BLUETEAM_RAG_MODEL_SHA256

SQLite retrieval corpus over cases / confirmed false positives / IR playbooks. ENABLED=true requires an absolute DB path or startup raises. ALLOW_DOWNLOAD defaults false (local_files_only).

Gating

WAZUH_READ_ONLY, WAZUH_DISABLED_CATEGORIES, WAZUH_DISABLED_TOOLS

skip destructive tools / tool categories. The registered tool count changes with these. WAZUH_READ_ONLY=true skips the host_forensics (23 tools) and fail2ban (3 tools) modules at import, so the startup line reads 118 tools registered instead of 144: 144 - 23 - 3 = 118. Disabling a category via WAZUH_DISABLED_CATEGORIES subtracts that category's tools the same way. Each skip is logged at INFO with the category name, immediately before the count line. Nothing is hardcoded: the count comes from the live FastMCP registry after import


Capabilities

Wazuh SIEM

Alert search (blueteam_wazuh_indexer_search, wazuh_alert_dsl_query), zero-doc statistical aggregations, schema discovery (blueteam_index_schema), domain/email/geo/syscheck/compliance lookups, and Manager API tools (rules, decoders, groups, agents, security events).

Semantic Search & Prompt Routing

blueteam_semantic_search (BM25 over Wazuh rule/alert corpora) and blueteam_prompt_route (BM25 prompt→tool router). Both accept an opt-in rerank=true second stage — a local cross-encoder (BAAI/bge-reranker-base, ONNX) re-scores the BM25 candidates for synonym / cross-lingual matching. Gated by BLUETEAM_RERANK_ENABLED (default false); disabled / unavailable falls back to BM25-only. Weights are pre-downloaded by setup.sh into BLUETEAM_RERANK_CACHE_PATH — local-only, never a hosted API.

Truncation is rank-based with no score threshold: raw cross-encoder logits are uncalibrated across query distributions, so a fixed floor deletes good matches. BLUETEAM_RERANK_MAX_CANDIDATES (default 100) bounds the fan-out inside the shared rerank_hits() helper, so every caller is bounded identically.

Local Case RAG (blueteam_rag_*)

A local retrieval corpus over the SOC's own history — case_store records, confirmed false-positive reasons and converted IR playbooks — plus a deterministic false-positive check. Embeddings are computed in-process by ONNX (fastembed.TextEmbedding, no torch, no hosted embedding API), stored in SQLite, and never transmitted.

  • blueteam_rag_ingest — rebuild a corpus label. Derived labels (cases, false_positives) are rebuilt from their source of truth, so re-run it after editing cases or marking new FPs.

  • blueteam_rag_query — vector recall (default 100 candidates) then the optional cross-encoder, returning scores plus corpus stats so a stale index is visible.

  • blueteam_rag_fp_validate — verdict ladder over registry lookups and corpus matches: suppressed_exact, conflicting_state, likely_true_positive, likely_false_positive, insufficient_evidence, validation_incomplete. Advisory only, never writes, and evidence.confidence is always not_computed.

The distinction that matters: insufficient_evidence means the corpus was searched and came up short; validation_incomplete means it was never searched. Conflating them turns a broken store into a false-negative finding on a live alert.

3-Sum APT Correlation

three_sum_correlation runs two engines plus unified scoring:

  • Engine A — MITRE-driven multi-IoC risk thresholding. Alerts classify by rule.mitre.tactic (via MITRE_TACTIC_TO_CATEGORY) and rule.mitre.id (resolved through the ATT&CK STIX bundle), scored as rule.level × tactic weight, and gated by a ≥2-category chained-attack rule (threshold_score default 35).

  • Engine B — 3-source volumetric Z-score (MAD + shoulder-check) flagging simultaneous spikes.

  • Plus multi-resolution (1h/24h/7d), unified severity scoring, and Indexer degradation detection.

Threat Intelligence

9 providers — CrowdSec, ThreatFox, OTX, URLhaus, GreyNoise, AbuseIPDB, VirusTotal, Netra, Argus — with a unified blueteam_threat_intel_aggregate (concurrent fan-out) and a weighted blueteam_unified_threat_score. Plus stealer_log_check (HudsonRock) and jarm_fingerprint (TLS fingerprint for C2/malware attribution, no API key), and 3 RapidAPI lookups (blueteam_ip_blacklist, blueteam_ioc_search, blueteam_breach_check). blueteam_ip_blacklist is registered but no longer advertised in the SOC prompts: it is a third paid RapidAPI product, and blueteam_ioc_search already returns blacklist verdicts from ~89 engines.

blueteam_misp_ioc_lookup queries your own MISP instance over POST /attributes/restSearch (read-only). It is not part of blueteam_threat_intel_aggregate: the aggregate covers the six public providers, MISP is operator-owned, and the two can legitimately disagree. No pymisp dependency — the tool reuses _api_call plus the shared TTL cache and rate limiter. Community free text (comment, galaxy descriptions) is stripped before the result reaches the model.

Alert Enrichment

blueteam_wazuh_alert_summarize, blueteam_beacon_detect, blueteam_attack_chain, blueteam_threat_card, blueteam_wazuh_alert_compare, blueteam_curated_threat_report.

Vulnerability Management

CVE triage and remediation: NVD/EPSS/KEV/PoC enrichment (blueteam_cve_*), SSVC action bands (blueteam_cve_ssvc), dependency-manifest scanning against OSV (blueteam_dependency_scan), and vendor patch guidance (blueteam_cve_advisory — MSRC / Red Hat / Ubuntu). The LangGraph workflow runs this chain in its vuln step and folds vendor advisories into exported reports.

Investigation, Graphs & Workflows

blueteam_investigate_ip, blueteam_attack_graph (networkx clusters + PageRank), blueteam_pivot_suggest, blueteam_campaign_watch, blueteam_stix_killchain, blueteam_investigation_workflow and blueteam_playbook_run (LangGraph), plus a false-positive knowledge base (blueteam_false_positive_kb) that auto-suppresses known-noisy IOCs in 3-Sum. blueteam_investigation_workflow(..., check_false_positive=true) inserts the local RAG gate before enrichment; a suppressed_exact indicator short-circuits the run, so no report is generated for an alert an analyst already closed.

Host & Domain Forensics

WHOIS / CRT.sh, IOC extraction, JARM fingerprinting, typosquatting detection (blueteam_domain_permute), webshell scanning, server-side JSONL export, DOCX/XLSX/PPTX report export, and 23 host-forensics tools (log readers, fail2ban, rootkit scan, lynis, process/cron/users).

Detection Engineering (YARA)

blueteam_yara_rule_validate compiles a rule with yara-x and runs yaraQA-style checks. blueteam_yara_rule_generate drafts a rule from a Wazuh alert pattern (mode="alert", Indexer API), a sample under BLUETEAM_ALLOWED_PATHS (mode="file"), or raw text. blueteam_yara_rule_save writes a validated rule to the staging directory (BLUETEAM_YARA_RULES_DIR). Generation is read-only; saving needs the wazuh:write scope. A generated rule reports coverage: verified (self-scanned and matched its sample), unverified (compiled but did not match), or draft (no sample, logs only). Wazuh alerts are logs, so an alert-derived rule stays draft until it is tested against a real artifact.

Detection Engineering (Sigma)

blueteam_sigma_rule_generate drafts a Sigma rule from a Wazuh alert pattern (mode="alert", Indexer API) or analyst text (mode="text"), reporting coverage as draft or no-values. blueteam_sigma_rule_validate runs a YAML+schema check always and a pySigma parse when pySigma is installed, naming the stages that ran in engine. blueteam_sigma_rule_convert maps a rule to an OpenSearch artifact through the opensearch_lucene backend: lucene (query string), dsl (_search body), monitor (Dashboards alerting monitor), or saved_search. blueteam_sigma_rule_save writes the YAML to BLUETEAM_SIGMA_RULES_DIR and needs the wazuh:write scope.

Two Wazuh-specific notes. These are Wazuh-native Sigma rules, logsource.product: wazuh with Wazuh alert field names in detection, so they are not sigmaHQ-portable and upstream sigma check warns about the product. And blueteam_sigma_rule_generate probes the Indexer for fields it emits, reporting unmapped ones as finding SG9; a field the index does not know can never match.

Conversion needs the optional pySigma install (BLUETEAM_INSTALL_SIGMA=1, see requirements.txt). Without it the convert tool returns an install hint and the other three keep working. Artifacts target BLUETEAM_SIGMA_INDEX_PATTERN (wazuh-alerts-*), never the pySigma default beats-*; the response carries index_retargeted, and false means the upstream payload shape changed and the artifact may point at the wrong index.

Sigma to native Wazuh XML rules is out of scope and needs a written scope amendment. Promotion out of either staging directory is a manual, reviewed step.


Security & Privacy

Inbound authentication (streamable_http)

streamable_http is protected by a pre-shared API key in mcp_server/core/server_auth.py:

  • MCP_API_KEY — format btm_<43-char-urlsafe-base64> (47 chars). Stored only as a SHA-256 digest, compared with hmac.compare_digest (constant-time).

  • MCP_API_KEY_SCOPES — default wazuh:read (read-only). Add wazuh:write to unlock the 11 write tools (blueteam_fail2ban_unban, blueteam_case_*, blueteam_set_owned_domains, blueteam_mark_investigated, blueteam_wazuh_export, blueteam_export_report, blueteam_capture_traffic, blueteam_yara_rule_save, blueteam_sigma_rule_save). Fail-closed: no scope ⇒ read-only.

  • Bind guard (main.py::_start_http_transport): a non-loopback bind without MCP_API_KEY raises ConfigurationError and refuses to start. Loopback stays auth-less only when no key is configured; when a key is set it is enforced on every request.

  • JSON depth guard (parse_json_body_safe): every POST body is capped at 1 MB (MAX_BODY_BYTES) and rejected if nesting exceeds 100 levels (MAX_JSON_DEPTH) before json.loads runs — blocks the stack-exhaustion DoS from deeply nested JSON-RPC payloads.

  • Inbound rate limiter (SlidingWindowRateLimiter): per-client-IP sliding-window cap (BLUETEAM_HTTP_RATE_LIMIT, requests/min, default 0 = disabled) → 429 on excess. Distinct from BLUETEAM_RATE_LIMIT, which gates destructive tools (fail2ban unban, tcpdump capture) with a per-minute global cap.

  • Origin validation (_origin_allowed): an Origin header must be a loopback origin or in BLUETEAM_ALLOWED_ORIGINS (comma-separated exact origins), else 403. Blocks browser-based DNS-rebinding / localhost-exfiltration. Requests without an Origin header (non-browser clients) are unaffected. The middleware is always installed — rate limiting + origin validation apply even on an auth-less loopback bind.

Redaction policy

Three-state policy (BLUETEAM_REDACTION_POLICY, default protect_victim):

Policy

Behavior

full

Shape-based masking of emails, private IPs, all domains, paths, user-agents — conservative fallback when protect_victim has no owned domains

protect_victim

Mask only victim-owned indicators (owned domains, private IPs, identities); attacker IOCs stay visible. Recommended for SOC triage.

raw

Layer-1 credential strip only — hard-gated behind BLUETEAM_ALLOW_FORENSIC_BYPASS=true + BLUETEAM_FORENSIC_TOKEN

Layer 1 (credential stripping) applies in all states and is never bypassable. The attacker-IOC registry (core/attacker_registry.py) exempts confirmed attacker indicators from shape-based masking — never from Layer 1.

Two-tier unmasking on top of the policy:

  • Tier 1 — reveal_owned=true — reveals only owned *.tangerangkota.go.id assets to the LLM, and unmask owned-domain bucket keys in the aggregation tools (including wazuh_alert_dsl_query). Never expands beyond BLUETEAM_OWNED_DOMAINS.

  • Tier 2 — bypass_redaction=true + forensic_token — writes raw data to disk; the LLM receives only the file path, never the raw content.

Set BLUETEAM_OWNED_DOMAINS to your org's domains (comma-separated, e.g. tangerangkota.go.id). Inspect with blueteam_owned_domains; update at runtime with blueteam_set_owned_domains (gated by BLUETEAM_ALLOW_RUNTIME_DOMAINS=true, default off).


SOC Analysis Prompt (copy-paste for your LLM)

A ready-to-paste prompt for a local LLM connected to this MCP server. Two output formats — Markdown (inline) and DOCX (requires officecli, blueteam_export_report).

Canonical source of truth: resource/skill/soc-analysis.md. This block is a copy of that skill's body — update the skill, not this block, when the toolset changes.

# blue_team_mcp — SOC Analysis Skill

You are a TangerangKota-CSIRT SOC analyst with access to the `blue_team_mcp`
MCP server (`socMcp1`). The server wraps a Wazuh Indexer (alert data) + Wazuh
Manager (config/agent data) plus 7+ external threat-intel providers into 145
tools. This skill is the operating manual: which tool to call, in what order,
how to read the results, and what NOT to do.

## 0. First-call protocol (CRITICAL)

The client shows tools as **uninspected** on first use. The
first `tool_invoke` returns only the tool signature + docstring — **this is not
an error and not a hallucination**. It is the MCP inspection handshake.

Correct pattern, every time:

1. First call → you get `"hasn't been inspected yet — its signature is below"`.
2. **Read the signature** (it includes the exact parameter schema).
3. **Re-invoke immediately** with params matching the schema.

Do NOT: skip the tool, invent a different tool name, or report the tool as
broken. Always re-invoke once after the signature comes back.

## 1. Tool taxonomy (grouped by SOC function)

Choose the tool by what the analyst wants — never invent tools.

### Triage (single IP)
| Want | Tool |
|---|---|
| One-call full picture | `blueteam_threat_card(srcip, since="24h")` |
| Compact alert digest | `blueteam_wazuh_alert_summarize(srcip)` |
| Rule→rule progression | `blueteam_attack_chain(srcip, since)` |
| ATT&CK kill chain (STIX) | `blueteam_stix_killchain(srcip, since)` |
| Beaconing detection | `blueteam_beacon_detect(srcip)` |
| Compare two IPs | `blueteam_wazuh_alert_compare(srcip_a, srcip_b)` |
| Velocity (accelerating?) | `wazuh_attack_velocity(srcip)` |
| Timeline buckets | `wazuh_alert_timeline(srcip)` |
| Raw alert search (Indexer) | `blueteam_wazuh_indexer_search(...)` |
| Local alerts file (fallback Indexer) | `blueteam_wazuh_alerts(srcip, since, limit)` |

### Threat intel (enrichment)
| Want | Tool |
|---|---|
| 6 providers concurrently | `blueteam_threat_intel_aggregate(indicator)` |
| CrowdSec reputation | `crowdsec_ip_reputation(ip)` |
| Argus (aggregated sources) | `argus_ip_lookup(ip)` — renders **every** provider in the response with no hardcoded provider or field names, so a changed response shape still renders. Report comments are counted (`N text value(s), not expanded`), never printed; `response_format="json"` returns the verbatim payload when you need the comment text |
| GreyNoise scanner check | `greynoise_ip_context(ip)` |
| OTX pulse | `otx_lookup(indicator)` |
| URLhaus hash/URL | `urlhaus_hash_lookup` / `urlhaus_lookup` |
| Netra | `netra_ip_analysis(ip)` — 30s spaced, **90s** per-request budget because its multi-source fan-out legitimately takes ~34s |
| VirusTotal domain/hash | `blueteam_lookup_domain_virustotal` / `blueteam_lookup_hash_virustotal` |
| AbuseIPDB IP reputation | **no standalone tool** — AbuseIPDB runs inside `blueteam_unified_threat_score` (weight 0.30). Do not call a `*_abuseipdb` tool; it is not registered. |
| RapidAPI IOC search / breach | `blueteam_ioc_search` / `blueteam_breach_check` |
| MISP (own instance) | `blueteam_misp_ioc_lookup(value)` — read-only `restSearch` against your MISP. Returns the attributes an indicator appears in, plus tag names; `comment` and galaxy free text are stripped by an allowlist before you see them. Needs `MISP_URL` + `MISP_API_KEY`: when unset the tool raises at call time, so report "MISP not configured", never "no results". A header reading `Capability probe: version probe skipped` is a restricted version endpoint, not a failed lookup |

`blueteam_ioc_search` takes `detail_level`:
- `"summary"` (default) - verdict line first (malicious/total engines, band, tags, ASN), top 5 communicating files, sanitized WHOIS. Answers "is this IP bad" without reading further.
- `"forensic"` - every resolution and file, plus the flagged per-vendor verdicts. Use it only once the IP is a finding and you need to pivot on hashes or hostnames.
- `"raw"` - verbatim provider body for fields not yet mapped. WHOIS is still filtered. Always JSON.

It is unrelated to `threatfox_ioc_search` (different API, different quota). Both are metered; the `blueteam_threat_intel_aggregate` (six providers) does **not** include RapidAPI, so the two can disagree. Report both and name the source; never merge them into one verdict. `blueteam_ip_blacklist` is a third paid RapidAPI product that no longer appears in the report prompts - do not call it.

Netra and Argus lookups are spaced 30s apart, Sangfor 5s (`NETRA_MIN_INTERVAL` /
`ARGUS_MIN_INTERVAL` / `SANGFOR_MIN_INTERVAL`). Enriching N IPs costs N×interval — batch
only the IPs the analysis actually needs, and don't re-query an IP you already have.

Netra also gets a 90s per-request budget (the rest of the server runs on
`HTTP_TIMEOUT`, default 30s) because its fan-out across sources measured ~34s in
production. A 30s budget used to make every Netra lookup fail and trip its circuit
breaker. If a Netra call still times out at 90s, that is a real backend problem; the
error text tells you which budget applied and names the upstream host.

### CVE / vulnerability enrichment
When an alert or `blueteam_wazuh_vulnerabilities` surfaces a `CVE-YYYY-NNNN`,
enrich it with exploitation data the Indexer does not carry:
| Want | Tool |
|---|---|
| Full NVD record (desc, CVSS, refs) | `blueteam_cve_lookup(cve_id)` |
| Composite risk + patch urgency | `blueteam_cve_score(cve_id)` |
| SSVC action band (Act/Attend/Track*/Track) | `blueteam_cve_ssvc(cve_id, exposure="open")` |
| Exploitation probability (EPSS) | `blueteam_cve_epss(cve_ids=[...])` |
| CISA KEV (actively exploited?) | `blueteam_cve_kev(cve_id)` |
| Public PoC exists? (GitHub/Nuclei) | `blueteam_cve_poc(cve_id)` |
| CVE → ATT&CK techniques + groups | `blueteam_cve_attack_mapping(cve_id)` |
| Vendor remediation (MSRC/RedHat/Ubuntu) | `blueteam_cve_advisory(cve_id)` |
| Scan a dependency manifest for CVEs | `blueteam_dependency_scan(raw_text="<requirements.txt / package.json / pom.xml>")` |

`blueteam_cve_score` fans out NVD + EPSS + KEV + PoC in one call and returns a
0-100 score with a severity label. KEV membership forces CRITICAL.
`blueteam_cve_ssvc` walks the CISA Deployer SSVC tree and returns an action band
with an explainable rationale — `Act` means patch now, `Track` means schedule.
`blueteam_cve_advisory` returns MSRC / Red Hat / Ubuntu patch guidance
(RHSA / USN IDs). `blueteam_dependency_scan` parses a manifest and maps every
package to live CVEs via OSV — feed the returned `cve_ids` to the tools above.
No API key required (optional `NVD_API_KEY` / `GITHUB_TOKEN` raise rate limits).

The investigation workflow auto-extracts CVEs from alert text (and, when given
`dependency_manifest`, discovers more via `blueteam_dependency_scan`), enriches
each with score + SSVC + attack mapping in its `vuln` step, and feeds the
techniques into `three_sum_correlation` Engine A as a `vuln_boost` category
signal — a KEV-listed CVE lands at ~8-10 in its ATT&CK category, never a hard
gate. SSVC stays advisory metadata, never a correlation input.

### Correlation / APT detection
| Want | Tool |
|---|---|
| 3-Sum Engine A+B | `three_sum_correlation(time_window_minutes, ...)` |
| Campaign clusters/hubs | `blueteam_attack_graph(window_days)` |
| Campaign evolution | `blueteam_campaign_watch()` |
| Next pivot suggestion | `blueteam_pivot_suggest(ioc)` |
| STIX relationship analysis | `blueteam_stix_analyze(technique_id="T1059.001")` → which actors use the technique + its mitigations; `actor_name="Lazarus"` → that actor's TTPs and campaigns |
| Baseline drift | `blueteam_baseline_drift(...)` |
| FP knowledge base | `blueteam_false_positive_kb()` |
| Known-noise check (local corpus) | `blueteam_rag_fp_validate(srcip, description)` |

> ATT&CK tactic names follow the installed bundle release — `Stealth` and `Defense Impairment`
> replaced `Defense Evasion` in ATT&CK v18. Map a tactic to its 3-Sum category by meaning, not by
> exact string match, and report the tactic as the alert spells it. If a `blueteam_stix_*` call
> returns a STIX load error, report ATT&CK enrichment as unavailable for that pass, note it in the
> report, and continue with the remaining tools — do not retry in a loop. A missing `rule.mitre.id`
> on the alerts is the more common cause and it is worth reporting on its own.

### Investigation / case management
| Want | Tool |
|---|---|
| Full langgraph workflow | `blueteam_investigation_workflow(srcip or alert_text or dependency_manifest)` |
| Rebuild the local case corpus | `blueteam_rag_ingest(source="cases"\|"false_positives"\|"text", texts, label)` |
| Search prior cases / playbooks | `blueteam_rag_query(query, sources, rerank)` |
| Comprehensive IP profile | `blueteam_investigate_ip(srcip)` |
| Record verdict | `blueteam_mark_investigated(...)` |
| Case lifecycle | `blueteam_case_create/get/list/add_iocs/add_verdict` |
| History | `blueteam_investigation_history` / `blueteam_investigation_summary` |

`blueteam_investigation_workflow` **requires at least one** of `alert_text`,
`srcip`, or `dependency_manifest`. A no-target call is rejected with a
validation error (`"Provide 'alert_text', 'srcip', or 'dependency_manifest'..."`),
not an internal crash. Give it a target and re-invoke.

Pass `check_false_positive=true` to consult the local corpus before enrichment. A
`suppressed_exact` or `conflicting_state` verdict short-circuits the run and **no report is
generated** — correct for an alert an analyst already closed, surprising if you expected one.
Any other verdict is recorded in `fp_validation` and the investigation continues.

### Local case RAG (opt-in, needs `BLUETEAM_RAG_ENABLED` + `BLUETEAM_RAG_DB`)
| Want | Tool |
|---|---|
| "Have we seen this before?" | `blueteam_rag_query(query="ssh brute force mail server")` |
| Search only confirmed noise | `blueteam_rag_query(query=..., sources=["false_positives"])` |
| Search only IR guidance | `blueteam_rag_query(query="ransomware containment steps", sources=["ir_playbooks"])` |
| Is this alert noise? | `blueteam_rag_fp_validate(srcip="8.8.8.8", description="ssh auth failure")` |
| Refresh the index | `blueteam_rag_ingest(source="cases")` |

Read the `verdict` before acting on it. `suppressed_exact`, `conflicting_state` and
`likely_true_positive` are authoritative (registry lookups, no model). `likely_false_positive` is
**advisory** — confirm the matched cases describe the same activity. `insufficient_evidence` means
the corpus was searched and came up short; `validation_incomplete` means it was **never searched**
(store down, model failed, node timed out) and those two are not interchangeable. `evidence.confidence`
is always `not_computed`; there is no calibrated probability in this pipeline, so never quote one.

Nothing here auto-closes an alert. Record the decision with `blueteam_mark_investigated`.
Re-run `blueteam_rag_ingest` after editing cases or marking new false positives — the index is
derived and does not notice edits on its own.

### Email / breach / domain forensics
| Want | Tool |
|---|---|
| Top targeted emails | `wazuh_email_lookup(...)` |
| Email ↔ attacker IP | `wazuh_compromised_emails_analysis(emails)` |
| Breach check (RapidAPI) | `blueteam_breach_check(email)` |
| Stealer log (HudsonRock) | `stealer_log_check(email)` |
| Domain lookup in alerts | `wazuh_domain_lookup(domain)` |
| Typosquat variants | `blueteam_domain_permute(domain)` |
| WHOIS / CRT.sh | `blueteam_whois_lookup` / `blueteam_crtsh_lookup` |

### Filtered reporting (analyst intent → one tool)
`blueteam_curated_threat_report(filters={...})` is the single entry point for
filtered reports. All conditions collapse into `filters` (AND semantics):

| Analyst says | `filters` |
|---|---|
| "from Indonesia" | `{"geo_country": "Indonesia"}` |
| "targeting *.go.id" | `{"domain_pattern": "*.go.id"}` |
| "subdomain tangerangkota" | `{"domain_contains": "tangerangkota"}` |
| "critical only" | `{"rule_level_min": 10}` |
| "medium and above" | `{"rule_level_min": 5}` |
| "rule 600029 only" | `{"rule_ids": ["600029"]}` |
| "POST only" | `{"http_methods": ["POST"]}` |
| "blocked 403" | `{"response_codes": ["403"]}` |
| "exclude scanner IP" | `{"exclude_srcips": ["203.0.113.42"]}` |
| "known-bad CrowdSec" | `{"min_crowdsec_reputation": "malicious"}` |

Group by domain → `group_by="domain"`, per IP → `"srcip"` (default), per agent
→ `"agent"`, per rule → `"rule.id"`. Time aliases: "1h"/"24h"/"7d"/"30d".

### Geo / scanning / host forensics (read-only, no auto-mitigation)

| Want | Tool |
|---|---|
| Geo distribution / heatmap | `blueteam_wazuh_geo_distribution`, `blueteam_wazuh_geo_heatmap` |
| FIM / compliance / vulns | `blueteam_wazuh_syscheck`, `blueteam_wazuh_compliance`, `blueteam_wazuh_vulnerabilities` |
| Webshell scan | `blueteam_check_webshell(url)` |
| Fail2ban state | `blueteam_fail2ban_status`, `blueteam_fail2ban_jail_status`, `blueteam_fail2ban_unban` |
| Process / connection / user inventory | `blueteam_list_processes`, `blueteam_list_connections`, `blueteam_list_listening_ports`, `blueteam_list_users`, `blueteam_list_cron_jobs`, `blueteam_who_is_logged_in`, `blueteam_last_logins` |
| Failed/brute login history | `blueteam_failed_logins`, `blueteam_sudo_history` |
| Log review | `blueteam_journalctl`, `blueteam_read_syslog`, `blueteam_read_auth_log`, `blueteam_read_web_log` |
| Privilege / persistence | `blueteam_find_suid_files`, `blueteam_find_world_writable`, `blueteam_check_ssh_authorized_keys` |
| Malware / integrity | `blueteam_rootkit_scan`, `blueteam_lynis_audit`, `blueteam_hash_file`, `blueteam_check_updates` |
| System state | `blueteam_system_health`, `blueteam_check_open_firewall` |
| Packet capture | `blueteam_capture_traffic` |
| Playbook / PDF conversion | `blueteam_document_convert(path)` — Marker (scanned-PDF OCR): playbook / advisory PDF → markdown/JSON/html/chunks (`page_range` for docs longer than the response cap; `mode="table"` → JSON) |
| Office / data file → markdown | `blueteam_markitdown_convert(path)` — MarkItDown (no OCR, no torch): docx / pptx / xlsx / xls / msg / html / csv / json / xml / digital PDF → markdown. Image-only PDFs return an error — route those to `blueteam_document_convert` |

`blueteam_check_webshell(url)` only accepts **public** hosts by default — any URL whose
host resolves to a private / loopback / link-local / CGNAT address is rejected. To scan a
webshell on **your own infrastructure** (e.g. `subdomain.tangerangkota.go.id` resolving to
RFC1918), the operator must add that domain to `ALLOWED_INTERNAL_DOMAINS` on the server.
Every hop is resolved once and IP-pinned (`curl --resolve`), so DNS-rebinding and
redirect-to-internal are both blocked. If a URL is rejected, report "host is non-public /
not allowlisted — operator must add it to ALLOWED_INTERNAL_DOMAINS", don't retry the URL.

### Extended toolbox (long tail — don't invent names)

| Tool | What it does |
|---|---|
| `blueteam_ai_bot_recon` | surface AI/LLM-driven reconnaissance & scanning |
| `jarm_fingerprint` | active TLS server fingerprinting (no API key) |
| `blueteam_unified_threat_score(indicator)` | CrowdSec+ThreatFox+AbuseIPDB → single 0.0–1.0 score |
| `blueteam_threat_hunt` | named DSL query templates per adversary technique |
| `blueteam_semantic_search` | BM25 ranking over Wazuh rules/alerts; `rerank=true` adds a local cross-encoder (bge-reranker-base) for cross-lingual matching |
| `blueteam_prompt_route` | BM25 prompt→tool router; `rerank=true` re-scores candidates with the same cross-encoder |
| `blueteam_mitre_lookup` | ATT&CK technique/group lookup |
| `blueteam_asset_context` | CMDB asset criticality / owner |
| `blueteam_false_positive_tracker(rule_id)` | rule_id → FP-summary cross-reference |
| `sangfor_blocklist_check` / `sangfor_blocklist_list(ip=…, date_start, date_end, limit, offset)` | Sangfor firewall blocklist (list POSTs `{date_start,date_end,limit,offset,ip}` to `/blocklist`) |
| `blueteam_baseline_profile` / `blueteam_calendar_heatmap` | day×hour scheduled-attack profiling |
| `blueteam_extract_iocs` / `blueteam_ioc_lifecycle` | IOC extraction & lifecycle store (local, free) |
| `blueteam_ioc_search(detail_level="summary"\|"forensic"\|"raw")` | RapidAPI IOC lookup - verdict-first summary by default; WHOIS stripped to technical registry fields at every level (no `person`/`address`/`phone`/`fax-no`). Metered; 403 = not subscribed |
| `blueteam_ip_blacklist` | Registered but deprioritized - a separate paid RapidAPI product, redundant with `blueteam_ioc_search` for blacklist verdicts |
| `wazuh_alert_focused_crawl` | surgical alert deep-dive (`rule_id`/`src_ip`/`sample_size`) |
| `wazuh_alert_aggregate_analysis` | zero-doc full-index statistical summary |
| `wazuh_alert_dsl_query` | raw OpenSearch DSL (script-injection guarded) |
| `threatfox_ioc_search` / `_bulk` | direct ThreatFox search (vs the 6-provider aggregate) |
| `crowdsec_ip_reputation_bulk` / `otx_lookup_bulk` / `urlhaus_lookup_bulk` | bulk enrich up to N IOCs |
| `blueteam_index_schema` | discover index field mappings |
| `blueteam_wazuh_export` | scroll-export alerts to JSONL |
| `blueteam_wazuh_agents` / `blueteam_wazuh_agents_summary` | Manager API: agent inventory and last-seen summary |
| `blueteam_wazuh_get_agent_sca` / `blueteam_wazuh_get_sca_policy_checks` / `blueteam_wazuh_list_sca_policies` | SCA policies and per-agent check results |
| `blueteam_wazuh_get_rules` / `blueteam_wazuh_get_rule_files` / `blueteam_wazuh_get_rule_file_content` | Ruleset files and their content |
| `blueteam_wazuh_get_decoders` / `blueteam_wazuh_get_groups` | Decoders and rule groups |
| `blueteam_wazuh_get_security_events` / `blueteam_wazuh_manager_logs` / `blueteam_wazuh_get_cluster_nodes` | Security events, manager logs, cluster nodes |
| `blueteam_metrics` | Prometheus metrics |
| `blueteam_playbook_run` | run a named playbook workflow |
| `blueteam_export_report` | export a report to DOCX/XLSX/PPTX (officecli) |
| `blueteam_owned_domains` / `blueteam_set_owned_domains` | view/set the runtime owned (victim) domains for `protect_victim` redaction |
| `blueteam_yara_rule_validate(rule_source)` | compile a rule with yara-x + yaraQA-style findings (naming, short atoms, `fullword` misuse) |
| `blueteam_yara_rule_generate(mode, …)` | draft a rule from a Wazuh alert pattern (`mode="alert"`), a sample under `BLUETEAM_ALLOWED_PATHS` (`mode="file"`), or raw text |
| `blueteam_yara_rule_save(rule_source, …)` | write a VALIDATED rule to the staging dir (`BLUETEAM_YARA_RULES_DIR`); needs `wazuh:write` |
| `blueteam_sigma_rule_generate(mode, …)` | draft a Sigma rule from a Wazuh alert pattern (`mode="alert"`) or raw text (`mode="text"`). Returns `coverage="draft"` or `"no-values"` |
| `blueteam_sigma_rule_validate(rule_source)` | YAML + schema check, plus a pySigma parse when pySigma is installed. `engine` names the stages that ran |
| `blueteam_sigma_rule_convert(rule_source, output_format)` | Sigma → OpenSearch: `lucene` (query string), `dsl` (`_search` body), `monitor` (Dashboards alerting monitor), `saved_search` |
| `blueteam_sigma_rule_save(rule_source, …)` | write the YAML to the staging dir (`BLUETEAM_SIGMA_RULES_DIR`); needs `wazuh:write` |

### Resources (read via MCP resource reads, not tool calls)

| URI | What it provides |
|---|---|
| `wazuh://rules/taxonomy` | Wazuh rule taxonomy — rule IDs grouped by category/groups |
| `wazuh://mitre/attack` | MITRE ATT&CK tactic/technique mapping (feeds 3-Sum Engine A) |
| `metrics://prometheus` | Server telemetry (tool-call counters, timings) in Prometheus text format |
| `metrics://prometheus/json` | Same telemetry as a JSON snapshot |

## 2. Standard investigation workflows

### Workflow A — IP triage (fast, 2 calls)
```
1. blueteam_threat_card(srcip="X", since="24h")
2. blueteam_threat_intel_aggregate(indicator="X")   # if intel missing from card
```

### Workflow B — deep dive (forensic)
```
1. blueteam_wazuh_alert_summarize(srcip="X", since="7d")
2. blueteam_attack_chain(srcip="X", since="7d")
3. blueteam_stix_killchain(srcip="X", since="7d")
4. blueteam_stix_analyze(technique_id="<top T-id from step 3>")   # who uses it + mitigations
5. blueteam_investigation_workflow(srcip="X", window="7d", use_attack_graph=true)
```

### Workflow C — campaign hunt (APT)
```
1. three_sum_correlation(time_window_minutes=10080, response_format="json")
2. blueteam_attack_graph(window_days=30, top_n=20)
3. blueteam_pivot_suggest(ioc="<triggered-ip>")
4. blueteam_campaign_watch()   # diff vs previous snapshot
```

### Workflow D — compromised email
```
1. wazuh_email_lookup(top_n=20, since="7d", reveal_owned=true)
2. wazuh_compromised_emails_analysis(emails=["<top emails>"], enrich_with_netra=false)
3. blueteam_breach_check(email="<official dinas email>")
4. stealer_log_check(email="<official dinas email>")
```

### Workflow E — vulnerability triage (manifest → patch)
```
1. blueteam_dependency_scan(raw_text="<paste requirements.txt / package.json / pom.xml>", response_format="json")
2. blueteam_cve_score(cve_id="<top CVE>")          # or blueteam_cve_ssvc for an action band
3. blueteam_cve_attack_mapping(cve_id="<top CVE>") # MITRE techniques → 3-Sum Engine A
4. blueteam_cve_advisory(cve_id="<top CVE>")       # vendor patch guidance (RHSA / USN)
```

### Workflow F — detection engineering (webshell / sample → YARA)
```
1. blueteam_check_webshell(url="https://<host>/<file>.php")   # or blueteam_hash_file(path)
2. blueteam_yara_rule_generate(mode="file", file_path="/opt/samples/<file>", self_scan=true)
3. # logs only, no sample yet:
   blueteam_yara_rule_generate(mode="alert", srcip="X", since="7d")
4. blueteam_yara_rule_validate(rule_source="<edited rule>")   # after your edits
5. blueteam_yara_rule_save(rule_source="<final rule>")         # staging, needs wazuh:write
```

Read `coverage` before you trust a rule. `verified` means the rule self-scanned and
matched the sample. `unverified` means it compiled but did not match its own sample, so
the strings are wrong. `draft` means it came from alert text or raw text with no
sample; check `alert_field_coverage` and get an artifact before deploying. A rule in
the staging directory is not loaded by Wazuh until an operator promotes it by hand to
`wazuh-rules-dev`.

### Workflow G — detection engineering (alert pattern → Sigma → OpenSearch)
```
1. blueteam_sigma_rule_generate(mode="alert", srcip="X", since="24h")   # or mode="text"
2. # read coverage, unmapped_fields, field_coverage, existing_rules before continuing
3. blueteam_sigma_rule_validate(rule_source="<edited rule>")   # engine: schema+pysigma | schema-only
4. blueteam_sigma_rule_convert(rule_source="<final rule>", output_format="lucene")   # or dsl/monitor/saved_search
5. blueteam_sigma_rule_save(rule_source="<final rule>")       # staging, needs wazuh:write
```

Use Sigma when the pattern is expressible as field/value pairs and you also want a query
or a Dashboards monitor. Use YARA (Workflow F) when you have an artifact to match.

These are **Wazuh-native** Sigma rules: `logsource.product` is `wazuh` and `detection`
carries Wazuh alert field names such as `data.url`. They are not sigmaHQ-portable, and
Sigma → native Wazuh XML is out of scope for this server.

Four things to check, in this order:

1. `coverage` — `draft` came from logs or text, so review the modifiers. `no-values`
   means nothing usable was harvested: the detection block holds a placeholder and the
   rule matches nothing. Never deploy it.
2. `unmapped_fields` (finding `SG9`) — the index does not know those fields, so the
   query can never match. Fix the field names before converting.
3. `field_coverage` all zero — the deployment's decoders do not populate the harvested
   fields, so the draft was built from nothing.
4. `existing_rules` — Manager rules that already cover this description. Decide whether
   new detection logic is actually needed.

A converted query is a starting point, not a finished detection. A `cidr` modifier
becomes a literal Lucene term (`data.srcip:10.0.0.0\/8`), which OpenSearch reads as a
string rather than a network match; rewrite those clauses as `term` or `range` before
running them. The tool prints a warning when it sees one.

Read `index_retargeted` on every conversion. `false` means the upstream saved-search payload
shape changed and the artifact may still target `beats-*`; the tool also prints a WARNING and
names the configured index. Inspect the `index` field before importing into Dashboards.

Conversion needs the optional pySigma dependency (`BLUETEAM_INSTALL_SIGMA=1`). Without it
the convert tool returns an install hint, and generate/validate/save keep working.

## 3. Redaction & the forensic token (read before touching PII)

The server masks PII/credentials in 6 layers plus a `protect_victim` extension
(bare hostname/agent-name masking). Layer 1 (credentials) is **never
bypassable**. Policies:

- `full` (default): mask emails, private IPs, all domains, paths, UAs.
- `protect_victim`: mask **only** victim-owned indicators (owned domains), keep
  attacker IOCs/payload intact. **Requires `BLUETEAM_OWNED_DOMAINS` set** —
  otherwise the server silently falls back to `full`.
- `raw`: Layer-1 strip only. **Hard-gated** behind `BLUETEAM_ALLOW_FORENSIC_BYPASS`
  AND `BLUETEAM_FORENSIC_TOKEN`.

The runtime owned-domains set (used by `protect_victim`) is viewable/settable
at runtime via `blueteam_owned_domains` / `blueteam_set_owned_domains` — the
env var `BLUETEAM_OWNED_DOMAINS` only sets the initial value.

**Forensic token rule**: the token lives in the *server's* env
(`BLUETEAM_FORENSIC_TOKEN`) — you cannot read it. To use `raw` or full unmask,
the operator must pass it as a parameter:

```json
{"redaction_policy": "raw", "forensic_token": "<token>", "reveal_owned": true}
```

If the operator set a token but you don't know its value, the call returns
`"raw/forensic bypass requires the operator forensic token"`. That is **correct
behavior** — ask the operator for the token value, or have them pass it in the
prompt. Do NOT claim the env var is broken.

To partially unmask owned domains without `raw`, use `reveal_owned=true` +
`redaction_policy="protect_victim"` (no token needed).

## 4. Reading 3-Sum correlation results

`three_sum_correlation` has two engines:

- **Engine A** — per-IP weighted risk across MITRE categories:
  - A = recon/resource-dev/discovery (weakest)
  - B = initial-access/exec/priv-esc/defense-evasion/credential-access/lateral-move (mid)
  - C = persistence/collection/C2/exfiltration/impact (strongest)
  - An IP triggers only when **≥2 categories** AND weighted score ≥ threshold.
- **Engine B** — volumetric Z-score across all 3 sources simultaneously
  (default Z ≥ 2.5; the 7-day window runs at 2.0).

Final severity is **volume-based**, not the per-IP score:
`unified_score = engine_a_triggers + engine_b_anomalies + overlap_bonus` (capped 10).

| unified_score | severity | action |
|---|---|---|
| 0 | NONE | — |
| 1–2 | LOW | watch |
| 3–5 | MEDIUM | investigate |
| 6–8 | HIGH | active IR |
| 9–10 | CRITICAL | full incident declaration |

Key reads from the `stats` block:
- `multi_category_count` = IPs in ≥2 categories (this gates triggering).
- `intersection_count` = IPs in **all 3** (A∩B∩C) — rarest, highest confidence,
  triage immediately **regardless of score**.
- `triggers_count` = IPs that actually passed the gate (actionable set).
- Always `multi_category_count >= intersection_count`.

**`_degraded: true` → Indexer unreachable → severity=NONE means *unknown*, not
*clean*.** Never report "no threats" from a degraded run.

Conservative production defaults (validated): `time_window_minutes=10080`,
`threshold_score=35` (dynamic rule.level × MITRE-tactic-weight scaling),
`z_score_threshold=2.5`. Note the 7-day tier loosens to `z_score_threshold=2.0`.
Do not lower below these without production telemetry evidence.

## 5. Error handling — what each error actually means

| Error | Meaning | Correct action |
|---|---|---|
| **any tool result with `isError: true`** | the tool failed; the text is a diagnostic, **not a finding** | report the failure and the named cause. Never read an error string as a verdict |
| `"hasn't been inspected yet"` | MCP handshake, not an error | re-invoke with matching params |
| `"circuit breaker open for '<upstream>' (N consecutive failures)"` | that one upstream failed N times in a row. The name is the pool: a URL host (`otx.alienvault.com`, `urlhaus.abuse.ch`, the Netra host) or an explicit pool (`argus`, `rapidapi`, `indexer`, `wazuh`). Breakers are per upstream, so everything else still works | skip that provider, name it in the report, retry the same call after 60s |
| `"Request timed out after <N>s for <host>"` | the call exceeded its budget. `<N>` is the budget actually applied (global `HTTP_TIMEOUT`, default 30s; 90s for Netra), `<host>` is the upstream | a timeout is a slow or unreachable upstream, never a finding. Retry once; if it repeats, report the upstream as degraded |
| `"Access forbidden (403) ... not subscribed to this API"` | RapidAPI key is valid but that specific product was never subscribed | use a different RapidAPI tool, or tell the operator to subscribe. Check the `url:` in the error to see which product was called |
| `"Rate limit reached (429)"` | quota exhausted for that provider (per-product on RapidAPI) | read the `x-ratelimit-*` fields in the error; do not retry immediately |
| `"tool not available in this request"` | client didn't expose that tool this session | use an equivalent tool or note it |
| `"raw/forensic bypass requires ... token"` | correct gate behavior | pass the token value (see §3) |
| missing-key provider errors | provider skipped gracefully in `errors[]` | report partial result, note which provider skipped |
| `"MISP_URL and MISP_API_KEY must both be set to use MISP tools"` | MISP is not configured on this server | report it as "MISP not configured". Never as "no results". Ask the operator to set the env vars |
| `"Provide 'alert_text', 'srcip', or 'dependency_manifest'"` | `blueteam_investigation_workflow` called with no target | pass one of the three targets and re-invoke |
| `"Marker conversion failed: ... llama-server binary not found"` | surya's OCR VLM backend spawns the external llama.cpp binary, which is absent | install llama-server on the host (ggml-org/llama.cpp releases) and set `LLAMA_CPP_BINARY` (e.g. `Environment="LLAMA_CPP_BINARY=/usr/local/bin/llama-server"`) in the service, then restart |
| `"Marker conversion failed: ... fast_layout server failed to become healthy ... operator torchvision::nms does not exist"` | torchvision's compiled `_C` extension did not load: the venv's torch/torchvision versions do not match, so Marker's surya subprocess crashes at import | on the host, reinstall the pinned CPU pair: `pip install "torch==2.14.0" "torchvision==0.29.0" --index-url https://download.pytorch.org/whl/cpu`, then check `python -c "import torch, torchvision; torch.ops.torchvision.nms"` and restart `blue-team-mcp.service` |

A failing tool raises, so the MCP client marks the result `isError: true`. Provider
error text is a diagnostic — never a result. Threat-intel providers fail
**independently**: a missing API key never blocks the rest of the aggregation — it
appears in the `errors[]` list. Read it and say so in the report.

### 5a. Circuit breaker recovery workflow

The circuit breaker trips after 5 consecutive transport/5xx failures to a
backend (Wazuh Indexer, threat-intel API). Once open, it refuses all requests
for 60 seconds (`recovery_timeout`), then allows exactly **one** half-open
trial. If that trial succeeds (any HTTP response including 4xx), the breaker
closes. If it fails, the timer resets.

```
┌──────────────┐    5 consecutive     ┌──────────────┐
│   CLOSED     │ ──────────────────▶  │    OPEN      │
│  (normal)    │    failures           │  (fail fast) │
└──────────────┘                      └──────┬───────┘
       ▲                                     │
       │         half-open trial              │  60s elapsed
       │         succeeds (any HTTP)          │
       └─────────────────────────────────────┘
```

**When you hit a circuit-breaker error in a session:**

1. **Identify which upstream is down.** The error names it:
   `"circuit breaker open for '<host>' (N consecutive failures)"`. Threat-intel
   tools are keyed by URL host, so you get `otx.alienvault.com`,
   `urlhaus.abuse.ch`, `packages.ecosyste.ms`, or the Netra host rather than one
   shared pool. Explicitly named pools: `argus`, `rapidapi`, `indexer`, `wazuh`.
   An open breaker on one host says nothing about the others.

2. **Check the failure count.** `"(10 consecutive failures)"` = breaker tripped
   at 5, stayed open through a half-open trial, tripped again. This means the
   backend has been unreachable for **at least 2 minutes** (5 attempts +
   60s timeout + second 5 attempts).

3. **Stop calling that pool.** Every call while the breaker is open returns
   `CircuitOpenError` instantly — zero network I/O. Calling again does nothing
   and wastes tokens. Wait at least 60 seconds from the last error before
   retrying.

4. **Use tools that don't hit the dead backend.** Breakers are per upstream, so
   this is usually free: if the Indexer breaker is open, switch to threat-intel
   tools, and vice versa. Only same-host callers are affected — if Netra's host
   breaker is open, the other providers on it are too.

5. **The breaker is self-healing.** Once the backend recovers, the next
   half-open trial succeeds and the breaker closes automatically. There is no
   manual reset command — just wait and retry.

**What NOT to do:**
- Don't call `blueteam_breach_check` repeatedly when the breaker is open —
  each call fails instantly with the same error.
- Don't restart the server hoping to clear the breaker — breakers are
  in-memory per pool. Restarting an MCP server mid-session is worse than
  waiting (it breaks the JSON-RPC channel).
- Don't report "all tools broken" — name the specific pool and what tools
  still work.

**Circuit breaker state by pool (see `mcp_server/core/http_client.py`):**

| Pool key | Typical tools | Backend |
|---|---|---|
| URL host (default) | CrowdSec, OTX, AbuseIPDB, VirusTotal, URLhaus, GreyNoise, WHOIS/RDAP/CRT.sh, Netra | Derived from the request URL host when the caller passes no `client_name`, so unrelated upstreams never share one breaker |
| `rapidapi` | `blueteam_ioc_search`, `blueteam_breach_check`, `blueteam_ip_blacklist` | Own pool and own breaker. Products have separate subscriptions and separate quotas |
| `indexer` | alert search, geo, timeline, correlation, email/domain alert lookup | Wazuh Indexer (OpenSearch) |
| `wazuh` | agent/rule/SCA queries | Wazuh Manager API |
| `argus` | Argus IP lookup | Argus threat-intel API (standalone pool) |

### 5b. Forensic token escalation path

The forensic token (`BLUETEAM_FORENSIC_TOKEN`) is a shared secret between the
server operator and the server. The LLM cannot read server environment
variables — it must receive the token explicitly.

**Escalation ladder (least → most privileged):**

```
Level 0: No unmask
  → redaction_policy="full" (default)
  → All PII masked. Suitable for routine analysis.

Level 1: Owned-domain unmask (no token needed)
  → reveal_owned=true, redaction_policy="protect_victim"
  → Emails/subdomains at owned domains unmasked.
  → Attacker IOCs stay visible, victim PII masked.
  → No token required if BLUETEAM_OWNED_DOMAINS is set.
  → Falls back silently to "full" if owned domains not configured.

Level 2: Full forensic unmask (token required)
  → redaction_policy="raw", forensic_token="<token>"
  → ONLY Layer 1 credentials stay masked.
  → Everything else — emails, IPs, domains, paths, UAs — RAW.
  → Requires BOTH BLUETEAM_ALLOW_FORENSIC_BYPASS=true on server
    AND the operator to pass the token value.
```

**When the LLM hits the token gate:**

```
Error: "raw/forensic bypass requires the operator forensic token
        (BLUETEAM_FORENSIC_TOKEN). Pass forensic_token=<token>."
```

1. **Don't retry without the token.** The server correctly rejected the call.
   Retrying with the same params produces the same error.

2. **Report to the operator exactly what you need:**
   > "To unmask full alert data (raw policy), pass `forensic_token=<value>`
   > as a parameter. The token was set on the server's
   > `BLUETEAM_FORENSIC_TOKEN` env var — I cannot read it. If you provide
   > the value, I will include it in tool calls. Alternatively, I can use
   > `reveal_owned=true` with `redaction_policy='protect_victim'` which
   > needs no token and partially unmasks owned domains."

3. **Offer the lower-privilege alternative immediately** — `reveal_owned=true`
   often answers the same question without the escalation.

4. **Never guess the token.** It's validated server-side; wrong values produce
   the same error. Guessing wastes calls.

5. **Once the operator provides the token**, include it in every call that
   needs it:
   ```json
   {"forensic_token": "<value>", "redaction_policy": "raw", "reveal_owned": true}
   ```

The token is a single string — same value for all tools. The operator can
provide it once at session start and you reuse it across calls.

## 6. Output conventions

- Default `response_format="markdown"` for analyst-facing reports; **always
  `"json"`** when piping into follow-up tools.
- Export a finished report to DOCX/XLSX/PPTX with `blueteam_export_report`
  (officecli) — markdown/JSON are the in-session formats; officecli is for
  deliverables.
- Never claim a tool "succeeded" without evidence of execution. If a tool needs
  a live credential and fails, state "not verified — requires valid key/cluster".
- **Redacted-but-real protocol**: for PII-adjacent data (citizen IP, email),
  don't print raw values beyond operational need; partial-mask in shared docs.
- This server is **defensive only** — no tool auto-blocks IPs. Recommend
  "add to watchlist / manual firewall block" and never claim auto-mitigation.

## 7. Golden rules (hard)

1. Re-invoke after every "hasn't been inspected" signature.
2. Read `errors[]` and `_degraded` before reporting conclusions.
3. Never claim a clean verdict from a degraded/missing-credential run.
4. Forensic token must be **passed as a param**; you can't read server env.
5. `reveal_owned=true` ≠ `raw`; use the least-privileged unmask that answers the question.
6. Don't invent tools — §1 lists the common surface and the Extended toolbox
   covers the long tail. For anything else, verify the exact name via the
   tool's signature before calling.

Requirements

  • Python 3.11+

  • mcp, httpx[http2], pydantic, networkx, langgraph, officecli-sdk

  • See requirements.txt.

  • Optional, Marker PDF→markdown document conversion (blueteam_document_convert): setup.sh installs it only when BLUETEAM_INSTALL_MARKER=1. Version-pinned stack, see the marker block in setup.sh (do not float): torch==2.14.0 + torchvision==0.29.0 matching CPU pair from the pytorch CPU index, marker-pdf==2.0.0, numpy<2, scipy<1.14, scikit-learn>=1.6.1,<2, pillow<11. A floating torch/torchvision pair crashes Marker's surya subprocess (operator torchvision::nms does not exist, 300s timeout per conversion). numpy<2 is required because numpy 2.x removed np.long (AttributeError inside surya/transformers); scikit-learn<2 still resolves to a version that satisfies marker-pdf 2.0.0's >=1.6.1 requirement. Scanned-page OCR needs the external llama-server binary (llama.cpp, not pip-installable): install it on the host and set LLAMA_CPP_BINARY in the service env, or OCR fails with llama-server binary not found. First run downloads the surya models (multi-GB HuggingFace download); set BLUETEAM_PREWARM_MARKER=1 to fetch them at install time.

  • Optional, MarkItDown office/data → markdown (blueteam_markitdown_convert): setup.sh installs it only when BLUETEAM_INSTALL_MARKITDOWN=1 (no torch, no model downloads): pip install "markitdown[pdf,docx,pptx,xlsx,xls,outlook]". Local formats only — URLs, .zip, .epub, audio and YouTube are held out of v1.


Development

  • Before merge: python3 check_guardrails.py --strict must exit 0, and logging stays on stderr.


🤝💸💎 Sponsored by

Kiyararouter Every model. One beautiful API.

Kiyararouter is the modern OpenAI-compatible gateway for teams building with AI. Connect once, ship faster, and stay flexible.

Related MCP Connectors

Related MCP Servers

  • A
    license
    B
    quality
    A
    maintenance
    An MCP server for the Wazuh SIEM/XDR platform that enables users to query agents, security alerts, detection rules, and decoders through Claude or other MCP clients. It provides specialized tools and prompts for investigating security alerts, performing agent health checks, and generating environmental security overviews.
    28
    10 npm
    4
    MIT
  • A
    license
    Not graded
    quality
    A
    maintenance
    An MCP server that exposes a 60+ tool security and threat-intel stack to AI agents, enabling secret scanning, Sigma rule generation, ransomware lookup, OSINT, and deep research.
    1
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    An MCP server that transforms Claude Code into an autonomous DFIR analyst by providing typed, audited forensic tools for disk, memory, timeline, registry, and IOC analysis on the SANS SIFT Workstation.
    Apache 2.0
  • A
    license
    Not graded
    quality
    D
    maintenance
    This MCP server connects Claude Desktop to a Velociraptor instance and local forensic tools. It enables remote endpoint investigation and local evidence analysis through natural language commands.
    1
    MIT