main
py 174 lines 7.56 KB
Raw
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()