tum %!s(int64=2) %!d(string=před) týdny
rodič
revize
5e215c08f6

+ 2 - 0
compose-overrides/soc-integrator.yml

36
       - LOG_LEVEL=${LOG_LEVEL:-INFO}
36
       - LOG_LEVEL=${LOG_LEVEL:-INFO}
37
     ports:
37
     ports:
38
       - "${SOC_INTEGRATOR_PORT:-8088}:8080"
38
       - "${SOC_INTEGRATOR_PORT:-8088}:8080"
39
+    volumes:
40
+      - ../scripts:/app/scripts:ro
39
     depends_on:
41
     depends_on:
40
       soc-integrator-db:
42
       soc-integrator-db:
41
         condition: service_healthy
43
         condition: service_healthy

+ 96 - 0
progress-update.md

475
 
475
 
476
 - `GET /docs`
476
 - `GET /docs`
477
 - `GET /openapi.json`
477
 - `GET /openapi.json`
478
+
479
+---
480
+
481
+Date: March 4, 2026
482
+Project: FoodProject SOC Platform (Wazuh + Shuffle + IRIS-web + SOC Integrator)
483
+
484
+## Appendix C (C1-C3) Production Log Mapping Update
485
+
486
+This update documents production log sources and required fields for Appendix C detections implemented in `soc-integrator`.
487
+
488
+### C1. Impossible Travel Detection
489
+
490
+- Use case:
491
+  - `C1-01` Impossible Travel
492
+- Primary production log sources:
493
+  - VPN authentication success logs
494
+  - Active Directory / Windows authentication success logs (`event_id=4624`)
495
+  - Cloud IdP login success logs (Entra/Okta/Google Workspace)
496
+- Required normalized fields:
497
+  - `asset.user`
498
+  - `network.src_ip`
499
+  - `timestamp`
500
+  - login success indicator (`payload.success=true` or equivalent)
501
+  - geo context (`network.country` and `network.src_lat/src_lon`) or GeoIP enrichment from source IP
502
+- Detection logic summary:
503
+  - Compare consecutive successful logins for same user
504
+  - Calculate distance and travel time
505
+  - Trigger when computed travel speed exceeds threshold (`c1_max_travel_speed_kmph`)
506
+
507
+### C2. Advanced Credential Abuse & Privilege Misuse
508
+
509
+- Use cases:
510
+  - `C2-01` Privileged off-hours login
511
+  - `C2-02` Dormant account activation
512
+  - `C2-03` Service account interactive logon
513
+  - `C2-04` Rapid privilege escalation followed by sensitive access
514
+- Primary production log sources:
515
+  - Windows Security logs (`4624`, `4672`, `4728`, `4732`, `5145`)
516
+  - Linux auth/sudo/PAM logs
517
+  - VPN/IdP authentication logs
518
+- Required normalized fields:
519
+  - `asset.user`, `asset.is_admin`, `asset.is_service`
520
+  - `payload.logon_type`, `payload.event_id`, `payload.action`, `payload.success`
521
+  - `network.src_ip`, `network.dst_host`, `network.dst_port`
522
+  - `timestamp`
523
+
524
+### C3. Lateral Movement & Internal Reconnaissance
525
+
526
+- Use cases:
527
+  - `C3-01` Multi-host authentication success burst
528
+  - `C3-02` SMB/RDP lateral movement burst pattern
529
+  - `C3-03` Admin account accessing many servers rapidly
530
+  - `C3-04` Internal scanning/enumeration burst
531
+- Primary production log sources:
532
+  - Windows authentication and share access logs
533
+  - East-west firewall telemetry
534
+  - IDS/NDR internal movement/scanning alerts
535
+  - Endpoint network telemetry (e.g., Sysmon network events)
536
+- Required normalized fields:
537
+  - `asset.user`, `asset.is_admin`
538
+  - `network.src_ip`, `network.dst_host`, `network.dst_port`
539
+  - login success indicator where applicable
540
+  - `timestamp`
541
+
542
+### Minimum Windows Event IDs for Initial Rollout
543
+
544
+- `4624` Successful logon
545
+- `4672` Special privileges assigned to new logon
546
+- `4728`, `4732` Privileged group membership changes
547
+- `5145` Detailed file share access
548
+
549
+### Implementation Note
550
+
551
+- Simulation scripts exist for Appendix C validation and UAT replay:
552
+  - `scripts/send-wazuh-proposal-appendix-c-events.sh`
553
+- In production, these simulated events are replaced by actual VPN/AD/cloud/endpoint/network telemetry sources listed above.
554
+
555
+### Appendix C Production Data Onboarding Checklist
556
+
557
+| Source | Log Path / Channel | Must-Have Fields | Use Cases | Verification Query (Wazuh/Indexer) |
558
+|---|---|---|---|---|
559
+| VPN Gateway (FortiGate/SSL-VPN) | Syslog export from firewall/VPN device | `timestamp`, `user`, `src_ip`, `action/result`, `event_id` (if mapped), `country` (optional) | C1, C2 | `full_log:*vpn* AND full_log:*user=*` |
560
+| Active Directory / Windows DC | Windows Security Event Log (agent/forwarder) | `event_id`, `timestamp`, `user/account`, `src_ip` (where present), `logon_type`, `success/failure` | C1, C2, C3 | `rule.id:* AND (data.win.system.eventID:4624 OR full_log:*event_id=4624*)` |
561
+| Cloud IdP (Entra/Okta/Google) | API export / SIEM connector -> syslog/json | `user`, `src_ip`, `event_time`, `outcome`, `geo.country` (if available), `app/service` | C1, C2 | `full_log:*source=*idp* OR full_log:*okta* OR full_log:*entra*` |
562
+| Windows Endpoints/Servers | Wazuh agent + Sysmon/Security logs | `event_id`, `user`, `src_ip`, `dst_host`, `dst_port`, `process/action` | C2, C3 | `full_log:*source=windows* AND rule.id:*` |
563
+| Linux Servers | auth.log / secure / sudo / sshd | `timestamp`, `user`, `src_ip`, `action`, `success` | C2, C3 | `full_log:*sshd* OR full_log:*sudo*` |
564
+| East-West Firewall | Internal traffic logs (allow/deny/flow) | `src_ip`, `dst_ip/dst_host`, `dst_port`, `action`, `timestamp` | C3 | `full_log:*src_ip=* AND full_log:*dst_port=*` |
565
+| IDS/NDR | IDS alerts / network detection logs | `src_ip`, `dst_ip/dst_host`, `dst_port`, `signature/category`, `timestamp` | C3 | `full_log:*scan* OR full_log:*lateral* OR full_log:*enumeration*` |
566
+
567
+#### Acceptance Checklist (Per Source)
568
+
569
+- Parsing/decoder is stable (no malformed key fields in sampled logs)
570
+- Required fields are present and normalized into event model used by `soc-integrator`
571
+- Timestamp format is valid ISO-8601 after normalization
572
+- Sample events can be found in `wazuh-alerts-*` within expected ingestion latency
573
+- At least one C-use-case evaluation run confirms source contributes to detection context

+ 4 - 1
soc-integrator/Dockerfile

6
 WORKDIR /app
6
 WORKDIR /app
7
 
7
 
8
 COPY requirements.txt .
8
 COPY requirements.txt .
9
-RUN pip install --no-cache-dir -r requirements.txt
9
+RUN apt-get update \
10
+    && apt-get install -y --no-install-recommends bash netcat-openbsd \
11
+    && rm -rf /var/lib/apt/lists/* \
12
+    && pip install --no-cache-dir -r requirements.txt
10
 
13
 
11
 COPY app ./app
14
 COPY app ./app
12
 
15
 

+ 58 - 25
soc-integrator/app/adapters/geoip.py

8
 
8
 
9
 
9
 
10
 class GeoIpAdapter:
10
 class GeoIpAdapter:
11
-    def __init__(self, provider: str = "ip-api", cache_ttl_seconds: int = 21600) -> None:
12
-        self.provider = provider
11
+    def __init__(self, provider: str = "ipwhois", cache_ttl_seconds: int = 21600) -> None:
12
+        normalized = (provider or "").strip().lower()
13
+        if normalized in {"ipwho.is", "ipwhois"}:
14
+            normalized = "ipwhois"
15
+        self.provider = normalized or "ipwhois"
13
         self.cache_ttl_seconds = max(60, int(cache_ttl_seconds))
16
         self.cache_ttl_seconds = max(60, int(cache_ttl_seconds))
14
         self._cache: dict[str, tuple[float, dict[str, Any]]] = {}
17
         self._cache: dict[str, tuple[float, dict[str, Any]]] = {}
15
 
18
 
42
             out["cached"] = True
45
             out["cached"] = True
43
             return out
46
             return out
44
 
47
 
45
-        if self.provider != "ip-api":
46
-            return {"ok": False, "country_code": None, "error": f"unsupported_provider:{self.provider}"}
48
+        if self.provider == "ip-api":
49
+            url = f"http://ip-api.com/json/{candidate}"
50
+            params = {"fields": "status,message,countryCode,country,lat,lon,query"}
51
+            async with httpx.AsyncClient(timeout=8.0) as client:
52
+                response = await client.get(url, params=params)
53
+                response.raise_for_status()
54
+                data = response.json() if response.content else {}
47
 
55
 
48
-        url = f"http://ip-api.com/json/{candidate}"
49
-        params = {"fields": "status,message,countryCode,lat,lon,query"}
50
-        async with httpx.AsyncClient(timeout=8.0) as client:
51
-            response = await client.get(url, params=params)
52
-            response.raise_for_status()
53
-            data = response.json() if response.content else {}
56
+            if str(data.get("status", "")).lower() != "success":
57
+                return {
58
+                    "ok": False,
59
+                    "country_code": None,
60
+                    "country_name": None,
61
+                    "error": str(data.get("message") or "lookup_failed"),
62
+                    "provider": "ip-api",
63
+                    "ip": candidate,
64
+                }
54
 
65
 
55
-        if str(data.get("status", "")).lower() != "success":
56
-            return {
57
-                "ok": False,
58
-                "country_code": None,
59
-                "error": str(data.get("message") or "lookup_failed"),
66
+            result: dict[str, Any] = {
67
+                "ok": True,
68
+                "country_code": data.get("countryCode"),
69
+                "country_name": data.get("country"),
70
+                "lat": data.get("lat"),
71
+                "lon": data.get("lon"),
60
                 "provider": "ip-api",
72
                 "provider": "ip-api",
61
-                "ip": candidate,
73
+                "ip": data.get("query", candidate),
74
+                "cached": False,
62
             }
75
             }
76
+        elif self.provider == "ipwhois":
77
+            url = f"https://ipwho.is/{candidate}"
78
+            async with httpx.AsyncClient(timeout=8.0) as client:
79
+                response = await client.get(url)
80
+                response.raise_for_status()
81
+                data = response.json() if response.content else {}
82
+
83
+            if not bool(data.get("success", False)):
84
+                return {
85
+                    "ok": False,
86
+                    "country_code": None,
87
+                    "country_name": None,
88
+                    "error": str(data.get("message") or "lookup_failed"),
89
+                    "provider": "ipwho.is",
90
+                    "ip": candidate,
91
+                }
92
+
93
+            result = {
94
+                "ok": True,
95
+                "country_code": data.get("country_code"),
96
+                "country_name": data.get("country"),
97
+                "lat": data.get("latitude"),
98
+                "lon": data.get("longitude"),
99
+                "provider": "ipwho.is",
100
+                "ip": data.get("ip", candidate),
101
+                "cached": False,
102
+            }
103
+        else:
104
+            return {"ok": False, "country_code": None, "country_name": None, "error": f"unsupported_provider:{self.provider}"}
63
 
105
 
64
-        result: dict[str, Any] = {
65
-            "ok": True,
66
-            "country_code": data.get("countryCode"),
67
-            "lat": data.get("lat"),
68
-            "lon": data.get("lon"),
69
-            "provider": "ip-api",
70
-            "ip": data.get("query", candidate),
71
-            "cached": False,
72
-        }
73
         self._cache[candidate] = (now + self.cache_ttl_seconds, result)
106
         self._cache[candidate] = (now + self.cache_ttl_seconds, result)
74
         return result
107
         return result

+ 1 - 1
soc-integrator/app/config.py

40
     c3_scan_port_threshold: int = 20
40
     c3_scan_port_threshold: int = 20
41
     c_detection_create_iris_ticket: bool = True
41
     c_detection_create_iris_ticket: bool = True
42
     c_detection_ticket_cooldown_seconds: int = 900
42
     c_detection_ticket_cooldown_seconds: int = 900
43
-    geoip_provider: str = "ip-api"
43
+    geoip_provider: str = "ipwhois"
44
     geoip_cache_ttl_seconds: int = 21600
44
     geoip_cache_ttl_seconds: int = 21600
45
 
45
 
46
     shuffle_base_url: str = "http://shuffle-backend:5001"
46
     shuffle_base_url: str = "http://shuffle-backend:5001"

+ 687 - 1
soc-integrator/app/main.py

1
 import asyncio
1
 import asyncio
2
 import logging
2
 import logging
3
-from datetime import datetime, timezone
3
+import os
4
+import re
5
+import shlex
6
+import subprocess
7
+import uuid
8
+from collections import deque
9
+from datetime import datetime, timedelta, timezone
4
 from pathlib import Path
10
 from pathlib import Path
5
 
11
 
6
 from fastapi import Depends, FastAPI, File, HTTPException, Request, UploadFile
12
 from fastapi import Depends, FastAPI, File, HTTPException, Request, UploadFile
25
     IrisTicketCreateRequest,
31
     IrisTicketCreateRequest,
26
     LogLossCheckRequest,
32
     LogLossCheckRequest,
27
     LogLossStreamCheck,
33
     LogLossStreamCheck,
34
+    SimLogRunRequest,
28
     ShuffleLoginRequest,
35
     ShuffleLoginRequest,
29
     ShuffleProxyRequest,
36
     ShuffleProxyRequest,
30
     TriggerShuffleRequest,
37
     TriggerShuffleRequest,
40
 logger = logging.getLogger(__name__)
47
 logger = logging.getLogger(__name__)
41
 UI_DIR = Path(__file__).resolve().parent / "ui"
48
 UI_DIR = Path(__file__).resolve().parent / "ui"
42
 UI_ASSETS_DIR = UI_DIR / "assets"
49
 UI_ASSETS_DIR = UI_DIR / "assets"
50
+SIM_SCRIPTS_DIR = Path("/app/scripts")
51
+SIM_RUN_LOGS_DIR = Path("/tmp/soc-integrator-sim-logs")
43
 
52
 
44
 wazuh_adapter = WazuhAdapter(
53
 wazuh_adapter = WazuhAdapter(
45
     base_url=settings.wazuh_base_url,
54
     base_url=settings.wazuh_base_url,
357
         "last_result": None,
366
         "last_result": None,
358
         "last_ticket_ts_by_key": {},
367
         "last_ticket_ts_by_key": {},
359
     }
368
     }
369
+    app.state.systems_monitor_state = {
370
+        "last_ok_at": {},
371
+    }
372
+    app.state.sim_runs = {}
373
+    SIM_RUN_LOGS_DIR.mkdir(parents=True, exist_ok=True)
360
     if settings.wazuh_auto_sync_enabled:
374
     if settings.wazuh_auto_sync_enabled:
361
         app.state.wazuh_auto_sync_task = asyncio.create_task(_wazuh_auto_sync_loop())
375
         app.state.wazuh_auto_sync_task = asyncio.create_task(_wazuh_auto_sync_loop())
362
         logger.info(
376
         logger.info(
393
             await ll_task
407
             await ll_task
394
         except asyncio.CancelledError:
408
         except asyncio.CancelledError:
395
             pass
409
             pass
410
+    sim_runs = getattr(app.state, "sim_runs", {})
411
+    for run in sim_runs.values():
412
+        process = run.get("process")
413
+        if process and process.poll() is None:
414
+            process.terminate()
396
 
415
 
397
 
416
 
398
 @app.get(
417
 @app.get(
768
     return result, matched, severity, confidence
787
     return result, matched, severity, confidence
769
 
788
 
770
 
789
 
790
+def _extract_first_array(payload: object) -> list[object]:
791
+    if isinstance(payload, list):
792
+        return payload
793
+    if not isinstance(payload, dict):
794
+        return []
795
+
796
+    preferred_keys = [
797
+        "items",
798
+        "results",
799
+        "workflows",
800
+        "apps",
801
+        "affected_items",
802
+        "data",
803
+    ]
804
+    for key in preferred_keys:
805
+        value = payload.get(key)
806
+        if isinstance(value, list):
807
+            return value
808
+
809
+    for value in payload.values():
810
+        extracted = _extract_first_array(value)
811
+        if extracted:
812
+            return extracted
813
+    return []
814
+
815
+
816
+SIM_SCRIPT_MAP: dict[str, str] = {
817
+    "fortigate": "send-wazuh-fortigate-test-events.sh",
818
+    "endpoint": "send-wazuh-endpoint-agent-test-events.sh",
819
+    "cisco": "send-wazuh-cisco-test-events.sh",
820
+    "proposal_required": "send-wazuh-proposal-required-events.sh",
821
+    "proposal_appendix_b": "send-wazuh-proposal-appendix-b-events.sh",
822
+    "proposal_appendix_c": "send-wazuh-proposal-appendix-c-events.sh",
823
+    "wazuh_test": "send-wazuh-test-events.sh",
824
+}
825
+
826
+
827
+def _build_sim_command(payload: SimLogRunRequest) -> list[str]:
828
+    script_name = SIM_SCRIPT_MAP[payload.script]
829
+    script_path = SIM_SCRIPTS_DIR / script_name
830
+    count = max(1, int(payload.count))
831
+    delay = max(0.0, float(payload.delay_seconds))
832
+
833
+    if payload.script == "endpoint":
834
+        cmd = [
835
+            "/bin/bash",
836
+            str(script_path),
837
+            payload.target or "all",
838
+            payload.scenario or "all",
839
+            str(count),
840
+            str(delay),
841
+        ]
842
+    else:
843
+        cmd = [
844
+            "/bin/bash",
845
+            str(script_path),
846
+            payload.target or "all",
847
+            str(count),
848
+            str(delay),
849
+        ]
850
+
851
+    if payload.forever:
852
+        cmd.append("--forever")
853
+    return cmd
854
+
855
+
856
+def _serialize_sim_run(run_id: str, run: dict[str, object]) -> dict[str, object]:
857
+    process = run.get("process")
858
+    poll_code = process.poll() if process else None
859
+    return_code = run.get("return_code")
860
+    if poll_code is not None and return_code is None:
861
+        run["return_code"] = poll_code
862
+        return_code = poll_code
863
+
864
+    return {
865
+        "run_id": run_id,
866
+        "script": run.get("script"),
867
+        "target": run.get("target"),
868
+        "scenario": run.get("scenario"),
869
+        "count": run.get("count"),
870
+        "delay_seconds": run.get("delay_seconds"),
871
+        "forever": run.get("forever"),
872
+        "pid": run.get("pid"),
873
+        "cmd": run.get("cmd"),
874
+        "started_at": run.get("started_at"),
875
+        "stopped_at": run.get("stopped_at"),
876
+        "running": bool(process and process.poll() is None),
877
+        "return_code": return_code,
878
+        "log_file": run.get("log_file"),
879
+    }
880
+
881
+
882
+def _tail_log_lines(path: Path, limit: int = 200) -> list[str]:
883
+    line_limit = max(1, min(int(limit), 1000))
884
+    lines: deque[str] = deque(maxlen=line_limit)
885
+    try:
886
+        with path.open("r", encoding="utf-8", errors="replace") as handle:
887
+            for line in handle:
888
+                lines.append(line.rstrip("\n"))
889
+    except FileNotFoundError:
890
+        return []
891
+    return list(lines)
892
+
893
+
894
+def _safe_query_token(value: object) -> str | None:
895
+    text = str(value or "").strip()
896
+    if not text:
897
+        return None
898
+    if not re.fullmatch(r"[A-Za-z0-9_.:-]+", text):
899
+        return None
900
+    return text
901
+
902
+
903
+def _parse_iso_datetime(value: object) -> datetime | None:
904
+    text = str(value or "").strip()
905
+    if not text:
906
+        return None
907
+    if text.endswith("Z"):
908
+        text = text[:-1] + "+00:00"
909
+    try:
910
+        parsed = datetime.fromisoformat(text)
911
+    except ValueError:
912
+        return None
913
+    if parsed.tzinfo is None:
914
+        parsed = parsed.replace(tzinfo=timezone.utc)
915
+    return parsed.astimezone(timezone.utc)
916
+
917
+
918
+def _sim_wazuh_query_clauses(run: dict[str, object]) -> list[str]:
919
+    script = str(run.get("script") or "").strip().lower()
920
+    target = str(run.get("target") or "all").strip()
921
+    scenario = str(run.get("scenario") or "all").strip().lower()
922
+    target_token = _safe_query_token(target)
923
+    _ = scenario
924
+
925
+    clauses: list[str] = ["(full_log:*soc_mvp_test=true* OR data.soc_mvp_test:true)"]
926
+    if script == "fortigate":
927
+        clauses.append(
928
+            "(full_log:*fortigate* OR full_log:*FGT80F* OR full_log:*FGT60F* OR full_log:*FGT40F* "
929
+            "OR full_log:*FGT501E* OR data.vendor:fortinet OR data.product:fortigate OR data.source:fortigate)"
930
+        )
931
+        if target_token and target_token.lower() != "all":
932
+            clauses.append(f"(full_log:*{target_token}* OR data.model:{target_token})")
933
+    elif script == "endpoint":
934
+        if target_token and target_token.lower() != "all":
935
+            lowered = target_token.lower()
936
+            if lowered in {"windows", "win"}:
937
+                clauses.append(
938
+                    "(full_log:*source=windows* OR full_log:*source=windows_agent* "
939
+                    "OR data.source:windows OR data.source:windows_agent OR data.platform:windows)"
940
+                )
941
+            elif lowered in {"mac", "macos"}:
942
+                clauses.append(
943
+                    "(full_log:*source=mac* OR full_log:*source=mac_agent* "
944
+                    "OR data.source:mac OR data.source:mac_agent OR data.platform:mac)"
945
+                )
946
+            elif lowered == "linux":
947
+                clauses.append(
948
+                    "(full_log:*source=linux* OR full_log:*source=linux_agent* "
949
+                    "OR data.source:linux OR data.source:linux_agent OR data.platform:linux)"
950
+                )
951
+            else:
952
+                clauses.append(f"full_log:*{target_token}*")
953
+        else:
954
+            clauses.append(
955
+                "(full_log:*source=windows* OR full_log:*source=windows_agent* "
956
+                "OR full_log:*source=mac* OR full_log:*source=mac_agent* "
957
+                "OR full_log:*source=linux* OR full_log:*source=linux_agent* "
958
+                "OR data.source:windows OR data.source:windows_agent "
959
+                "OR data.source:mac OR data.source:mac_agent "
960
+                "OR data.source:linux OR data.source:linux_agent)"
961
+            )
962
+    elif script == "cisco":
963
+        clauses.append("(full_log:*cisco* OR data.vendor:cisco)")
964
+        if target_token and target_token.lower() != "all":
965
+            clauses.append(f"full_log:*{target_token}*")
966
+    elif script in {"proposal_required", "proposal_appendix_b", "proposal_appendix_c", "wazuh_test"}:
967
+        clauses.append("(full_log:*soc_mvp_test=true* OR data.soc_mvp_test:true)")
968
+        if target_token and target_token.lower() != "all":
969
+            clauses.append(f"full_log:*{target_token}*")
970
+    else:
971
+        clauses.append("full_log:*soc_mvp_test=true*")
972
+
973
+    return clauses
974
+
975
+
976
+def _extract_wazuh_hits(payload: object) -> list[dict[str, object]]:
977
+    if not isinstance(payload, dict):
978
+        return []
979
+    hits_root = payload.get("hits")
980
+    if not isinstance(hits_root, dict):
981
+        return []
982
+    hits = hits_root.get("hits")
983
+    if not isinstance(hits, list):
984
+        return []
985
+    result: list[dict[str, object]] = []
986
+    for hit in hits:
987
+        if isinstance(hit, dict):
988
+            result.append(hit)
989
+    return result
990
+
991
+
992
+def _extract_wazuh_event_item(hit: dict[str, object], include_raw: bool) -> dict[str, object]:
993
+    source = hit.get("_source") if isinstance(hit.get("_source"), dict) else {}
994
+    source = source if isinstance(source, dict) else {}
995
+    agent = source.get("agent") if isinstance(source.get("agent"), dict) else {}
996
+    agent = agent if isinstance(agent, dict) else {}
997
+    decoder = source.get("decoder") if isinstance(source.get("decoder"), dict) else {}
998
+    decoder = decoder if isinstance(decoder, dict) else {}
999
+    data = source.get("data") if isinstance(source.get("data"), dict) else {}
1000
+    data = data if isinstance(data, dict) else {}
1001
+    rule = source.get("rule") if isinstance(source.get("rule"), dict) else {}
1002
+    rule = rule if isinstance(rule, dict) else {}
1003
+
1004
+    item: dict[str, object] = {
1005
+        "@timestamp": source.get("@timestamp") or source.get("timestamp"),
1006
+        "event_id": data.get("event_id") or source.get("id") or hit.get("_id"),
1007
+        "agent_name": agent.get("name"),
1008
+        "agent_id": agent.get("id"),
1009
+        "decoder_name": decoder.get("name"),
1010
+        "source": data.get("source"),
1011
+        "event_type": data.get("event_type"),
1012
+        "severity": data.get("severity"),
1013
+        "rule_id": rule.get("id"),
1014
+        "rule_description": rule.get("description"),
1015
+        "full_log": source.get("full_log"),
1016
+    }
1017
+    if include_raw:
1018
+        item["raw"] = source
1019
+    return item
1020
+
1021
+
1022
+def _extract_wazuh_rule_item(hit: dict[str, object], include_raw: bool) -> dict[str, object] | None:
1023
+    source = hit.get("_source") if isinstance(hit.get("_source"), dict) else {}
1024
+    source = source if isinstance(source, dict) else {}
1025
+    rule = source.get("rule") if isinstance(source.get("rule"), dict) else {}
1026
+    rule = rule if isinstance(rule, dict) else {}
1027
+    rule_id = rule.get("id")
1028
+    if rule_id in {None, ""}:
1029
+        return None
1030
+    agent = source.get("agent") if isinstance(source.get("agent"), dict) else {}
1031
+    agent = agent if isinstance(agent, dict) else {}
1032
+    data = source.get("data") if isinstance(source.get("data"), dict) else {}
1033
+    data = data if isinstance(data, dict) else {}
1034
+
1035
+    item: dict[str, object] = {
1036
+        "@timestamp": source.get("@timestamp") or source.get("timestamp"),
1037
+        "rule_id": rule_id,
1038
+        "rule_level": rule.get("level"),
1039
+        "rule_description": rule.get("description"),
1040
+        "rule_firedtimes": rule.get("firedtimes"),
1041
+        "event_id": data.get("event_id") or source.get("id") or hit.get("_id"),
1042
+        "agent_name": agent.get("name"),
1043
+        "full_log": source.get("full_log"),
1044
+    }
1045
+    if include_raw:
1046
+        item["raw"] = source
1047
+    return item
1048
+
1049
+
771
 @app.post(
1050
 @app.post(
772
     "/ioc/enrich",
1051
     "/ioc/enrich",
773
     response_model=ApiResponse,
1052
     response_model=ApiResponse,
1104
 
1383
 
1105
 
1384
 
1106
 @app.get(
1385
 @app.get(
1386
+    "/geoip/{ip}",
1387
+    response_model=ApiResponse,
1388
+    summary="GeoIP lookup",
1389
+    description="Lookup geolocation for a public IP address using configured GeoIP provider.",
1390
+)
1391
+async def geoip_lookup(ip: str) -> ApiResponse:
1392
+    result = await geoip_adapter.lookup(ip)
1393
+    return ApiResponse(data={"geoip": result})
1394
+
1395
+
1396
+@app.get(
1107
     "/sync/wazuh-version",
1397
     "/sync/wazuh-version",
1108
     response_model=ApiResponse,
1398
     response_model=ApiResponse,
1109
     summary="Wazuh version",
1399
     summary="Wazuh version",
1249
     )
1539
     )
1250
 
1540
 
1251
 
1541
 
1542
+@app.get(
1543
+    "/monitor/systems",
1544
+    response_model=ApiResponse,
1545
+    dependencies=[Depends(require_internal_api_key)],
1546
+    summary="Systems monitor overview",
1547
+    description="Unified monitoring snapshot for Wazuh, Shuffle, IRIS, and PagerDuty with pipeline KPIs and recent records.",
1548
+)
1549
+async def monitor_systems(
1550
+    minutes: int = 60,
1551
+    limit: int = 20,
1552
+    include_raw: bool = False,
1553
+) -> ApiResponse:
1554
+    window_minutes = max(1, minutes)
1555
+    row_limit = max(1, limit)
1556
+    now = datetime.now(timezone.utc)
1557
+    since = now - timedelta(minutes=window_minutes)
1558
+    now_iso = now.isoformat()
1559
+
1560
+    dependencies = await mvp_service.dependency_health()
1561
+    monitor_state = getattr(app.state, "systems_monitor_state", {"last_ok_at": {}})
1562
+    last_ok_at_by_key = monitor_state.setdefault("last_ok_at", {})
1563
+
1564
+    # KPI counters from persisted database records in the selected lookback window.
1565
+    alerts_ingested = repo.count_incident_events_since(since=since, source="wazuh")
1566
+    detections_matched = repo.count_c_detection_events_since(since=since)
1567
+    iris_tickets_created = repo.count_incidents_with_iris_since(since=since)
1568
+    pagerduty_escalations_sent = repo.count_escalations_since(since=since, success=True)
1569
+    pagerduty_escalations_failed = repo.count_escalations_since(since=since, success=False)
1570
+
1571
+    wazuh_recent: list[object] = []
1572
+    wazuh_recent_error: str | None = None
1573
+    try:
1574
+        wazuh_resp = await wazuh_adapter.list_manager_logs(limit=row_limit, offset=0, q=None, sort=None)
1575
+        wazuh_recent = _extract_first_array(wazuh_resp)[:row_limit]
1576
+    except Exception as exc:
1577
+        wazuh_recent_error = str(exc)
1578
+
1579
+    shuffle_recent: list[object] = []
1580
+    shuffle_recent_error: str | None = None
1581
+    try:
1582
+        workflows_resp = await shuffle_adapter.list_workflows()
1583
+        workflows = _extract_first_array(workflows_resp)
1584
+        for item in workflows[:row_limit]:
1585
+            if isinstance(item, dict):
1586
+                shuffle_recent.append(
1587
+                    {
1588
+                        "id": item.get("id") or item.get("workflow_id"),
1589
+                        "name": item.get("name") or item.get("workflow", {}).get("name"),
1590
+                        "status": item.get("status"),
1591
+                    }
1592
+                )
1593
+            else:
1594
+                shuffle_recent.append(item)
1595
+    except Exception as exc:
1596
+        shuffle_recent_error = str(exc)
1597
+
1598
+    iris_recent: list[object] = []
1599
+    iris_recent_error: str | None = None
1600
+    try:
1601
+        iris_resp = await iris_adapter.list_cases(limit=row_limit, offset=0)
1602
+        iris_recent = _extract_first_array(iris_resp)[:row_limit]
1603
+    except Exception as exc:
1604
+        iris_recent_error = str(exc)
1605
+
1606
+    pagerduty_recent = repo.list_recent_escalations(limit=row_limit)
1607
+
1608
+    def build_card(
1609
+        label: str,
1610
+        dependency_key: str,
1611
+        recent: list[object],
1612
+        kpis: dict[str, object],
1613
+        extra_error: str | None = None,
1614
+    ) -> dict[str, object]:
1615
+        dep = dependencies.get(dependency_key, {})
1616
+        dep_status = str(dep.get("status") or "down")
1617
+        status = "ok" if dep_status == "up" else "down"
1618
+
1619
+        if dep_status == "up":
1620
+            last_ok_at_by_key[label] = now_iso
1621
+
1622
+        error_parts: list[str] = []
1623
+        if dep.get("error"):
1624
+            error_parts.append(str(dep.get("error")))
1625
+        if extra_error:
1626
+            error_parts.append(extra_error)
1627
+
1628
+        if dep_status == "up" and extra_error:
1629
+            status = "degraded"
1630
+
1631
+        card: dict[str, object] = {
1632
+            "status": status,
1633
+            "latency_ms": dep.get("latency_ms"),
1634
+            "last_ok_at": last_ok_at_by_key.get(label),
1635
+            "last_error": " | ".join(error_parts) if error_parts else None,
1636
+            "kpis": kpis,
1637
+            "recent": recent,
1638
+        }
1639
+        if include_raw:
1640
+            card["raw"] = dep.get("details")
1641
+        return card
1642
+
1643
+    cards = {
1644
+        "wazuh": build_card(
1645
+            label="wazuh",
1646
+            dependency_key="wazuh",
1647
+            recent=wazuh_recent,
1648
+            extra_error=wazuh_recent_error,
1649
+            kpis={
1650
+                "alerts_ingested": alerts_ingested,
1651
+                "recent_rows": len(wazuh_recent),
1652
+            },
1653
+        ),
1654
+        "shuffle": build_card(
1655
+            label="shuffle",
1656
+            dependency_key="shuffle",
1657
+            recent=shuffle_recent,
1658
+            extra_error=shuffle_recent_error,
1659
+            kpis={
1660
+                "recent_workflows": len(shuffle_recent),
1661
+            },
1662
+        ),
1663
+        "iris": build_card(
1664
+            label="iris",
1665
+            dependency_key="iris",
1666
+            recent=iris_recent,
1667
+            extra_error=iris_recent_error,
1668
+            kpis={
1669
+                "tickets_created": iris_tickets_created,
1670
+                "recent_rows": len(iris_recent),
1671
+            },
1672
+        ),
1673
+        "pagerduty": build_card(
1674
+            label="pagerduty",
1675
+            dependency_key="pagerduty_stub",
1676
+            recent=pagerduty_recent,
1677
+            kpis={
1678
+                "escalations_sent": pagerduty_escalations_sent,
1679
+                "escalations_failed": pagerduty_escalations_failed,
1680
+            },
1681
+        ),
1682
+    }
1683
+
1684
+    app.state.systems_monitor_state = monitor_state
1685
+
1686
+    return ApiResponse(
1687
+        data={
1688
+            "generated_at": now_iso,
1689
+            "window_minutes": window_minutes,
1690
+            "cards": cards,
1691
+            "pipeline": {
1692
+                "alerts_ingested": alerts_ingested,
1693
+                "detections_matched": detections_matched,
1694
+                "iris_tickets_created": iris_tickets_created,
1695
+                "pagerduty_escalations_sent": pagerduty_escalations_sent,
1696
+                "pagerduty_escalations_failed": pagerduty_escalations_failed,
1697
+            },
1698
+        }
1699
+    )
1700
+
1701
+
1702
+@app.get(
1703
+    "/sim/logs/runs",
1704
+    response_model=ApiResponse,
1705
+    dependencies=[Depends(require_internal_api_key)],
1706
+    summary="List simulator runs",
1707
+    description="List active and recent simulator script runs started from soc-integrator.",
1708
+)
1709
+async def sim_logs_runs() -> ApiResponse:
1710
+    sim_runs: dict[str, dict[str, object]] = getattr(app.state, "sim_runs", {})
1711
+    items: list[dict[str, object]] = []
1712
+    for run_id, run in sim_runs.items():
1713
+        serialized = _serialize_sim_run(run_id, run)
1714
+        if (not serialized["running"]) and not run.get("stopped_at"):
1715
+            run["stopped_at"] = datetime.now(timezone.utc).isoformat()
1716
+            serialized["stopped_at"] = run["stopped_at"]
1717
+        items.append(serialized)
1718
+    items.sort(key=lambda x: str(x.get("started_at") or ""), reverse=True)
1719
+    return ApiResponse(data={"items": items})
1720
+
1721
+
1722
+@app.post(
1723
+    "/sim/logs/start",
1724
+    response_model=ApiResponse,
1725
+    dependencies=[Depends(require_internal_api_key)],
1726
+    summary="Start simulator logs script",
1727
+    description="Start a whitelisted simulator script in background and return run metadata.",
1728
+)
1729
+async def sim_logs_start(payload: SimLogRunRequest) -> ApiResponse:
1730
+    script_name = SIM_SCRIPT_MAP[payload.script]
1731
+    script_path = SIM_SCRIPTS_DIR / script_name
1732
+    if not script_path.exists():
1733
+        raise HTTPException(status_code=400, detail=f"Simulator script not found in container: {script_name}")
1734
+
1735
+    cmd = _build_sim_command(payload)
1736
+    env = dict(os.environ)
1737
+    env.setdefault("WAZUH_SYSLOG_HOST", "wazuh.manager")
1738
+    env.setdefault("WAZUH_SYSLOG_PORT", "514")
1739
+    run_id = str(uuid.uuid4())
1740
+    log_file = SIM_RUN_LOGS_DIR / f"{run_id}.log"
1741
+    log_handle = None
1742
+
1743
+    try:
1744
+        log_handle = log_file.open("ab")
1745
+        process = subprocess.Popen(
1746
+            cmd,
1747
+            cwd=str(SIM_SCRIPTS_DIR),
1748
+            env=env,
1749
+            stdout=log_handle,
1750
+            stderr=subprocess.STDOUT,
1751
+            start_new_session=True,
1752
+        )
1753
+    except Exception as exc:
1754
+        if log_handle:
1755
+            try:
1756
+                log_handle.close()
1757
+            except Exception:
1758
+                pass
1759
+        raise HTTPException(status_code=502, detail=f"Failed to start simulator: {exc}") from exc
1760
+    finally:
1761
+        if log_handle:
1762
+            log_handle.close()
1763
+    sim_runs: dict[str, dict[str, object]] = getattr(app.state, "sim_runs", {})
1764
+    sim_runs[run_id] = {
1765
+        "script": payload.script,
1766
+        "target": payload.target,
1767
+        "scenario": payload.scenario,
1768
+        "count": payload.count,
1769
+        "delay_seconds": payload.delay_seconds,
1770
+        "forever": payload.forever,
1771
+        "pid": process.pid,
1772
+        "cmd": " ".join(shlex.quote(part) for part in cmd),
1773
+        "started_at": datetime.now(timezone.utc).isoformat(),
1774
+        "stopped_at": None,
1775
+        "return_code": None,
1776
+        "log_file": str(log_file),
1777
+        "process": process,
1778
+    }
1779
+    app.state.sim_runs = sim_runs
1780
+    return ApiResponse(data={"run": _serialize_sim_run(run_id, sim_runs[run_id])})
1781
+
1782
+
1783
+@app.post(
1784
+    "/sim/logs/stop/{run_id}",
1785
+    response_model=ApiResponse,
1786
+    dependencies=[Depends(require_internal_api_key)],
1787
+    summary="Stop simulator run",
1788
+    description="Stop a running simulator script by run_id.",
1789
+)
1790
+async def sim_logs_stop(run_id: str) -> ApiResponse:
1791
+    sim_runs: dict[str, dict[str, object]] = getattr(app.state, "sim_runs", {})
1792
+    run = sim_runs.get(run_id)
1793
+    if not run:
1794
+        raise HTTPException(status_code=404, detail=f"Run not found: {run_id}")
1795
+
1796
+    process = run.get("process")
1797
+    if process and process.poll() is None:
1798
+        try:
1799
+            process.terminate()
1800
+            process.wait(timeout=3)
1801
+        except subprocess.TimeoutExpired:
1802
+            process.kill()
1803
+        except Exception as exc:
1804
+            raise HTTPException(status_code=502, detail=f"Failed to stop run: {exc}") from exc
1805
+
1806
+    run["stopped_at"] = datetime.now(timezone.utc).isoformat()
1807
+    return ApiResponse(data={"run": _serialize_sim_run(run_id, run)})
1808
+
1809
+
1810
+@app.post(
1811
+    "/sim/logs/stop-running",
1812
+    response_model=ApiResponse,
1813
+    dependencies=[Depends(require_internal_api_key)],
1814
+    summary="Stop all running simulator runs",
1815
+    description="Stop all currently running simulator scripts (including forever mode).",
1816
+)
1817
+async def sim_logs_stop_running() -> ApiResponse:
1818
+    sim_runs: dict[str, dict[str, object]] = getattr(app.state, "sim_runs", {})
1819
+    stopped: list[dict[str, object]] = []
1820
+    already_stopped = 0
1821
+
1822
+    for run_id, run in sim_runs.items():
1823
+        process = run.get("process")
1824
+        if process and process.poll() is None:
1825
+            try:
1826
+                process.terminate()
1827
+                process.wait(timeout=3)
1828
+            except subprocess.TimeoutExpired:
1829
+                process.kill()
1830
+            except Exception as exc:
1831
+                raise HTTPException(status_code=502, detail=f"Failed to stop run {run_id}: {exc}") from exc
1832
+            run["stopped_at"] = datetime.now(timezone.utc).isoformat()
1833
+            stopped.append(_serialize_sim_run(run_id, run))
1834
+        else:
1835
+            already_stopped += 1
1836
+
1837
+    return ApiResponse(
1838
+        data={
1839
+            "stopped_count": len(stopped),
1840
+            "already_stopped_count": already_stopped,
1841
+            "runs": stopped,
1842
+        }
1843
+    )
1844
+
1845
+
1846
+@app.get(
1847
+    "/sim/logs/output/{run_id}",
1848
+    response_model=ApiResponse,
1849
+    dependencies=[Depends(require_internal_api_key)],
1850
+    summary="Get simulator run output",
1851
+    description="Return tailed output lines from simulator run log file.",
1852
+)
1853
+async def sim_logs_output(run_id: str, limit: int = 200) -> ApiResponse:
1854
+    sim_runs: dict[str, dict[str, object]] = getattr(app.state, "sim_runs", {})
1855
+    run = sim_runs.get(run_id)
1856
+    if not run:
1857
+        raise HTTPException(status_code=404, detail=f"Run not found: {run_id}")
1858
+
1859
+    log_file_path = run.get("log_file")
1860
+    if not log_file_path:
1861
+        raise HTTPException(status_code=404, detail=f"No log file for run: {run_id}")
1862
+
1863
+    log_file = Path(str(log_file_path))
1864
+    lines = _tail_log_lines(log_file, limit=limit)
1865
+    process = run.get("process")
1866
+    running = bool(process and process.poll() is None)
1867
+    return ApiResponse(
1868
+        data={
1869
+            "run_id": run_id,
1870
+            "running": running,
1871
+            "line_count": len(lines),
1872
+            "lines": lines,
1873
+            "text": "\n".join(lines),
1874
+            "log_file": str(log_file),
1875
+        }
1876
+    )
1877
+
1878
+
1879
+@app.get(
1880
+    "/sim/logs/wazuh-latest/{run_id}",
1881
+    response_model=ApiResponse,
1882
+    dependencies=[Depends(require_internal_api_key)],
1883
+    summary="Get latest Wazuh logs/rules for simulator run",
1884
+    description="Return latest Wazuh event logs and matched rules correlated to a simulator run.",
1885
+)
1886
+async def sim_logs_wazuh_latest(
1887
+    run_id: str,
1888
+    limit: int = 50,
1889
+    minutes: int = 15,
1890
+    include_raw: bool = False,
1891
+) -> ApiResponse:
1892
+    sim_runs: dict[str, dict[str, object]] = getattr(app.state, "sim_runs", {})
1893
+    run = sim_runs.get(run_id)
1894
+    if not run:
1895
+        raise HTTPException(status_code=404, detail=f"Run not found: {run_id}")
1896
+
1897
+    requested_minutes = max(1, int(minutes))
1898
+    # Keep query unfiltered and use a wide lookback to emulate Discover "latest records".
1899
+    effective_minutes = max(1440, requested_minutes)
1900
+    query_limit = max(1, min(int(limit), 200))
1901
+    query_text = "*"
1902
+
1903
+    try:
1904
+        raw = await wazuh_adapter.search_alerts(
1905
+            query=query_text,
1906
+            limit=query_limit,
1907
+            minutes=effective_minutes,
1908
+        )
1909
+    except Exception as exc:
1910
+        raise HTTPException(status_code=502, detail=f"Wazuh search failed: {exc}") from exc
1911
+
1912
+    hits = _extract_wazuh_hits(raw)
1913
+    events = [_extract_wazuh_event_item(hit, include_raw=include_raw) for hit in hits]
1914
+    rules: list[dict[str, object]] = []
1915
+    for hit in hits:
1916
+        rule_item = _extract_wazuh_rule_item(hit, include_raw=include_raw)
1917
+        if rule_item:
1918
+            rules.append(rule_item)
1919
+
1920
+    return ApiResponse(
1921
+        data={
1922
+            "run": _serialize_sim_run(run_id, run),
1923
+            "query": {
1924
+                "effective_minutes": effective_minutes,
1925
+                "text": query_text,
1926
+                "limit": query_limit,
1927
+            },
1928
+            "events": events,
1929
+            "rules": rules,
1930
+            "totals": {
1931
+                "events": len(events),
1932
+                "rules": len(rules),
1933
+            },
1934
+        }
1935
+    )
1936
+
1937
+
1252
 @app.post(
1938
 @app.post(
1253
     "/monitor/log-loss/check",
1939
     "/monitor/log-loss/check",
1254
     response_model=ApiResponse,
1940
     response_model=ApiResponse,

+ 30 - 0
soc-integrator/app/models.py

325
     limit: int = Field(default=200, description="Maximum events fetched from Wazuh indexer.", examples=[200])
325
     limit: int = Field(default=200, description="Maximum events fetched from Wazuh indexer.", examples=[200])
326
 
326
 
327
 
327
 
328
+class SimLogRunRequest(BaseModel):
329
+    model_config = ConfigDict(
330
+        json_schema_extra={
331
+            "example": {
332
+                "script": "fortigate",
333
+                "target": "all",
334
+                "scenario": "all",
335
+                "count": 1,
336
+                "delay_seconds": 0.3,
337
+                "forever": False,
338
+            }
339
+        }
340
+    )
341
+
342
+    script: Literal[
343
+        "fortigate",
344
+        "endpoint",
345
+        "cisco",
346
+        "proposal_required",
347
+        "proposal_appendix_b",
348
+        "proposal_appendix_c",
349
+        "wazuh_test",
350
+    ] = Field(description="Whitelisted simulator script key.", examples=["fortigate"])
351
+    target: str = Field(default="all", description="Primary selector/model/platform for the script.", examples=["all"])
352
+    scenario: str = Field(default="all", description="Secondary selector used by endpoint simulator.", examples=["process"])
353
+    count: int = Field(default=1, description="Number of iterations for non-forever mode.", examples=[1])
354
+    delay_seconds: float = Field(default=0.3, description="Delay between iterations.", examples=[0.3])
355
+    forever: bool = Field(default=False, description="Run continuously until stopped.", examples=[False])
356
+
357
+
328
 class ApiResponse(BaseModel):
358
 class ApiResponse(BaseModel):
329
     ok: bool = True
359
     ok: bool = True
330
     message: str = "ok"
360
     message: str = "ok"

+ 89 - 0
soc-integrator/app/repositories/mvp_repo.py

37
 
37
 
38
 
38
 
39
 class MvpRepository:
39
 class MvpRepository:
40
+    def count_incident_events_since(self, since: datetime, source: str | None = None) -> int:
41
+        with get_conn() as conn, conn.cursor() as cur:
42
+            if source:
43
+                cur.execute(
44
+                    """
45
+                    SELECT COUNT(*) AS cnt
46
+                    FROM incident_events
47
+                    WHERE created_at >= %s
48
+                      AND source = %s
49
+                    """,
50
+                    (since, source),
51
+                )
52
+            else:
53
+                cur.execute(
54
+                    """
55
+                    SELECT COUNT(*) AS cnt
56
+                    FROM incident_events
57
+                    WHERE created_at >= %s
58
+                    """,
59
+                    (since,),
60
+                )
61
+            row = cur.fetchone() or {}
62
+            return int(row.get("cnt", 0) or 0)
63
+
64
+    def count_incidents_with_iris_since(self, since: datetime) -> int:
65
+        with get_conn() as conn, conn.cursor() as cur:
66
+            cur.execute(
67
+                """
68
+                SELECT COUNT(*) AS cnt
69
+                FROM incident_index
70
+                WHERE first_seen >= %s
71
+                  AND iris_case_id IS NOT NULL
72
+                  AND iris_case_id <> ''
73
+                """,
74
+                (since,),
75
+            )
76
+            row = cur.fetchone() or {}
77
+            return int(row.get("cnt", 0) or 0)
78
+
79
+    def count_escalations_since(self, since: datetime, success: bool | None = None) -> int:
80
+        with get_conn() as conn, conn.cursor() as cur:
81
+            if success is None:
82
+                cur.execute(
83
+                    """
84
+                    SELECT COUNT(*) AS cnt
85
+                    FROM escalation_audit
86
+                    WHERE attempted_at >= %s
87
+                    """,
88
+                    (since,),
89
+                )
90
+            else:
91
+                cur.execute(
92
+                    """
93
+                    SELECT COUNT(*) AS cnt
94
+                    FROM escalation_audit
95
+                    WHERE attempted_at >= %s
96
+                      AND success = %s
97
+                    """,
98
+                    (since, success),
99
+                )
100
+            row = cur.fetchone() or {}
101
+            return int(row.get("cnt", 0) or 0)
102
+
103
+    def count_c_detection_events_since(self, since: datetime) -> int:
104
+        with get_conn() as conn, conn.cursor() as cur:
105
+            cur.execute(
106
+                """
107
+                SELECT COUNT(*) AS cnt
108
+                FROM c_detection_events
109
+                WHERE matched_at >= %s
110
+                """,
111
+                (since,),
112
+            )
113
+            row = cur.fetchone() or {}
114
+            return int(row.get("cnt", 0) or 0)
115
+
116
+    def list_recent_escalations(self, limit: int = 20) -> list[dict[str, Any]]:
117
+        with get_conn() as conn, conn.cursor() as cur:
118
+            cur.execute(
119
+                """
120
+                SELECT id, incident_key, attempted_at, status_code, success, response_excerpt
121
+                FROM escalation_audit
122
+                ORDER BY attempted_at DESC
123
+                LIMIT %s
124
+                """,
125
+                (max(1, limit),),
126
+            )
127
+            return [dict(row) for row in cur.fetchall()]
128
+
40
     def has_event(self, source: str, event_id: str) -> bool:
129
     def has_event(self, source: str, event_id: str) -> bool:
41
         with get_conn() as conn, conn.cursor() as cur:
130
         with get_conn() as conn, conn.cursor() as cur:
42
             cur.execute(
131
             cur.execute(

+ 455 - 1
soc-integrator/app/ui/assets/app.js

26
   return {
26
   return {
27
     tabs: [
27
     tabs: [
28
       { key: "overview", label: "Overview" },
28
       { key: "overview", label: "Overview" },
29
+      { key: "systems", label: "Systems" },
29
       { key: "monitoring", label: "Monitoring" },
30
       { key: "monitoring", label: "Monitoring" },
30
       { key: "ioc", label: "IOC" },
31
       { key: "ioc", label: "IOC" },
32
+      { key: "geoip", label: "GeoIP" },
31
       { key: "iris", label: "IRIS" },
33
       { key: "iris", label: "IRIS" },
32
       { key: "shuffle", label: "Shuffle" },
34
       { key: "shuffle", label: "Shuffle" },
33
       { key: "wazuh", label: "Wazuh" },
35
       { key: "wazuh", label: "Wazuh" },
36
     ],
38
     ],
37
     activeTab: "overview",
39
     activeTab: "overview",
38
     apiBase: window.location.origin,
40
     apiBase: window.location.origin,
39
-    internalApiKey: "",
41
+    internalApiKey: "dev-internal-key",
40
     errorMessage: "",
42
     errorMessage: "",
41
 
43
 
42
     overview: { health: null, autoSync: null },
44
     overview: { health: null, autoSync: null },
45
+    systemsMonitor: {
46
+      data: null,
47
+      loading: false,
48
+      autoRefresh: true,
49
+      paused: false,
50
+      intervalSeconds: 20,
51
+      minutes: 60,
52
+      limit: 20,
53
+      lastRefreshAt: null,
54
+      timerId: null,
55
+    },
56
+    simLogs: {
57
+      runs: null,
58
+      startResult: null,
59
+      selectedRunId: "",
60
+      output: null,
61
+      outputLimit: 200,
62
+      autoRefresh: true,
63
+      intervalSeconds: 3,
64
+      timerId: null,
65
+      form: {
66
+        script: "fortigate",
67
+        target: "all",
68
+        scenario: "all",
69
+        count: 1,
70
+        delay_seconds: 0.3,
71
+        forever: false,
72
+      },
73
+    },
74
+    simWazuh: {
75
+      latest: null,
76
+      limit: 100,
77
+      autoRefresh: true,
78
+      showQuery: false,
79
+    },
80
+    systemsCardMeta: [
81
+      { key: "wazuh", label: "Wazuh" },
82
+      { key: "shuffle", label: "Shuffle" },
83
+      { key: "iris", label: "IRIS" },
84
+      { key: "pagerduty", label: "PagerDuty" },
85
+    ],
43
 
86
 
44
     logLoss: { result: null },
87
     logLoss: { result: null },
45
     cDetections: { state: null, evaluate: null, history: null },
88
     cDetections: { state: null, evaluate: null, history: null },
46
 
89
 
47
     ioc: { enrich: null, evaluate: null, history: null, upload: null, analysis: null, fileEval: null },
90
     ioc: { enrich: null, evaluate: null, history: null, upload: null, analysis: null, fileEval: null },
91
+    geoip: { ip: "8.8.8.8", result: null },
48
     iris: { create: null, list: null },
92
     iris: { create: null, list: null },
49
     shuffle: { status: null, catalog: null, execute: null },
93
     shuffle: { status: null, catalog: null, execute: null },
50
     wazuh: { status: null, list: null, sync: null },
94
     wazuh: { status: null, list: null, sync: null },
131
       this.loadHealth();
175
       this.loadHealth();
132
       this.loadAutoSync();
176
       this.loadAutoSync();
133
       this.loadCState();
177
       this.loadCState();
178
+      this.loadSystemsMonitor();
179
+      this.loadSimRuns();
180
+      this.startSimLogsAutoRefresh();
181
+      this.startSystemsMonitorAutoRefresh();
134
       this.loadOpenApiSpec();
182
       this.loadOpenApiSpec();
135
     },
183
     },
136
 
184
 
185
+    simScriptUsesScenario() {
186
+      return this.simLogs.form.script === "endpoint";
187
+    },
188
+
189
+    systemsStatusClass(status) {
190
+      if (status === "ok") {
191
+        return "status-ok";
192
+      }
193
+      if (status === "degraded") {
194
+        return "status-warn";
195
+      }
196
+      return "status-down";
197
+    },
198
+
199
+    systemsCards() {
200
+      const root = this.unwrapApiData(this.systemsMonitor.data) || {};
201
+      return root.cards || {};
202
+    },
203
+
204
+    systemsCard(key) {
205
+      const cards = this.systemsCards();
206
+      return cards[key] || {};
207
+    },
208
+
209
+    systemsRecentRows(key) {
210
+      const recent = this.systemsCard(key).recent;
211
+      if (!Array.isArray(recent)) {
212
+        return [];
213
+      }
214
+      return recent.map((row, index) => this.normalizeTableRow(row, index));
215
+    },
216
+
217
+    systemsRecentColumns(key) {
218
+      return this.tableColumns(this.systemsRecentRows(key));
219
+    },
220
+
221
+    systemsPipelineRows() {
222
+      const root = this.unwrapApiData(this.systemsMonitor.data) || {};
223
+      const pipeline = root.pipeline || {};
224
+      return Object.entries(pipeline).map(([key, value]) => ({ key, value: this.cellText(value) }));
225
+    },
226
+
227
+    systemsSetAutoRefresh(enabled) {
228
+      this.systemsMonitor.autoRefresh = Boolean(enabled);
229
+      this.startSystemsMonitorAutoRefresh();
230
+    },
231
+
232
+    systemsTogglePaused() {
233
+      this.systemsMonitor.paused = !this.systemsMonitor.paused;
234
+    },
235
+
236
+    systemsSetInterval(seconds) {
237
+      const parsed = Number(seconds || 20);
238
+      this.systemsMonitor.intervalSeconds = parsed > 0 ? parsed : 20;
239
+      this.startSystemsMonitorAutoRefresh();
240
+    },
241
+
242
+    stopSystemsMonitorAutoRefresh() {
243
+      if (this.systemsMonitor.timerId) {
244
+        clearInterval(this.systemsMonitor.timerId);
245
+        this.systemsMonitor.timerId = null;
246
+      }
247
+    },
248
+
249
+    startSystemsMonitorAutoRefresh() {
250
+      this.stopSystemsMonitorAutoRefresh();
251
+      if (!this.systemsMonitor.autoRefresh) {
252
+        return;
253
+      }
254
+      this.systemsMonitor.timerId = setInterval(() => {
255
+        if (!this.systemsMonitor.paused) {
256
+          this.loadSystemsMonitor();
257
+        }
258
+      }, Math.max(5, Number(this.systemsMonitor.intervalSeconds || 20)) * 1000);
259
+    },
260
+
261
+    async loadSystemsMonitor() {
262
+      try {
263
+        if (!this.internalApiKey) {
264
+          return;
265
+        }
266
+        this.systemsMonitor.loading = true;
267
+        const params = new URLSearchParams({
268
+          minutes: String(Math.max(1, Number(this.systemsMonitor.minutes || 60))),
269
+          limit: String(Math.max(1, Number(this.systemsMonitor.limit || 20))),
270
+        });
271
+        this.systemsMonitor.data = await this.apiCall(`/monitor/systems?${params.toString()}`, {
272
+          internal: true,
273
+        });
274
+        this.systemsMonitor.lastRefreshAt = new Date().toISOString();
275
+      } catch (err) {
276
+        this.setErr("Systems monitor failed", err);
277
+      } finally {
278
+        this.systemsMonitor.loading = false;
279
+      }
280
+    },
281
+
282
+    async loadSimRuns() {
283
+      try {
284
+        if (!this.internalApiKey) {
285
+          return;
286
+        }
287
+        this.simLogs.runs = await this.apiCall("/sim/logs/runs", { internal: true });
288
+        const rows = this.extractRows(this.simLogs.runs);
289
+        if (!this.simLogs.selectedRunId && rows.length) {
290
+          this.simLogs.selectedRunId = rows[0].run_id || "";
291
+          if (this.simLogs.selectedRunId) {
292
+            await this.loadSimWazuhLatest(this.simLogs.selectedRunId);
293
+          }
294
+        }
295
+      } catch (err) {
296
+        this.setErr("Load sim runs failed", err);
297
+      }
298
+    },
299
+
300
+    async startSimRun() {
301
+      try {
302
+        const payload = {
303
+          script: this.simLogs.form.script,
304
+          target: this.simLogs.form.target || "all",
305
+          scenario: this.simLogs.form.scenario || "all",
306
+          count: Number(this.simLogs.form.count || 1),
307
+          delay_seconds: Number(this.simLogs.form.delay_seconds || 0.3),
308
+          forever: Boolean(this.simLogs.form.forever),
309
+        };
310
+        this.simLogs.startResult = await this.apiCall("/sim/logs/start", {
311
+          method: "POST",
312
+          internal: true,
313
+          json: payload,
314
+        });
315
+        await this.loadSimRuns();
316
+        const started = this.unwrapApiData(this.simLogs.startResult)?.run;
317
+        if (started && started.run_id) {
318
+          this.simLogs.selectedRunId = started.run_id;
319
+          await this.loadSimOutput(started.run_id);
320
+          await this.loadSimWazuhLatest(started.run_id);
321
+        }
322
+      } catch (err) {
323
+        this.setErr("Start sim run failed", err);
324
+      }
325
+    },
326
+
327
+    async stopSimRun(runId) {
328
+      try {
329
+        await this.apiCall(`/sim/logs/stop/${encodeURIComponent(runId)}`, {
330
+          method: "POST",
331
+          internal: true,
332
+        });
333
+        await this.loadSimRuns();
334
+        if (this.simLogs.selectedRunId === runId) {
335
+          await this.loadSimOutput(runId);
336
+          await this.loadSimWazuhLatest(runId);
337
+        }
338
+      } catch (err) {
339
+        this.setErr("Stop sim run failed", err);
340
+      }
341
+    },
342
+
343
+    async stopRunningSimRuns() {
344
+      try {
345
+        await this.apiCall("/sim/logs/stop-running", {
346
+          method: "POST",
347
+          internal: true,
348
+        });
349
+        await this.loadSimRuns();
350
+        if (this.simLogs.selectedRunId) {
351
+          await this.loadSimOutput(this.simLogs.selectedRunId);
352
+          await this.loadSimWazuhLatest(this.simLogs.selectedRunId);
353
+        }
354
+      } catch (err) {
355
+        this.setErr("Stop running sim runs failed", err);
356
+      }
357
+    },
358
+
359
+    simRunRows() {
360
+      return this.extractRows(this.simLogs.runs);
361
+    },
362
+
363
+    selectSimRun(runId) {
364
+      this.simLogs.selectedRunId = runId || "";
365
+      this.loadSimOutput(this.simLogs.selectedRunId);
366
+      this.loadSimWazuhLatest(this.simLogs.selectedRunId);
367
+    },
368
+
369
+    simSelectedRun() {
370
+      const rows = this.simRunRows();
371
+      return rows.find((row) => row.run_id === this.simLogs.selectedRunId) || null;
372
+    },
373
+
374
+    stopSimLogsAutoRefresh() {
375
+      if (this.simLogs.timerId) {
376
+        clearInterval(this.simLogs.timerId);
377
+        this.simLogs.timerId = null;
378
+      }
379
+    },
380
+
381
+    startSimLogsAutoRefresh() {
382
+      this.stopSimLogsAutoRefresh();
383
+      if (!this.simLogs.autoRefresh) {
384
+        return;
385
+      }
386
+      this.simLogs.timerId = setInterval(async () => {
387
+        if (this.activeTab !== "systems") {
388
+          return;
389
+        }
390
+        try {
391
+          await this.loadSimRuns();
392
+          if (this.simLogs.selectedRunId) {
393
+            await this.loadSimOutput(this.simLogs.selectedRunId);
394
+            await this.loadSimWazuhLatest(this.simLogs.selectedRunId);
395
+          }
396
+        } catch (_err) {
397
+          // per-request error is already handled by each loader
398
+        }
399
+      }, 5000);
400
+    },
401
+
402
+    async loadSimOutput(runId = "") {
403
+      const selectedRunId = runId || this.simLogs.selectedRunId;
404
+      if (!selectedRunId) {
405
+        this.simLogs.output = null;
406
+        return;
407
+      }
408
+      try {
409
+        const limit = Math.max(10, Number(this.simLogs.outputLimit || 200));
410
+        this.simLogs.output = await this.apiCall(
411
+          `/sim/logs/output/${encodeURIComponent(selectedRunId)}?limit=${limit}`,
412
+          { internal: true },
413
+        );
414
+      } catch (err) {
415
+        this.setErr("Load sim output failed", err);
416
+      }
417
+    },
418
+
419
+    async loadSimWazuhLatest(runId = "") {
420
+      const selectedRunId = runId || this.simLogs.selectedRunId;
421
+      if (!selectedRunId) {
422
+        this.simWazuh.latest = null;
423
+        return;
424
+      }
425
+      try {
426
+        const limit = 100;
427
+        this.simWazuh.latest = await this.apiCall(
428
+          `/sim/logs/wazuh-latest/${encodeURIComponent(selectedRunId)}?limit=${limit}&minutes=1440&include_raw=true`,
429
+          { internal: true },
430
+        );
431
+      } catch (err) {
432
+        this.setErr("Load sim Wazuh latest failed", err);
433
+      }
434
+    },
435
+
436
+    simWazuhEventsRows() {
437
+      const root = this.unwrapApiData(this.simWazuh.latest) || {};
438
+      const events = Array.isArray(root.events) ? root.events : [];
439
+      return events.map((row, index) => this.normalizeTableRow(row, index));
440
+    },
441
+
442
+    simWazuhRulesRows() {
443
+      const root = this.unwrapApiData(this.simWazuh.latest) || {};
444
+      const rules = Array.isArray(root.rules) ? root.rules : [];
445
+      return rules.map((row, index) => this.normalizeTableRow(row, index));
446
+    },
447
+
448
+    simWazuhEventTableRows() {
449
+      return this.simWazuhEventsRows().map((row) => ({
450
+        time: row["@timestamp"] || row.timestamp || "",
451
+        rule_id: row.rule_id || row.rule?.id || "",
452
+        rule_description: row.rule_description || row.rule?.description || "",
453
+        full_log: row.full_log || "",
454
+      }));
455
+    },
456
+
457
+    parseFullLog(fullLog) {
458
+      const text = String(fullLog || "").trim();
459
+      if (!text) {
460
+        return {};
461
+      }
462
+      const parsed = {};
463
+      const regex = /([A-Za-z0-9_.-]+)=("([^"]*)"|[^\s]+)/g;
464
+      let match = null;
465
+      while ((match = regex.exec(text)) !== null) {
466
+        const key = match[1];
467
+        let value = match[3] !== undefined ? match[3] : match[2];
468
+        if (value === "true") {
469
+          value = true;
470
+        } else if (value === "false") {
471
+          value = false;
472
+        } else if (/^-?\d+$/.test(value)) {
473
+          value = Number(value);
474
+        } else if (/^-?\d+\.\d+$/.test(value)) {
475
+          value = Number(value);
476
+        }
477
+        parsed[key] = value;
478
+      }
479
+      if (Object.keys(parsed).length === 0) {
480
+        return { message: text };
481
+      }
482
+      return parsed;
483
+    },
484
+
485
+    fullLogAsJsonText(fullLog) {
486
+      return JSON.stringify(this.parseFullLog(fullLog), null, 2);
487
+    },
488
+
137
     pretty(value) {
489
     pretty(value) {
138
       if (value === null || value === undefined) {
490
       if (value === null || value === undefined) {
139
         return "No data yet";
491
         return "No data yet";
145
       }
497
       }
146
     },
498
     },
147
 
499
 
500
+    unwrapApiData(payload) {
501
+      if (payload && typeof payload === "object" && !Array.isArray(payload) && Object.prototype.hasOwnProperty.call(payload, "data")) {
502
+        return payload.data;
503
+      }
504
+      return payload;
505
+    },
506
+
507
+    findFirstArray(node, depth = 0) {
508
+      if (depth > 5 || node === null || node === undefined) {
509
+        return null;
510
+      }
511
+      if (Array.isArray(node)) {
512
+        return node;
513
+      }
514
+      if (typeof node !== "object") {
515
+        return null;
516
+      }
517
+
518
+      const preferredKeys = ["items", "matches", "results", "alerts", "agents", "workflows", "apps", "rows", "data"];
519
+      for (const key of preferredKeys) {
520
+        if (Array.isArray(node[key])) {
521
+          return node[key];
522
+        }
523
+      }
524
+
525
+      for (const value of Object.values(node)) {
526
+        const found = this.findFirstArray(value, depth + 1);
527
+        if (found) {
528
+          return found;
529
+        }
530
+      }
531
+      return null;
532
+    },
533
+
534
+    normalizeTableRow(row, index) {
535
+      if (row && typeof row === "object" && !Array.isArray(row)) {
536
+        return row;
537
+      }
538
+      return { row: index + 1, value: row };
539
+    },
540
+
541
+    extractRows(payload) {
542
+      const root = this.unwrapApiData(payload);
543
+      const rows = this.findFirstArray(root) || [];
544
+      return rows.map((row, index) => this.normalizeTableRow(row, index));
545
+    },
546
+
547
+    tableColumns(rows) {
548
+      const colSet = new Set();
549
+      const sample = rows.slice(0, 30);
550
+      for (const row of sample) {
551
+        if (!row || typeof row !== "object" || Array.isArray(row)) {
552
+          continue;
553
+        }
554
+        for (const key of Object.keys(row)) {
555
+          if (colSet.size >= 10) {
556
+            break;
557
+          }
558
+          colSet.add(key);
559
+        }
560
+        if (colSet.size >= 10) {
561
+          break;
562
+        }
563
+      }
564
+      return Array.from(colSet);
565
+    },
566
+
567
+    cellText(value) {
568
+      if (value === null || value === undefined) {
569
+        return "";
570
+      }
571
+      if (typeof value === "string") {
572
+        return value;
573
+      }
574
+      if (typeof value === "number" || typeof value === "boolean") {
575
+        return String(value);
576
+      }
577
+      return JSON.stringify(value);
578
+    },
579
+
580
+    keyValueRows(payload) {
581
+      const root = this.unwrapApiData(payload);
582
+      if (!root || typeof root !== "object" || Array.isArray(root)) {
583
+        return [];
584
+      }
585
+      return Object.entries(root).map(([key, value]) => ({
586
+        key,
587
+        value: this.cellText(value),
588
+      }));
589
+    },
590
+
148
     providersList() {
591
     providersList() {
149
       return (this.iocForm.providersText || "")
592
       return (this.iocForm.providersText || "")
150
         .split(",")
593
         .split(",")
313
         this.setErr("IOC history failed", err);
756
         this.setErr("IOC history failed", err);
314
       }
757
       }
315
     },
758
     },
759
+    async lookupGeoIp() {
760
+      try {
761
+        const ip = String(this.geoip.ip || "").trim();
762
+        if (!ip) {
763
+          throw new Error("IP is required");
764
+        }
765
+        this.geoip.result = await this.apiCall(`/geoip/${encodeURIComponent(ip)}`);
766
+      } catch (err) {
767
+        this.setErr("GeoIP lookup failed", err);
768
+      }
769
+    },
316
     onFileSelected(event) {
770
     onFileSelected(event) {
317
       const files = event && event.target ? event.target.files : null;
771
       const files = event && event.target ? event.target.files : null;
318
       this.iocFileForm.file = files && files.length ? files[0] : null;
772
       this.iocFileForm.file = files && files.length ? files[0] : null;

+ 40 - 0
soc-integrator/app/ui/assets/styles.css

62
   background: rgb(255 247 237);
62
   background: rgb(255 247 237);
63
 }
63
 }
64
 
64
 
65
+.status-down {
66
+  border-color: rgb(254 202 202);
67
+  color: rgb(153 27 27);
68
+  background: rgb(254 242 242);
69
+}
70
+
65
 .input-label {
71
 .input-label {
66
   display: block;
72
   display: block;
67
   margin-bottom: 0.25rem;
73
   margin-bottom: 0.25rem;
181
   line-height: 1.1rem;
187
   line-height: 1.1rem;
182
   color: rgb(30 41 59);
188
   color: rgb(30 41 59);
183
 }
189
 }
190
+
191
+.table-wrap {
192
+  overflow: auto;
193
+  border: 1px solid rgb(226 232 240);
194
+  border-radius: 0.55rem;
195
+  background: white;
196
+  max-width: 100%;
197
+}
198
+
199
+.data-table {
200
+  width: max-content;
201
+  min-width: 100%;
202
+  border-collapse: collapse;
203
+  font-size: 0.72rem;
204
+  line-height: 1rem;
205
+}
206
+
207
+.data-table th,
208
+.data-table td {
209
+  border-bottom: 1px solid rgb(226 232 240);
210
+  text-align: left;
211
+  vertical-align: top;
212
+  padding: 0.4rem 0.5rem;
213
+  white-space: pre-wrap;
214
+  word-break: break-word;
215
+}
216
+
217
+.data-table th {
218
+  background: rgb(248 250 252);
219
+  font-weight: 600;
220
+  color: rgb(30 41 59);
221
+  position: sticky;
222
+  top: 0;
223
+}

+ 488 - 5
soc-integrator/app/ui/index.html

6
     <title>SOC Integrator Admin</title>
6
     <title>SOC Integrator Admin</title>
7
     <script src="https://cdn.tailwindcss.com"></script>
7
     <script src="https://cdn.tailwindcss.com"></script>
8
     <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
8
     <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
9
-    <link rel="stylesheet" href="/ui/assets/styles.css?v=20260303-3" />
10
-    <script src="/ui/assets/app.js?v=20260303-3"></script>
9
+    <link rel="stylesheet" href="/ui/assets/styles.css?v=20260303-17" />
10
+    <script src="/ui/assets/app.js?v=20260303-17"></script>
11
   </head>
11
   </head>
12
   <body class="bg-slate-100 text-slate-800" x-data="socUi()" x-init="init()">
12
   <body class="bg-slate-100 text-slate-800" x-data="socUi()" x-init="init()">
13
-    <div class="mx-auto max-w-7xl px-3 py-4 md:px-5 md:py-6">
13
+    <div class="mx-auto w-full max-w-none px-3 py-4 md:px-5 md:py-6">
14
       <header class="admin-card mb-4">
14
       <header class="admin-card mb-4">
15
         <div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
15
         <div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
16
           <div>
16
           <div>
44
         <strong>Error:</strong> <span x-text="errorMessage"></span>
44
         <strong>Error:</strong> <span x-text="errorMessage"></span>
45
       </section>
45
       </section>
46
 
46
 
47
-      <div class="grid gap-4 md:grid-cols-[220px,1fr]">
47
+      <div class="grid gap-4 lg:grid-cols-[200px,minmax(0,1fr)]">
48
         <aside class="admin-card h-fit p-2">
48
         <aside class="admin-card h-fit p-2">
49
           <nav class="flex flex-row gap-2 overflow-x-auto md:flex-col md:overflow-visible">
49
           <nav class="flex flex-row gap-2 overflow-x-auto md:flex-col md:overflow-visible">
50
             <template x-for="item in tabs" :key="item.key">
50
             <template x-for="item in tabs" :key="item.key">
53
           </nav>
53
           </nav>
54
         </aside>
54
         </aside>
55
 
55
 
56
-        <main class="space-y-4">
56
+        <main class="min-w-0 space-y-4">
57
           <section x-show="activeTab === 'overview'" x-cloak class="admin-card">
57
           <section x-show="activeTab === 'overview'" x-cloak class="admin-card">
58
             <div class="action-row">
58
             <div class="action-row">
59
               <button class="btn btn-primary" @click="loadHealth()">Refresh Health</button>
59
               <button class="btn btn-primary" @click="loadHealth()">Refresh Health</button>
63
               <div class="panel-block">
63
               <div class="panel-block">
64
                 <h3 class="panel-subtitle">Health</h3>
64
                 <h3 class="panel-subtitle">Health</h3>
65
                 <pre class="json-box" x-text="pretty(overview.health)"></pre>
65
                 <pre class="json-box" x-text="pretty(overview.health)"></pre>
66
+                <div class="table-wrap mt-2" x-show="keyValueRows(overview.health).length">
67
+                  <table class="data-table">
68
+                    <thead><tr><th>Field</th><th>Value</th></tr></thead>
69
+                    <tbody>
70
+                      <template x-for="row in keyValueRows(overview.health)" :key="row.key">
71
+                        <tr><td x-text="row.key"></td><td x-text="row.value"></td></tr>
72
+                      </template>
73
+                    </tbody>
74
+                  </table>
75
+                </div>
66
               </div>
76
               </div>
67
               <div class="panel-block">
77
               <div class="panel-block">
68
                 <h3 class="panel-subtitle">Auto Sync</h3>
78
                 <h3 class="panel-subtitle">Auto Sync</h3>
69
                 <pre class="json-box" x-text="pretty(overview.autoSync)"></pre>
79
                 <pre class="json-box" x-text="pretty(overview.autoSync)"></pre>
80
+                <div class="table-wrap mt-2" x-show="keyValueRows(overview.autoSync).length">
81
+                  <table class="data-table">
82
+                    <thead><tr><th>Field</th><th>Value</th></tr></thead>
83
+                    <tbody>
84
+                      <template x-for="row in keyValueRows(overview.autoSync)" :key="row.key">
85
+                        <tr><td x-text="row.key"></td><td x-text="row.value"></td></tr>
86
+                      </template>
87
+                    </tbody>
88
+                  </table>
89
+                </div>
70
               </div>
90
               </div>
71
             </div>
91
             </div>
72
           </section>
92
           </section>
73
 
93
 
94
+          <section x-show="activeTab === 'systems'" x-cloak class="admin-card space-y-4">
95
+            <div class="panel-block">
96
+              <div class="mb-2 flex flex-wrap items-center gap-2">
97
+                <h3 class="panel-subtitle mb-0">Systems Monitor</h3>
98
+                <button class="btn btn-primary" @click="loadSystemsMonitor()">Refresh Now</button>
99
+                <button class="btn btn-ghost" @click="systemsTogglePaused()" x-text="systemsMonitor.paused ? 'Resume' : 'Pause'"></button>
100
+                <span class="text-xs text-slate-500" x-text="systemsMonitor.loading ? 'Loading...' : 'Idle'"></span>
101
+                <span class="text-xs text-slate-500" x-text="systemsMonitor.lastRefreshAt ? `Last refresh: ${systemsMonitor.lastRefreshAt}` : 'Not refreshed yet'"></span>
102
+              </div>
103
+            <div class="grid gap-3 md:grid-cols-4">
104
+                <label class="text-sm">
105
+                  <span class="input-label">Minutes</span>
106
+                  <input class="input" type="number" min="1" x-model.number="systemsMonitor.minutes" />
107
+                </label>
108
+                <label class="text-sm">
109
+                  <span class="input-label">Limit</span>
110
+                  <input class="input" type="number" min="1" x-model.number="systemsMonitor.limit" />
111
+                </label>
112
+                <label class="text-sm">
113
+                  <span class="input-label">Auto refresh</span>
114
+                  <select class="input" :value="systemsMonitor.autoRefresh ? 'true' : 'false'" @change="systemsSetAutoRefresh($event.target.value === 'true')">
115
+                    <option value="true">true</option>
116
+                    <option value="false">false</option>
117
+                  </select>
118
+                </label>
119
+                <label class="text-sm">
120
+                  <span class="input-label">Interval (seconds)</span>
121
+                  <select class="input" :value="String(systemsMonitor.intervalSeconds)" @change="systemsSetInterval($event.target.value)">
122
+                    <option value="10">10</option>
123
+                    <option value="20">20</option>
124
+                    <option value="30">30</option>
125
+                  </select>
126
+                </label>
127
+              </div>
128
+            </div>
129
+
130
+            <div class="panel-block">
131
+              <h3 class="panel-subtitle">Run Sim Logs</h3>
132
+              <div class="grid gap-3 md:grid-cols-3 lg:grid-cols-6">
133
+                <label class="text-sm">
134
+                  <span class="input-label">Script</span>
135
+                  <select class="input" x-model="simLogs.form.script">
136
+                    <option value="fortigate">fortigate</option>
137
+                    <option value="endpoint">endpoint</option>
138
+                    <option value="cisco">cisco</option>
139
+                    <option value="proposal_required">proposal_required</option>
140
+                    <option value="proposal_appendix_b">proposal_appendix_b</option>
141
+                    <option value="proposal_appendix_c">proposal_appendix_c</option>
142
+                    <option value="wazuh_test">wazuh_test</option>
143
+                  </select>
144
+                </label>
145
+                <label class="text-sm">
146
+                  <span class="input-label">Target</span>
147
+                  <input class="input" x-model="simLogs.form.target" placeholder="all" />
148
+                </label>
149
+                <label class="text-sm" x-show="simScriptUsesScenario()">
150
+                  <span class="input-label">Scenario</span>
151
+                  <input class="input" x-model="simLogs.form.scenario" placeholder="all" />
152
+                </label>
153
+                <label class="text-sm">
154
+                  <span class="input-label">Count</span>
155
+                  <input class="input" type="number" min="1" x-model.number="simLogs.form.count" />
156
+                </label>
157
+                <label class="text-sm">
158
+                  <span class="input-label">Delay (s)</span>
159
+                  <input class="input" type="number" min="0" step="0.1" x-model.number="simLogs.form.delay_seconds" />
160
+                </label>
161
+                <label class="text-sm">
162
+                  <span class="input-label">Forever</span>
163
+                  <select class="input" x-model="simLogs.form.forever">
164
+                    <option :value="false">false</option>
165
+                    <option :value="true">true</option>
166
+                  </select>
167
+                </label>
168
+              </div>
169
+              <div class="action-row mt-2">
170
+                <button class="btn btn-primary" @click="startSimRun()">Start</button>
171
+                <button class="btn btn-neutral" @click="loadSimRuns()">Refresh Runs</button>
172
+                <button class="btn btn-danger" @click="stopRunningSimRuns()">Stop Running</button>
173
+                <button class="btn btn-ghost" @click="loadSimOutput()">Refresh Logs</button>
174
+              </div>
175
+              <pre class="json-box mt-2" x-text="pretty(simLogs.startResult)"></pre>
176
+              <div class="table-wrap mt-2" x-show="simRunRows().length">
177
+                <table class="data-table">
178
+                  <thead>
179
+                    <tr>
180
+                      <template x-for="col in tableColumns(simRunRows())" :key="col">
181
+                        <th x-text="col"></th>
182
+                      </template>
183
+                      <th>Logs</th>
184
+                      <th>Action</th>
185
+                    </tr>
186
+                  </thead>
187
+                  <tbody>
188
+                    <template x-for="(row, idx) in simRunRows()" :key="idx">
189
+                      <tr>
190
+                        <template x-for="col in tableColumns(simRunRows())" :key="col">
191
+                          <td x-text="cellText(row[col])"></td>
192
+                        </template>
193
+                        <td>
194
+                          <button class="btn btn-ghost" @click="selectSimRun(row.run_id)">View</button>
195
+                        </td>
196
+                        <td>
197
+                          <button class="btn btn-danger" x-show="row.running" @click="stopSimRun(row.run_id)">Stop</button>
198
+                        </td>
199
+                      </tr>
200
+                    </template>
201
+                  </tbody>
202
+                </table>
203
+              </div>
204
+              <div class="mt-3 rounded-lg border border-slate-200 bg-white p-3" x-show="simLogs.selectedRunId">
205
+                <div class="mb-2 flex flex-wrap items-center gap-2">
206
+                  <h4 class="panel-mini-title mb-0">Run Output</h4>
207
+                  <span class="text-xs text-slate-600" x-text="`run_id: ${simLogs.selectedRunId}`"></span>
208
+                  <span class="status-pill" :class="simSelectedRun() && simSelectedRun().running ? 'status-ok' : 'status-warn'" x-text="simSelectedRun() && simSelectedRun().running ? 'running' : 'stopped'"></span>
209
+                </div>
210
+                <div class="grid gap-3 md:grid-cols-3">
211
+                  <label class="text-sm">
212
+                    <span class="input-label">Tail lines</span>
213
+                    <input class="input" type="number" min="10" max="1000" x-model.number="simLogs.outputLimit" />
214
+                  </label>
215
+                  <label class="text-sm">
216
+                    <span class="input-label">Auto refresh logs</span>
217
+                    <select class="input" x-model="simLogs.autoRefresh" @change="startSimLogsAutoRefresh()">
218
+                      <option :value="true">true</option>
219
+                      <option :value="false">false</option>
220
+                    </select>
221
+                  </label>
222
+                  <label class="text-sm">
223
+                    <span class="input-label">Interval (seconds)</span>
224
+                    <input class="input" type="number" min="2" max="60" x-model.number="simLogs.intervalSeconds" @change="startSimLogsAutoRefresh()" />
225
+                  </label>
226
+                </div>
227
+                <pre class="json-box mt-2" x-text="unwrapApiData(simLogs.output)?.text || 'No logs yet'"></pre>
228
+              </div>
229
+
230
+              <div class="mt-3 rounded-lg border border-slate-200 bg-white p-3" x-show="simLogs.selectedRunId">
231
+                <div class="mb-2 flex flex-wrap items-center gap-2">
232
+                  <h4 class="panel-mini-title mb-0">Wazuh Live Correlation</h4>
233
+                  <span class="text-xs text-slate-600" x-text="`run_id: ${simLogs.selectedRunId}`"></span>
234
+                  <span class="status-pill status-ok">auto refresh every 5s</span>
235
+                </div>
236
+                <div class="grid gap-3 md:grid-cols-2">
237
+                  <div class="text-sm">
238
+                    <span class="input-label">Records</span>
239
+                    <div class="input">Latest 100 (no filter)</div>
240
+                  </div>
241
+                  <div class="action-row mt-6">
242
+                    <button class="btn btn-neutral" @click="loadSimWazuhLatest()">Refresh Wazuh</button>
243
+                  </div>
244
+                </div>
245
+                <div class="mt-2">
246
+                  <label class="text-sm inline-flex items-center gap-2">
247
+                    <input type="checkbox" x-model="simWazuh.showQuery" />
248
+                    <span>Show query used</span>
249
+                  </label>
250
+                </div>
251
+                <pre class="json-box mt-2" x-show="simWazuh.showQuery" x-text="pretty(unwrapApiData(simWazuh.latest)?.query || null)"></pre>
252
+                <div class="mt-2">
253
+                  <div>
254
+                    <h5 class="panel-mini-title">Latest Event Logs</h5>
255
+                    <div class="table-wrap mt-2" x-show="simWazuhEventTableRows().length">
256
+                      <table class="data-table">
257
+                        <thead>
258
+                          <tr>
259
+                            <th>Time</th>
260
+                            <th>rule.id</th>
261
+                            <th>rule.description</th>
262
+                            <th>full_log</th>
263
+                          </tr>
264
+                        </thead>
265
+                        <tbody>
266
+                          <template x-for="(row, idx) in simWazuhEventTableRows()" :key="idx">
267
+                            <tr>
268
+                              <td x-text="cellText(row.time)"></td>
269
+                              <td x-text="cellText(row.rule_id)"></td>
270
+                              <td x-text="cellText(row.rule_description)"></td>
271
+                              <td><pre class="text-xs whitespace-pre-wrap" x-text="fullLogAsJsonText(row.full_log)"></pre></td>
272
+                            </tr>
273
+                          </template>
274
+                        </tbody>
275
+                      </table>
276
+                    </div>
277
+                    <div class="text-xs text-slate-500" x-show="!simWazuhEventTableRows().length">No events found for selected run yet.</div>
278
+                  </div>
279
+                </div>
280
+              </div>
281
+            </div>
282
+
283
+            <div class="grid gap-3 lg:grid-cols-4">
284
+              <template x-for="meta in systemsCardMeta" :key="meta.key">
285
+                <div class="panel-block">
286
+                  <div class="mb-2 flex items-center justify-between">
287
+                    <h4 class="panel-mini-title" x-text="meta.label"></h4>
288
+                    <span class="status-pill" :class="systemsStatusClass(systemsCard(meta.key).status || 'down')" x-text="systemsCard(meta.key).status || 'down'"></span>
289
+                  </div>
290
+                  <div class="text-xs text-slate-600">Latency: <span x-text="cellText(systemsCard(meta.key).latency_ms)"></span> ms</div>
291
+                  <div class="text-xs text-slate-600">Last OK: <span x-text="cellText(systemsCard(meta.key).last_ok_at)"></span></div>
292
+                  <div class="mt-1 text-xs text-rose-700" x-show="systemsCard(meta.key).last_error" x-text="`Error: ${systemsCard(meta.key).last_error}`"></div>
293
+                </div>
294
+              </template>
295
+            </div>
296
+
297
+            <div class="panel-block">
298
+              <h3 class="panel-subtitle">Incident Pipeline KPIs</h3>
299
+              <div class="table-wrap mt-2" x-show="systemsPipelineRows().length">
300
+                <table class="data-table">
301
+                  <thead><tr><th>KPI</th><th>Value</th></tr></thead>
302
+                  <tbody>
303
+                    <template x-for="row in systemsPipelineRows()" :key="row.key">
304
+                      <tr><td x-text="row.key"></td><td x-text="row.value"></td></tr>
305
+                    </template>
306
+                  </tbody>
307
+                </table>
308
+              </div>
309
+            </div>
310
+
311
+            <div class="grid gap-3 lg:grid-cols-2">
312
+              <template x-for="meta in systemsCardMeta" :key="`table-${meta.key}`">
313
+                <div class="panel-block">
314
+                  <h3 class="panel-subtitle" x-text="`${meta.label} Recent Data`"></h3>
315
+                  <div class="table-wrap mt-2" x-show="systemsRecentRows(meta.key).length">
316
+                    <table class="data-table">
317
+                      <thead>
318
+                        <tr>
319
+                          <template x-for="col in systemsRecentColumns(meta.key)" :key="col">
320
+                            <th x-text="col"></th>
321
+                          </template>
322
+                        </tr>
323
+                      </thead>
324
+                      <tbody>
325
+                        <template x-for="(row, idx) in systemsRecentRows(meta.key)" :key="idx">
326
+                          <tr>
327
+                            <template x-for="col in systemsRecentColumns(meta.key)" :key="col">
328
+                              <td x-text="cellText(row[col])"></td>
329
+                            </template>
330
+                          </tr>
331
+                        </template>
332
+                      </tbody>
333
+                    </table>
334
+                  </div>
335
+                  <div class="text-xs text-slate-500" x-show="!systemsRecentRows(meta.key).length">No recent rows</div>
336
+                </div>
337
+              </template>
338
+            </div>
339
+          </section>
340
+
74
           <section x-show="activeTab === 'monitoring'" x-cloak class="admin-card space-y-4">
341
           <section x-show="activeTab === 'monitoring'" x-cloak class="admin-card space-y-4">
75
             <div class="panel-block">
342
             <div class="panel-block">
76
               <div class="mb-2 flex flex-wrap items-center gap-2">
343
               <div class="mb-2 flex flex-wrap items-center gap-2">
104
                 <button class="btn btn-primary" @click="runLogLossCheck()">Run Check</button>
371
                 <button class="btn btn-primary" @click="runLogLossCheck()">Run Check</button>
105
               </div>
372
               </div>
106
               <pre class="json-box mt-2" x-text="pretty(logLoss.result)"></pre>
373
               <pre class="json-box mt-2" x-text="pretty(logLoss.result)"></pre>
374
+              <div class="table-wrap mt-2" x-show="extractRows(logLoss.result).length">
375
+                <table class="data-table">
376
+                  <thead>
377
+                    <tr>
378
+                      <template x-for="col in tableColumns(extractRows(logLoss.result))" :key="col">
379
+                        <th x-text="col"></th>
380
+                      </template>
381
+                    </tr>
382
+                  </thead>
383
+                  <tbody>
384
+                    <template x-for="(row, idx) in extractRows(logLoss.result)" :key="idx">
385
+                      <tr>
386
+                        <template x-for="col in tableColumns(extractRows(logLoss.result))" :key="col">
387
+                          <td x-text="cellText(row[col])"></td>
388
+                        </template>
389
+                      </tr>
390
+                    </template>
391
+                  </tbody>
392
+                </table>
393
+              </div>
107
             </div>
394
             </div>
108
 
395
 
109
             <div class="panel-block">
396
             <div class="panel-block">
125
                 <div><h4 class="panel-mini-title">Evaluate</h4><pre class="json-box" x-text="pretty(cDetections.evaluate)"></pre></div>
412
                 <div><h4 class="panel-mini-title">Evaluate</h4><pre class="json-box" x-text="pretty(cDetections.evaluate)"></pre></div>
126
                 <div><h4 class="panel-mini-title">History</h4><pre class="json-box" x-text="pretty(cDetections.history)"></pre></div>
413
                 <div><h4 class="panel-mini-title">History</h4><pre class="json-box" x-text="pretty(cDetections.history)"></pre></div>
127
               </div>
414
               </div>
415
+              <div class="table-wrap mt-2" x-show="extractRows(cDetections.evaluate).length">
416
+                <table class="data-table">
417
+                  <thead>
418
+                    <tr>
419
+                      <template x-for="col in tableColumns(extractRows(cDetections.evaluate))" :key="col">
420
+                        <th x-text="col"></th>
421
+                      </template>
422
+                    </tr>
423
+                  </thead>
424
+                  <tbody>
425
+                    <template x-for="(row, idx) in extractRows(cDetections.evaluate)" :key="idx">
426
+                      <tr>
427
+                        <template x-for="col in tableColumns(extractRows(cDetections.evaluate))" :key="col">
428
+                          <td x-text="cellText(row[col])"></td>
429
+                        </template>
430
+                      </tr>
431
+                    </template>
432
+                  </tbody>
433
+                </table>
434
+              </div>
435
+              <div class="table-wrap mt-2" x-show="extractRows(cDetections.history).length">
436
+                <table class="data-table">
437
+                  <thead>
438
+                    <tr>
439
+                      <template x-for="col in tableColumns(extractRows(cDetections.history))" :key="col">
440
+                        <th x-text="col"></th>
441
+                      </template>
442
+                    </tr>
443
+                  </thead>
444
+                  <tbody>
445
+                    <template x-for="(row, idx) in extractRows(cDetections.history)" :key="idx">
446
+                      <tr>
447
+                        <template x-for="col in tableColumns(extractRows(cDetections.history))" :key="col">
448
+                          <td x-text="cellText(row[col])"></td>
449
+                        </template>
450
+                      </tr>
451
+                    </template>
452
+                  </tbody>
453
+                </table>
454
+              </div>
128
             </div>
455
             </div>
129
           </section>
456
           </section>
130
 
457
 
150
                 <div><h4 class="panel-mini-title">Evaluate</h4><pre class="json-box" x-text="pretty(ioc.evaluate)"></pre></div>
477
                 <div><h4 class="panel-mini-title">Evaluate</h4><pre class="json-box" x-text="pretty(ioc.evaluate)"></pre></div>
151
                 <div><h4 class="panel-mini-title">History</h4><pre class="json-box" x-text="pretty(ioc.history)"></pre></div>
478
                 <div><h4 class="panel-mini-title">History</h4><pre class="json-box" x-text="pretty(ioc.history)"></pre></div>
152
               </div>
479
               </div>
480
+              <div class="table-wrap mt-2" x-show="extractRows(ioc.history).length">
481
+                <table class="data-table">
482
+                  <thead>
483
+                    <tr>
484
+                      <template x-for="col in tableColumns(extractRows(ioc.history))" :key="col">
485
+                        <th x-text="col"></th>
486
+                      </template>
487
+                    </tr>
488
+                  </thead>
489
+                  <tbody>
490
+                    <template x-for="(row, idx) in extractRows(ioc.history)" :key="idx">
491
+                      <tr>
492
+                        <template x-for="col in tableColumns(extractRows(ioc.history))" :key="col">
493
+                          <td x-text="cellText(row[col])"></td>
494
+                        </template>
495
+                      </tr>
496
+                    </template>
497
+                  </tbody>
498
+                </table>
499
+              </div>
153
             </div>
500
             </div>
154
 
501
 
155
             <div class="panel-block">
502
             <div class="panel-block">
170
                 <div><h4 class="panel-mini-title">Analysis</h4><pre class="json-box" x-text="pretty(ioc.analysis)"></pre></div>
517
                 <div><h4 class="panel-mini-title">Analysis</h4><pre class="json-box" x-text="pretty(ioc.analysis)"></pre></div>
171
                 <div><h4 class="panel-mini-title">Evaluate File</h4><pre class="json-box" x-text="pretty(ioc.fileEval)"></pre></div>
518
                 <div><h4 class="panel-mini-title">Evaluate File</h4><pre class="json-box" x-text="pretty(ioc.fileEval)"></pre></div>
172
               </div>
519
               </div>
520
+              <div class="table-wrap mt-2" x-show="extractRows(ioc.analysis).length">
521
+                <table class="data-table">
522
+                  <thead>
523
+                    <tr>
524
+                      <template x-for="col in tableColumns(extractRows(ioc.analysis))" :key="col">
525
+                        <th x-text="col"></th>
526
+                      </template>
527
+                    </tr>
528
+                  </thead>
529
+                  <tbody>
530
+                    <template x-for="(row, idx) in extractRows(ioc.analysis)" :key="idx">
531
+                      <tr>
532
+                        <template x-for="col in tableColumns(extractRows(ioc.analysis))" :key="col">
533
+                          <td x-text="cellText(row[col])"></td>
534
+                        </template>
535
+                      </tr>
536
+                    </template>
537
+                  </tbody>
538
+                </table>
539
+              </div>
540
+            </div>
541
+          </section>
542
+
543
+          <section x-show="activeTab === 'geoip'" x-cloak class="admin-card space-y-4">
544
+            <div class="panel-block">
545
+              <h3 class="panel-subtitle">GeoIP Lookup</h3>
546
+              <div class="grid gap-3 md:grid-cols-3">
547
+                <label class="text-sm md:col-span-2">
548
+                  <span class="input-label">IP Address</span>
549
+                  <input x-model="geoip.ip" class="input" placeholder="8.8.8.8" />
550
+                </label>
551
+                <div class="action-row mt-6">
552
+                  <button class="btn btn-primary" @click="lookupGeoIp()">Lookup</button>
553
+                </div>
554
+              </div>
555
+              <pre class="json-box mt-2" x-text="pretty(geoip.result)"></pre>
556
+              <div class="table-wrap mt-2" x-show="keyValueRows(unwrapApiData(geoip.result)?.geoip || {}).length">
557
+                <table class="data-table">
558
+                  <thead><tr><th>Field</th><th>Value</th></tr></thead>
559
+                  <tbody>
560
+                    <template x-for="row in keyValueRows(unwrapApiData(geoip.result)?.geoip || {})" :key="row.key">
561
+                      <tr><td x-text="row.key"></td><td x-text="row.value"></td></tr>
562
+                    </template>
563
+                  </tbody>
564
+                </table>
565
+              </div>
173
             </div>
566
             </div>
174
           </section>
567
           </section>
175
 
568
 
193
                 <button class="btn btn-neutral" @click="loadIrisTickets()">Load</button>
586
                 <button class="btn btn-neutral" @click="loadIrisTickets()">Load</button>
194
               </div>
587
               </div>
195
               <pre class="json-box" x-text="pretty(iris.list)"></pre>
588
               <pre class="json-box" x-text="pretty(iris.list)"></pre>
589
+              <div class="table-wrap mt-2" x-show="extractRows(iris.list).length">
590
+                <table class="data-table">
591
+                  <thead>
592
+                    <tr>
593
+                      <template x-for="col in tableColumns(extractRows(iris.list))" :key="col">
594
+                        <th x-text="col"></th>
595
+                      </template>
596
+                    </tr>
597
+                  </thead>
598
+                  <tbody>
599
+                    <template x-for="(row, idx) in extractRows(iris.list)" :key="idx">
600
+                      <tr>
601
+                        <template x-for="col in tableColumns(extractRows(iris.list))" :key="col">
602
+                          <td x-text="cellText(row[col])"></td>
603
+                        </template>
604
+                      </tr>
605
+                    </template>
606
+                  </tbody>
607
+                </table>
608
+              </div>
196
             </div>
609
             </div>
197
           </section>
610
           </section>
198
 
611
 
209
                 <div><h4 class="panel-mini-title">Health/Auth</h4><pre class="json-box" x-text="pretty(shuffle.status)"></pre></div>
622
                 <div><h4 class="panel-mini-title">Health/Auth</h4><pre class="json-box" x-text="pretty(shuffle.status)"></pre></div>
210
                 <div><h4 class="panel-mini-title">Apps/Workflows</h4><pre class="json-box" x-text="pretty(shuffle.catalog)"></pre></div>
623
                 <div><h4 class="panel-mini-title">Apps/Workflows</h4><pre class="json-box" x-text="pretty(shuffle.catalog)"></pre></div>
211
               </div>
624
               </div>
625
+              <div class="table-wrap mt-2" x-show="extractRows(shuffle.catalog).length">
626
+                <table class="data-table">
627
+                  <thead>
628
+                    <tr>
629
+                      <template x-for="col in tableColumns(extractRows(shuffle.catalog))" :key="col">
630
+                        <th x-text="col"></th>
631
+                      </template>
632
+                    </tr>
633
+                  </thead>
634
+                  <tbody>
635
+                    <template x-for="(row, idx) in extractRows(shuffle.catalog)" :key="idx">
636
+                      <tr>
637
+                        <template x-for="col in tableColumns(extractRows(shuffle.catalog))" :key="col">
638
+                          <td x-text="cellText(row[col])"></td>
639
+                        </template>
640
+                      </tr>
641
+                    </template>
642
+                  </tbody>
643
+                </table>
644
+              </div>
212
             </div>
645
             </div>
213
             <div class="panel-block">
646
             <div class="panel-block">
214
               <h3 class="panel-subtitle">Execute Workflow</h3>
647
               <h3 class="panel-subtitle">Execute Workflow</h3>
241
               </div>
674
               </div>
242
               <label class="text-sm mt-2 block"><span class="input-label">Query (alerts/logs)</span><input x-model="wazuhList.q" class="input" placeholder="optional q" /></label>
675
               <label class="text-sm mt-2 block"><span class="input-label">Query (alerts/logs)</span><input x-model="wazuhList.q" class="input" placeholder="optional q" /></label>
243
               <pre class="json-box mt-2" x-text="pretty(wazuh.list)"></pre>
676
               <pre class="json-box mt-2" x-text="pretty(wazuh.list)"></pre>
677
+              <div class="table-wrap mt-2" x-show="extractRows(wazuh.list).length">
678
+                <table class="data-table">
679
+                  <thead>
680
+                    <tr>
681
+                      <template x-for="col in tableColumns(extractRows(wazuh.list))" :key="col">
682
+                        <th x-text="col"></th>
683
+                      </template>
684
+                    </tr>
685
+                  </thead>
686
+                  <tbody>
687
+                    <template x-for="(row, idx) in extractRows(wazuh.list)" :key="idx">
688
+                      <tr>
689
+                        <template x-for="col in tableColumns(extractRows(wazuh.list))" :key="col">
690
+                          <td x-text="cellText(row[col])"></td>
691
+                        </template>
692
+                      </tr>
693
+                    </template>
694
+                  </tbody>
695
+                </table>
696
+              </div>
244
             </div>
697
             </div>
245
             <div class="panel-block">
698
             <div class="panel-block">
246
               <h3 class="panel-subtitle">Sync Wazuh to MVP</h3>
699
               <h3 class="panel-subtitle">Sync Wazuh to MVP</h3>
264
               </div>
717
               </div>
265
               <label class="text-sm block"><span class="input-label">Policy JSON</span><textarea x-model="mvp.policyText" rows="6" class="input code-input"></textarea></label>
718
               <label class="text-sm block"><span class="input-label">Policy JSON</span><textarea x-model="mvp.policyText" rows="6" class="input code-input"></textarea></label>
266
               <pre class="json-box mt-2" x-text="pretty(mvp.status)"></pre>
719
               <pre class="json-box mt-2" x-text="pretty(mvp.status)"></pre>
720
+              <div class="table-wrap mt-2" x-show="keyValueRows(mvp.status).length">
721
+                <table class="data-table">
722
+                  <thead><tr><th>Field</th><th>Value</th></tr></thead>
723
+                  <tbody>
724
+                    <template x-for="row in keyValueRows(mvp.status)" :key="row.key">
725
+                      <tr><td x-text="row.key"></td><td x-text="row.value"></td></tr>
726
+                    </template>
727
+                  </tbody>
728
+                </table>
729
+              </div>
267
             </div>
730
             </div>
268
 
731
 
269
             <div class="panel-block">
732
             <div class="panel-block">
316
               <label class="text-sm block mt-2"><span class="input-label">Body (JSON)</span><textarea x-model="explorer.bodyText" rows="8" class="input code-input"></textarea></label>
779
               <label class="text-sm block mt-2"><span class="input-label">Body (JSON)</span><textarea x-model="explorer.bodyText" rows="8" class="input code-input"></textarea></label>
317
               <button class="btn btn-primary mt-2" @click="runExplorerRequest()">Run Request</button>
780
               <button class="btn btn-primary mt-2" @click="runExplorerRequest()">Run Request</button>
318
               <pre class="json-box mt-2" x-text="pretty(explorer.result)"></pre>
781
               <pre class="json-box mt-2" x-text="pretty(explorer.result)"></pre>
782
+              <div class="table-wrap mt-2" x-show="extractRows(explorer.result).length">
783
+                <table class="data-table">
784
+                  <thead>
785
+                    <tr>
786
+                      <template x-for="col in tableColumns(extractRows(explorer.result))" :key="col">
787
+                        <th x-text="col"></th>
788
+                      </template>
789
+                    </tr>
790
+                  </thead>
791
+                  <tbody>
792
+                    <template x-for="(row, idx) in extractRows(explorer.result)" :key="idx">
793
+                      <tr>
794
+                        <template x-for="col in tableColumns(extractRows(explorer.result))" :key="col">
795
+                          <td x-text="cellText(row[col])"></td>
796
+                        </template>
797
+                      </tr>
798
+                    </template>
799
+                  </tbody>
800
+                </table>
801
+              </div>
319
             </div>
802
             </div>
320
           </section>
803
           </section>
321
         </main>
804
         </main>