| 1 | """ |
| 2 | In-memory cache for Wazuh Manager rules — powers the "Wazuh Rules" tab of the |
| 3 | Detections Catalog. |
| 4 | |
| 5 | Why this exists separately from ``RulesCache`` (CoPilot Searches): |
| 6 | - Different source: Wazuh Manager REST API, not a GitHub repo. We reuse the |
| 7 | existing connector wrapper ``get_wazuh_rules()`` so there's only one place |
| 8 | in the codebase that knows how to call ``GET /rules``. |
| 9 | - Different cadence: Wazuh rules change only on operator action (XML upload, |
| 10 | rule enable/disable). A long TTL (1 hour) is generous; the data is |
| 11 | effectively static between operator events. |
| 12 | - Different failure mode: the Wazuh Manager may be unconfigured or down on a |
| 13 | given deployment. We must NOT raise out of the catalog code path in that |
| 14 | case — the Stories tab and the rest of CoPilot keep working without the |
| 15 | Wazuh tab. The cache holds an ``unavailable_reason`` string so the UI can |
| 16 | render an explanatory empty state instead of a generic error. |
| 17 | |
| 18 | Auth note: |
| 19 | The wrapped connector route (``/wazuh_manager/rules``) is admin-only via |
| 20 | ``Security(...scopes=["admin"])``. The catalog deliberately calls the underlying |
| 21 | service function ``get_wazuh_rules()`` instead, because scope checks live on |
| 22 | the route handler, not the service. That keeps the catalog viewable by |
| 23 | analysts / customer_user roles without loosening the Wazuh management surface. |
| 24 | """ |
| 25 | |
| 26 | import asyncio |
| 27 | from datetime import datetime |
| 28 | from datetime import timedelta |
| 29 | from typing import Any |
| 30 | from typing import Dict |
| 31 | from typing import List |
| 32 | from typing import Optional |
| 33 | |
| 34 | from loguru import logger |
| 35 | |
| 36 | # Long TTL by design — Wazuh rules don't change without operator action. A |
| 37 | # manual ``refresh()`` is exposed for the rare "I just uploaded a rule file" |
| 38 | # case; we don't poll aggressively. |
| 39 | CACHE_TTL_MINUTES = 60 |
| 40 | |
| 41 | |
| 42 | class WazuhRulesCache: |
| 43 | """ |
| 44 | Single-source-of-truth in-memory copy of the Wazuh Manager ruleset, keyed |
| 45 | by integer rule ID, with the same ``ensure_loaded`` / ``refresh`` / |
| 46 | ``last_refresh`` surface as the CoPilot Searches ``RulesCache`` so the |
| 47 | catalog aggregator code can treat them symmetrically. |
| 48 | """ |
| 49 | |
| 50 | def __init__(self) -> None: |
| 51 | # rule_id (int) -> raw rule dict (the Wazuh API "affected_items" entry) |
| 52 | self._rules: Dict[int, Dict[str, Any]] = {} |
| 53 | self._last_refresh: Optional[datetime] = None |
| 54 | # When Wazuh is unreachable / not configured, populated with a short |
| 55 | # human-readable reason so the catalog UI can show an empty state |
| 56 | # instead of bubbling a 5xx. None means "last load succeeded". |
| 57 | self._unavailable_reason: Optional[str] = None |
| 58 | self._lock = asyncio.Lock() |
| 59 | |
| 60 | # ---- introspection ---------------------------------------------------- |
| 61 | |
| 62 | @property |
| 63 | def is_stale(self) -> bool: |
| 64 | if self._last_refresh is None: |
| 65 | return True |
| 66 | return (datetime.utcnow() - self._last_refresh) > timedelta(minutes=CACHE_TTL_MINUTES) |
| 67 | |
| 68 | @property |
| 69 | def last_refresh(self) -> Optional[datetime]: |
| 70 | return self._last_refresh |
| 71 | |
| 72 | @property |
| 73 | def rules_count(self) -> int: |
| 74 | return len(self._rules) |
| 75 | |
| 76 | @property |
| 77 | def is_available(self) -> bool: |
| 78 | """True if the last load succeeded — i.e. the catalog can render rule rows.""" |
| 79 | return self._unavailable_reason is None |
| 80 | |
| 81 | @property |
| 82 | def unavailable_reason(self) -> Optional[str]: |
| 83 | return self._unavailable_reason |
| 84 | |
| 85 | # ---- loading ---------------------------------------------------------- |
| 86 | |
| 87 | async def ensure_loaded(self) -> None: |
| 88 | """Lazy-load on first access; refresh on TTL expiry. Never raises.""" |
| 89 | if self.is_stale: |
| 90 | await self.refresh() |
| 91 | |
| 92 | async def refresh(self) -> int: |
| 93 | """ |
| 94 | Pull the full Wazuh ruleset into memory in one shot. |
| 95 | |
| 96 | Returns the number of rules loaded (0 if Wazuh is unavailable). |
| 97 | Never raises — failures are captured in ``unavailable_reason`` and |
| 98 | the existing cached snapshot (if any) is left in place rather than |
| 99 | wiped, so a transient Wazuh outage doesn't blank the UI. |
| 100 | """ |
| 101 | # Local import to avoid a hard dependency at module import time — |
| 102 | # keeps copilot_searches importable even on deployments where the |
| 103 | # wazuh_manager package isn't usable (e.g. unit tests). |
| 104 | from app.connectors.wazuh_manager.services.rules import get_wazuh_rules |
| 105 | |
| 106 | async with self._lock: |
| 107 | logger.info("Refreshing Wazuh rules cache from Wazuh Manager…") |
| 108 | try: |
| 109 | # limit=100000 is the Wazuh API ceiling; one call gets the |
| 110 | # entire ruleset for any realistic deployment (~3–5k rules). |
| 111 | # offset stays at 0. |
| 112 | response = await get_wazuh_rules(limit=100000, offset=0) |
| 113 | except Exception as exc: # noqa: BLE001 — we deliberately catch all |
| 114 | self._unavailable_reason = _short_reason(exc) |
| 115 | logger.warning( |
| 116 | f"Wazuh rules cache refresh failed: {self._unavailable_reason}. " "Keeping previously cached snapshot (if any).", |
| 117 | ) |
| 118 | # Still mark refresh time so we don't hammer Wazuh on every |
| 119 | # request when it's down; user can force ``refresh()`` to retry. |
| 120 | self._last_refresh = datetime.utcnow() |
| 121 | return len(self._rules) |
| 122 | |
| 123 | # Success — fully replace the cache. Don't merge: we want |
| 124 | # disabled / deleted rules to disappear too. |
| 125 | new_rules: Dict[int, Dict[str, Any]] = {} |
| 126 | for item in response.results: |
| 127 | # The schema is Pydantic — convert to plain dict so downstream |
| 128 | # aggregators get a uniform shape (.dict() not .model_dump() |
| 129 | # because both work in v2 and the older spelling reads less |
| 130 | # surprising to anyone who hasn't tracked the v1→v2 rename). |
| 131 | rule_dict = item.model_dump(by_alias=False) |
| 132 | rid = rule_dict.get("id") |
| 133 | if isinstance(rid, int): |
| 134 | new_rules[rid] = rule_dict |
| 135 | |
| 136 | self._rules = new_rules |
| 137 | self._unavailable_reason = None |
| 138 | self._last_refresh = datetime.utcnow() |
| 139 | logger.info(f"Loaded {len(self._rules)} Wazuh rules into cache") |
| 140 | return len(self._rules) |
| 141 | |
| 142 | # ---- accessors -------------------------------------------------------- |
| 143 | |
| 144 | def get_all_rules(self) -> List[Dict[str, Any]]: |
| 145 | """Return every cached rule as a plain dict (caller-owned, safe to mutate).""" |
| 146 | return list(self._rules.values()) |
| 147 | |
| 148 | def get_rule(self, rule_id: int) -> Optional[Dict[str, Any]]: |
| 149 | return self._rules.get(rule_id) |
| 150 | |
| 151 | |
| 152 | def _short_reason(exc: BaseException) -> str: |
| 153 | """ |
| 154 | Trim noisy exception messages down to a UI-friendly one-liner. |
| 155 | |
| 156 | Wazuh connector errors typically wrap an httpx/HTTPException and include |
| 157 | full URLs + stack-trace-ish detail. We want something the empty-state |
| 158 | component can show inline without exploding the layout. |
| 159 | """ |
| 160 | raw = str(exc).strip() or exc.__class__.__name__ |
| 161 | # Common cases — keep them short and actionable. |
| 162 | lower = raw.lower() |
| 163 | if "connection" in lower or "timed out" in lower or "timeout" in lower: |
| 164 | return "Wazuh Manager is not reachable." |
| 165 | if "401" in raw or "403" in raw or "unauthor" in lower: |
| 166 | return "Wazuh Manager rejected the credentials." |
| 167 | if "not configured" in lower or "not found" in lower: |
| 168 | return "Wazuh Manager connector is not configured." |
| 169 | # Generic fallback — truncate aggressively so it fits in a tooltip. |
| 170 | return raw[:160] + ("…" if len(raw) > 160 else "") |
| 171 | |
| 172 | |
| 173 | # Module-level singleton — symmetrical with ``rules_cache`` in copilot_searches. |
| 174 | wazuh_rules_cache = WazuhRulesCache() |