|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+#!/usr/bin/env python3
|
|
|
2
|
+"""
|
|
|
3
|
+test-wazuh-iris-sync.py — End-to-end test: Wazuh → soc-integrator → IRIS alert sync.
|
|
|
4
|
+
|
|
|
5
|
+Steps:
|
|
|
6
|
+ 1. Send test syslog events to Wazuh (optional, skip with --no-send)
|
|
|
7
|
+ 2. Wait for Wazuh indexer to index them
|
|
|
8
|
+ 3. Call POST /wazuh/sync-to-mvp
|
|
|
9
|
+ 4. Verify IRIS alerts were created with source="wazuh"
|
|
|
10
|
+ 5. Print a pass/fail summary
|
|
|
11
|
+
|
|
|
12
|
+Usage:
|
|
|
13
|
+ python3 scripts/test-wazuh-iris-sync.py
|
|
|
14
|
+ python3 scripts/test-wazuh-iris-sync.py --no-send # skip sending, just sync
|
|
|
15
|
+ python3 scripts/test-wazuh-iris-sync.py --min-severity high
|
|
|
16
|
+ python3 scripts/test-wazuh-iris-sync.py --minutes 60 # widen search window
|
|
|
17
|
+
|
|
|
18
|
+Env vars (override defaults):
|
|
|
19
|
+ INTEGRATOR_URL default: http://localhost:8088
|
|
|
20
|
+ INTEGRATOR_API_KEY default: dev-internal-key
|
|
|
21
|
+ IRIS_URL default: https://localhost:8443
|
|
|
22
|
+ IRIS_API_KEY required for IRIS verification (or set in soc-integrator/.env)
|
|
|
23
|
+"""
|
|
|
24
|
+from __future__ import annotations
|
|
|
25
|
+
|
|
|
26
|
+import argparse
|
|
|
27
|
+import json
|
|
|
28
|
+import os
|
|
|
29
|
+import ssl
|
|
|
30
|
+import subprocess
|
|
|
31
|
+import sys
|
|
|
32
|
+import time
|
|
|
33
|
+import urllib.request
|
|
|
34
|
+from pathlib import Path
|
|
|
35
|
+
|
|
|
36
|
+
|
|
|
37
|
+# ---------------------------------------------------------------------------
|
|
|
38
|
+# Config
|
|
|
39
|
+# ---------------------------------------------------------------------------
|
|
|
40
|
+
|
|
|
41
|
+INTEGRATOR_URL = os.environ.get("INTEGRATOR_URL", "http://localhost:8088")
|
|
|
42
|
+INTEGRATOR_KEY = os.environ.get("INTEGRATOR_API_KEY", "dev-internal-key")
|
|
|
43
|
+IRIS_URL = os.environ.get("IRIS_URL", "https://localhost:8443")
|
|
|
44
|
+
|
|
|
45
|
+# Try to read IRIS_API_KEY from env, then from soc-integrator/.env
|
|
|
46
|
+def _read_iris_key() -> str:
|
|
|
47
|
+ if k := os.environ.get("IRIS_API_KEY"):
|
|
|
48
|
+ return k
|
|
|
49
|
+ env_file = Path(__file__).parent.parent / "soc-integrator" / ".env"
|
|
|
50
|
+ if env_file.exists():
|
|
|
51
|
+ for line in env_file.read_text().splitlines():
|
|
|
52
|
+ if line.startswith("IRIS_API_KEY="):
|
|
|
53
|
+ return line.split("=", 1)[1].strip()
|
|
|
54
|
+ return ""
|
|
|
55
|
+
|
|
|
56
|
+IRIS_KEY = _read_iris_key()
|
|
|
57
|
+
|
|
|
58
|
+SSL_CTX = ssl.create_default_context()
|
|
|
59
|
+SSL_CTX.check_hostname = False
|
|
|
60
|
+SSL_CTX.verify_mode = ssl.CERT_NONE
|
|
|
61
|
+
|
|
|
62
|
+PASS = "\033[32m✓\033[0m"
|
|
|
63
|
+FAIL = "\033[31m✗\033[0m"
|
|
|
64
|
+INFO = "\033[36m·\033[0m"
|
|
|
65
|
+
|
|
|
66
|
+
|
|
|
67
|
+# ---------------------------------------------------------------------------
|
|
|
68
|
+# Helpers
|
|
|
69
|
+# ---------------------------------------------------------------------------
|
|
|
70
|
+
|
|
|
71
|
+def _get(url: str, headers: dict | None = None) -> dict:
|
|
|
72
|
+ req = urllib.request.Request(url, headers=headers or {})
|
|
|
73
|
+ with urllib.request.urlopen(req, context=SSL_CTX, timeout=15) as r:
|
|
|
74
|
+ return json.loads(r.read())
|
|
|
75
|
+
|
|
|
76
|
+
|
|
|
77
|
+def _post(url: str, data: dict | None = None, headers: dict | None = None) -> dict:
|
|
|
78
|
+ body = json.dumps(data or {}).encode() if data else b""
|
|
|
79
|
+ h = {"Content-Type": "application/json", **(headers or {})}
|
|
|
80
|
+ req = urllib.request.Request(url, data=body, headers=h, method="POST")
|
|
|
81
|
+ with urllib.request.urlopen(req, context=SSL_CTX, timeout=30) as r:
|
|
|
82
|
+ return json.loads(r.read())
|
|
|
83
|
+
|
|
|
84
|
+
|
|
|
85
|
+def _put(url: str, data: dict, headers: dict | None = None) -> dict:
|
|
|
86
|
+ body = json.dumps(data).encode()
|
|
|
87
|
+ h = {"Content-Type": "application/json", **(headers or {})}
|
|
|
88
|
+ req = urllib.request.Request(url, data=body, headers=h, method="PUT")
|
|
|
89
|
+ with urllib.request.urlopen(req, context=SSL_CTX, timeout=15) as r:
|
|
|
90
|
+ return json.loads(r.read())
|
|
|
91
|
+
|
|
|
92
|
+
|
|
|
93
|
+def _integrator(path: str, method: str = "GET", data: dict | None = None, params: str = "") -> dict:
|
|
|
94
|
+ url = f"{INTEGRATOR_URL}{path}"
|
|
|
95
|
+ if params:
|
|
|
96
|
+ url += ("&" if "?" in url else "?") + params
|
|
|
97
|
+ headers = {"X-Internal-API-Key": INTEGRATOR_KEY}
|
|
|
98
|
+ if method == "POST":
|
|
|
99
|
+ return _post(url, data, headers)
|
|
|
100
|
+ if method == "PUT":
|
|
|
101
|
+ return _put(url, data or {}, headers)
|
|
|
102
|
+ return _get(url, headers)
|
|
|
103
|
+
|
|
|
104
|
+
|
|
|
105
|
+def _iris_alerts(page: int = 1, per_page: int = 20) -> list[dict]:
|
|
|
106
|
+ url = f"{INTEGRATOR_URL}/iris/alerts?page={page}&per_page={per_page}&sort_by=alert_id&sort_dir=desc"
|
|
|
107
|
+ data = _get(url)
|
|
|
108
|
+ return (data.get("data") or {}).get("alerts", {}).get("data", [])
|
|
|
109
|
+
|
|
|
110
|
+
|
|
|
111
|
+def step(n: int, label: str) -> None:
|
|
|
112
|
+ print(f"\n\033[1mStep {n}: {label}\033[0m")
|
|
|
113
|
+
|
|
|
114
|
+
|
|
|
115
|
+def ok(msg: str) -> None:
|
|
|
116
|
+ print(f" {PASS} {msg}")
|
|
|
117
|
+
|
|
|
118
|
+
|
|
|
119
|
+def fail(msg: str) -> None:
|
|
|
120
|
+ print(f" {FAIL} {msg}")
|
|
|
121
|
+
|
|
|
122
|
+
|
|
|
123
|
+def info(msg: str) -> None:
|
|
|
124
|
+ print(f" {INFO} {msg}")
|
|
|
125
|
+
|
|
|
126
|
+
|
|
|
127
|
+# ---------------------------------------------------------------------------
|
|
|
128
|
+# Main test
|
|
|
129
|
+# ---------------------------------------------------------------------------
|
|
|
130
|
+
|
|
|
131
|
+def run(args: argparse.Namespace) -> int:
|
|
|
132
|
+ errors = 0
|
|
|
133
|
+
|
|
|
134
|
+ # ------------------------------------------------------------------
|
|
|
135
|
+ # Step 0: Health check
|
|
|
136
|
+ # ------------------------------------------------------------------
|
|
|
137
|
+ step(0, "Health check")
|
|
|
138
|
+ try:
|
|
|
139
|
+ h = _get(f"{INTEGRATOR_URL}/health")
|
|
|
140
|
+ if h.get("ok"):
|
|
|
141
|
+ ok(f"soc-integrator reachable at {INTEGRATOR_URL}")
|
|
|
142
|
+ else:
|
|
|
143
|
+ fail(f"soc-integrator unhealthy: {h}")
|
|
|
144
|
+ errors += 1
|
|
|
145
|
+ except Exception as exc:
|
|
|
146
|
+ fail(f"Cannot reach soc-integrator: {exc}")
|
|
|
147
|
+ return 1
|
|
|
148
|
+
|
|
|
149
|
+ # ------------------------------------------------------------------
|
|
|
150
|
+ # Step 1: Read current sync policy
|
|
|
151
|
+ # ------------------------------------------------------------------
|
|
|
152
|
+ step(1, "Read sync policy")
|
|
|
153
|
+ try:
|
|
|
154
|
+ policy_resp = _integrator("/wazuh/sync-policy")
|
|
|
155
|
+ current_min = policy_resp["data"]["sync"]["min_severity"]
|
|
|
156
|
+ ok(f"Current min_severity = {current_min!r}")
|
|
|
157
|
+ except Exception as exc:
|
|
|
158
|
+ fail(f"Could not read sync policy: {exc}")
|
|
|
159
|
+ errors += 1
|
|
|
160
|
+ current_min = "medium"
|
|
|
161
|
+
|
|
|
162
|
+ # ------------------------------------------------------------------
|
|
|
163
|
+ # Step 2: Optionally send test events to Wazuh
|
|
|
164
|
+ # ------------------------------------------------------------------
|
|
|
165
|
+ step(2, "Send test events to Wazuh")
|
|
|
166
|
+ if args.no_send:
|
|
|
167
|
+ info("Skipped (--no-send)")
|
|
|
168
|
+ else:
|
|
|
169
|
+ script = Path(__file__).parent / "test-firewall-syslog.py"
|
|
|
170
|
+ if not script.exists():
|
|
|
171
|
+ info("test-firewall-syslog.py not found — skipping send")
|
|
|
172
|
+ else:
|
|
|
173
|
+ try:
|
|
|
174
|
+ result = subprocess.run(
|
|
|
175
|
+ [sys.executable, str(script), "--via-docker", "--scenario", args.scenario],
|
|
|
176
|
+ capture_output=True, text=True, timeout=30,
|
|
|
177
|
+ )
|
|
|
178
|
+ sent = result.stdout.count("✓")
|
|
|
179
|
+ if sent:
|
|
|
180
|
+ ok(f"Sent {sent} test event(s) (scenario={args.scenario})")
|
|
|
181
|
+ else:
|
|
|
182
|
+ fail(f"No events sent\n{result.stdout}\n{result.stderr}")
|
|
|
183
|
+ errors += 1
|
|
|
184
|
+ except Exception as exc:
|
|
|
185
|
+ fail(f"Failed to send test events: {exc}")
|
|
|
186
|
+ errors += 1
|
|
|
187
|
+
|
|
|
188
|
+ # ------------------------------------------------------------------
|
|
|
189
|
+ # Step 3: Wait for Wazuh indexer
|
|
|
190
|
+ # ------------------------------------------------------------------
|
|
|
191
|
+ step(3, f"Wait {args.wait}s for Wazuh indexer")
|
|
|
192
|
+ if args.no_send:
|
|
|
193
|
+ info("Skipped (--no-send)")
|
|
|
194
|
+ else:
|
|
|
195
|
+ for i in range(args.wait, 0, -5):
|
|
|
196
|
+ print(f" {INFO} {i}s remaining...", end="\r", flush=True)
|
|
|
197
|
+ time.sleep(min(5, i))
|
|
|
198
|
+ print()
|
|
|
199
|
+ ok("Done")
|
|
|
200
|
+
|
|
|
201
|
+ # ------------------------------------------------------------------
|
|
|
202
|
+ # Step 4: Snapshot IRIS alert count before sync
|
|
|
203
|
+ # ------------------------------------------------------------------
|
|
|
204
|
+ step(4, "Snapshot IRIS alert count before sync")
|
|
|
205
|
+ try:
|
|
|
206
|
+ alerts_before = _iris_alerts(per_page=5)
|
|
|
207
|
+ max_id_before = max((a["alert_id"] for a in alerts_before), default=0)
|
|
|
208
|
+ ok(f"Latest IRIS alert_id before sync: {max_id_before}")
|
|
|
209
|
+ except Exception as exc:
|
|
|
210
|
+ info(f"Could not read IRIS alerts (verification will be skipped): {exc}")
|
|
|
211
|
+ max_id_before = 0
|
|
|
212
|
+
|
|
|
213
|
+ # ------------------------------------------------------------------
|
|
|
214
|
+ # Step 5: Run sync
|
|
|
215
|
+ # ------------------------------------------------------------------
|
|
|
216
|
+ step(5, "Run sync")
|
|
|
217
|
+ min_sev = args.min_severity or current_min
|
|
|
218
|
+ params = f"limit={args.limit}&minutes={args.minutes}&q=*&min_severity={min_sev}"
|
|
|
219
|
+ try:
|
|
|
220
|
+ resp = _integrator("/wazuh/sync-to-mvp", method="POST", params=params)
|
|
|
221
|
+ s = resp["data"]["sync"]
|
|
|
222
|
+ info(f"min_severity_applied : {s['min_severity_applied']}")
|
|
|
223
|
+ info(f"processed : {s['processed']}")
|
|
|
224
|
+ info(f"skipped_existing : {s['skipped_existing']}")
|
|
|
225
|
+ info(f"skipped_filtered : {s.get('skipped_filtered', 0)}")
|
|
|
226
|
+ info(f"ingested : {s['ingested']}")
|
|
|
227
|
+ info(f"iris_alert_ids : {s['iris_alert_ids']}")
|
|
|
228
|
+ if s.get("errors"):
|
|
|
229
|
+ fail(f"Sync errors: {s['errors']}")
|
|
|
230
|
+ errors += 1
|
|
|
231
|
+ else:
|
|
|
232
|
+ ok("Sync completed without errors")
|
|
|
233
|
+ except Exception as exc:
|
|
|
234
|
+ fail(f"Sync request failed: {exc}")
|
|
|
235
|
+ return errors + 1
|
|
|
236
|
+
|
|
|
237
|
+ # ------------------------------------------------------------------
|
|
|
238
|
+ # Step 6: Verify new IRIS alerts
|
|
|
239
|
+ # ------------------------------------------------------------------
|
|
|
240
|
+ step(6, "Verify IRIS alerts")
|
|
|
241
|
+ if not s["iris_alert_ids"]:
|
|
|
242
|
+ if s["ingested"] == 0 and s["skipped_existing"] == s["processed"]:
|
|
|
243
|
+ ok("All alerts already synced (no duplicates created) — dedup working")
|
|
|
244
|
+ elif s["skipped_filtered"] > 0 and s["ingested"] == 0:
|
|
|
245
|
+ ok(f"All new alerts filtered by min_severity={min_sev} — filter working")
|
|
|
246
|
+ else:
|
|
|
247
|
+ info("No new IRIS alerts created this run")
|
|
|
248
|
+ else:
|
|
|
249
|
+ try:
|
|
|
250
|
+ alerts_after = _iris_alerts(per_page=10)
|
|
|
251
|
+ new_alerts = [a for a in alerts_after if a["alert_id"] > max_id_before and a.get("alert_source") == "wazuh"]
|
|
|
252
|
+ if new_alerts:
|
|
|
253
|
+ ok(f"Found {len(new_alerts)} new IRIS alert(s) with source=wazuh:")
|
|
|
254
|
+ for a in new_alerts:
|
|
|
255
|
+ print(f" alert_id={a['alert_id']} ref={a.get('alert_source_ref','')} title={a.get('alert_title','')[:55]}")
|
|
|
256
|
+ else:
|
|
|
257
|
+ fail(f"iris_alert_ids={s['iris_alert_ids']} but no matching IRIS alerts found")
|
|
|
258
|
+ errors += 1
|
|
|
259
|
+ except Exception as exc:
|
|
|
260
|
+ fail(f"Could not verify IRIS alerts: {exc}")
|
|
|
261
|
+ errors += 1
|
|
|
262
|
+
|
|
|
263
|
+ # ------------------------------------------------------------------
|
|
|
264
|
+ # Step 7: Auto-sync status
|
|
|
265
|
+ # ------------------------------------------------------------------
|
|
|
266
|
+ step(7, "Auto-sync worker status")
|
|
|
267
|
+ try:
|
|
|
268
|
+ st = _integrator("/wazuh/auto-sync/status")["data"]
|
|
|
269
|
+ info(f"enabled : {st['enabled']}")
|
|
|
270
|
+ info(f"task_running : {st['task_running']}")
|
|
|
271
|
+ info(f"min_severity : {st['settings']['min_severity']}")
|
|
|
272
|
+ if st.get("state", {}).get("last_status"):
|
|
|
273
|
+ info(f"last_status : {st['state']['last_status']}")
|
|
|
274
|
+ ok("Auto-sync status retrieved")
|
|
|
275
|
+ except Exception as exc:
|
|
|
276
|
+ fail(f"Could not read auto-sync status: {exc}")
|
|
|
277
|
+ errors += 1
|
|
|
278
|
+
|
|
|
279
|
+ # ------------------------------------------------------------------
|
|
|
280
|
+ # Summary
|
|
|
281
|
+ # ------------------------------------------------------------------
|
|
|
282
|
+ print()
|
|
|
283
|
+ print("─" * 60)
|
|
|
284
|
+ if errors == 0:
|
|
|
285
|
+ print(f" {PASS} All checks passed")
|
|
|
286
|
+ else:
|
|
|
287
|
+ print(f" {FAIL} {errors} check(s) failed")
|
|
|
288
|
+ print("─" * 60)
|
|
|
289
|
+ return errors
|
|
|
290
|
+
|
|
|
291
|
+
|
|
|
292
|
+# ---------------------------------------------------------------------------
|
|
|
293
|
+# CLI
|
|
|
294
|
+# ---------------------------------------------------------------------------
|
|
|
295
|
+
|
|
|
296
|
+if __name__ == "__main__":
|
|
|
297
|
+ parser = argparse.ArgumentParser(description="End-to-end test: Wazuh → soc-integrator → IRIS")
|
|
|
298
|
+ parser.add_argument("--no-send", action="store_true", help="Skip sending test events to Wazuh")
|
|
|
299
|
+ parser.add_argument("--scenario", default="rdp", help="Firewall scenario to send (default: rdp)")
|
|
|
300
|
+ parser.add_argument("--wait", type=int, default=20, help="Seconds to wait for indexer (default: 20)")
|
|
|
301
|
+ parser.add_argument("--minutes", type=int, default=5, help="Sync lookback window in minutes (default: 5)")
|
|
|
302
|
+ parser.add_argument("--limit", type=int, default=20, help="Max alerts to sync (default: 20)")
|
|
|
303
|
+ parser.add_argument("--min-severity", default=None,
|
|
|
304
|
+ help="Override min_severity for this run (default: use policy)")
|
|
|
305
|
+ args = parser.parse_args()
|
|
|
306
|
+ sys.exit(run(args))
|