tum 2 週間 前
コミット
5e215c08f6

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

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

+ 96 - 0
progress-update.md

@@ -475,3 +475,99 @@ Additional FastAPI-generated endpoints:
475 475
 
476 476
 - `GET /docs`
477 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,7 +6,10 @@ ENV PYTHONUNBUFFERED=1
6 6
 WORKDIR /app
7 7
 
8 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 14
 COPY app ./app
12 15
 

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

@@ -8,8 +8,11 @@ import httpx
8 8
 
9 9
 
10 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 16
         self.cache_ttl_seconds = max(60, int(cache_ttl_seconds))
14 17
         self._cache: dict[str, tuple[float, dict[str, Any]]] = {}
15 18
 
@@ -42,33 +45,63 @@ class GeoIpAdapter:
42 45
             out["cached"] = True
43 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 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 106
         self._cache[candidate] = (now + self.cache_ttl_seconds, result)
74 107
         return result

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

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

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

@@ -1,6 +1,12 @@
1 1
 import asyncio
2 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 10
 from pathlib import Path
5 11
 
6 12
 from fastapi import Depends, FastAPI, File, HTTPException, Request, UploadFile
@@ -25,6 +31,7 @@ from app.models import (
25 31
     IrisTicketCreateRequest,
26 32
     LogLossCheckRequest,
27 33
     LogLossStreamCheck,
34
+    SimLogRunRequest,
28 35
     ShuffleLoginRequest,
29 36
     ShuffleProxyRequest,
30 37
     TriggerShuffleRequest,
@@ -40,6 +47,8 @@ app = FastAPI(title=settings.app_name, version="0.1.0")
40 47
 logger = logging.getLogger(__name__)
41 48
 UI_DIR = Path(__file__).resolve().parent / "ui"
42 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 53
 wazuh_adapter = WazuhAdapter(
45 54
     base_url=settings.wazuh_base_url,
@@ -357,6 +366,11 @@ async def startup() -> None:
357 366
         "last_result": None,
358 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 374
     if settings.wazuh_auto_sync_enabled:
361 375
         app.state.wazuh_auto_sync_task = asyncio.create_task(_wazuh_auto_sync_loop())
362 376
         logger.info(
@@ -393,6 +407,11 @@ async def shutdown() -> None:
393 407
             await ll_task
394 408
         except asyncio.CancelledError:
395 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 417
 @app.get(
@@ -768,6 +787,266 @@ def _build_abuseipdb_ioc_result(
768 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 1050
 @app.post(
772 1051
     "/ioc/enrich",
773 1052
     response_model=ApiResponse,
@@ -1104,6 +1383,17 @@ async def ioc_history(limit: int = 50, offset: int = 0) -> ApiResponse:
1104 1383
 
1105 1384
 
1106 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 1397
     "/sync/wazuh-version",
1108 1398
     response_model=ApiResponse,
1109 1399
     summary="Wazuh version",
@@ -1249,6 +1539,402 @@ async def wazuh_auto_sync_status() -> ApiResponse:
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 1938
 @app.post(
1253 1939
     "/monitor/log-loss/check",
1254 1940
     response_model=ApiResponse,

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

@@ -325,6 +325,36 @@ class CDetectionEvaluateRequest(BaseModel):
325 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 358
 class ApiResponse(BaseModel):
329 359
     ok: bool = True
330 360
     message: str = "ok"

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

@@ -37,6 +37,95 @@ DEFAULT_POLICY: dict[str, Any] = {
37 37
 
38 38
 
39 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 129
     def has_event(self, source: str, event_id: str) -> bool:
41 130
         with get_conn() as conn, conn.cursor() as cur:
42 131
             cur.execute(

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

@@ -26,8 +26,10 @@ window.socUi = function socUi() {
26 26
   return {
27 27
     tabs: [
28 28
       { key: "overview", label: "Overview" },
29
+      { key: "systems", label: "Systems" },
29 30
       { key: "monitoring", label: "Monitoring" },
30 31
       { key: "ioc", label: "IOC" },
32
+      { key: "geoip", label: "GeoIP" },
31 33
       { key: "iris", label: "IRIS" },
32 34
       { key: "shuffle", label: "Shuffle" },
33 35
       { key: "wazuh", label: "Wazuh" },
@@ -36,15 +38,57 @@ window.socUi = function socUi() {
36 38
     ],
37 39
     activeTab: "overview",
38 40
     apiBase: window.location.origin,
39
-    internalApiKey: "",
41
+    internalApiKey: "dev-internal-key",
40 42
     errorMessage: "",
41 43
 
42 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 87
     logLoss: { result: null },
45 88
     cDetections: { state: null, evaluate: null, history: null },
46 89
 
47 90
     ioc: { enrich: null, evaluate: null, history: null, upload: null, analysis: null, fileEval: null },
91
+    geoip: { ip: "8.8.8.8", result: null },
48 92
     iris: { create: null, list: null },
49 93
     shuffle: { status: null, catalog: null, execute: null },
50 94
     wazuh: { status: null, list: null, sync: null },
@@ -131,9 +175,317 @@ window.socUi = function socUi() {
131 175
       this.loadHealth();
132 176
       this.loadAutoSync();
133 177
       this.loadCState();
178
+      this.loadSystemsMonitor();
179
+      this.loadSimRuns();
180
+      this.startSimLogsAutoRefresh();
181
+      this.startSystemsMonitorAutoRefresh();
134 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 489
     pretty(value) {
138 490
       if (value === null || value === undefined) {
139 491
         return "No data yet";
@@ -145,6 +497,97 @@ window.socUi = function socUi() {
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 591
     providersList() {
149 592
       return (this.iocForm.providersText || "")
150 593
         .split(",")
@@ -313,6 +756,17 @@ window.socUi = function socUi() {
313 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 770
     onFileSelected(event) {
317 771
       const files = event && event.target ? event.target.files : null;
318 772
       this.iocFileForm.file = files && files.length ? files[0] : null;

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

@@ -62,6 +62,12 @@
62 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 71
 .input-label {
66 72
   display: block;
67 73
   margin-bottom: 0.25rem;
@@ -181,3 +187,37 @@
181 187
   line-height: 1.1rem;
182 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,11 +6,11 @@
6 6
     <title>SOC Integrator Admin</title>
7 7
     <script src="https://cdn.tailwindcss.com"></script>
8 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 11
   </head>
12 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 14
       <header class="admin-card mb-4">
15 15
         <div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
16 16
           <div>
@@ -44,7 +44,7 @@
44 44
         <strong>Error:</strong> <span x-text="errorMessage"></span>
45 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 48
         <aside class="admin-card h-fit p-2">
49 49
           <nav class="flex flex-row gap-2 overflow-x-auto md:flex-col md:overflow-visible">
50 50
             <template x-for="item in tabs" :key="item.key">
@@ -53,7 +53,7 @@
53 53
           </nav>
54 54
         </aside>
55 55
 
56
-        <main class="space-y-4">
56
+        <main class="min-w-0 space-y-4">
57 57
           <section x-show="activeTab === 'overview'" x-cloak class="admin-card">
58 58
             <div class="action-row">
59 59
               <button class="btn btn-primary" @click="loadHealth()">Refresh Health</button>
@@ -63,14 +63,281 @@
63 63
               <div class="panel-block">
64 64
                 <h3 class="panel-subtitle">Health</h3>
65 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 76
               </div>
67 77
               <div class="panel-block">
68 78
                 <h3 class="panel-subtitle">Auto Sync</h3>
69 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 90
               </div>
71 91
             </div>
72 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 341
           <section x-show="activeTab === 'monitoring'" x-cloak class="admin-card space-y-4">
75 342
             <div class="panel-block">
76 343
               <div class="mb-2 flex flex-wrap items-center gap-2">
@@ -104,6 +371,26 @@
104 371
                 <button class="btn btn-primary" @click="runLogLossCheck()">Run Check</button>
105 372
               </div>
106 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 394
             </div>
108 395
 
109 396
             <div class="panel-block">
@@ -125,6 +412,46 @@
125 412
                 <div><h4 class="panel-mini-title">Evaluate</h4><pre class="json-box" x-text="pretty(cDetections.evaluate)"></pre></div>
126 413
                 <div><h4 class="panel-mini-title">History</h4><pre class="json-box" x-text="pretty(cDetections.history)"></pre></div>
127 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 455
             </div>
129 456
           </section>
130 457
 
@@ -150,6 +477,26 @@
150 477
                 <div><h4 class="panel-mini-title">Evaluate</h4><pre class="json-box" x-text="pretty(ioc.evaluate)"></pre></div>
151 478
                 <div><h4 class="panel-mini-title">History</h4><pre class="json-box" x-text="pretty(ioc.history)"></pre></div>
152 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 500
             </div>
154 501
 
155 502
             <div class="panel-block">
@@ -170,6 +517,52 @@
170 517
                 <div><h4 class="panel-mini-title">Analysis</h4><pre class="json-box" x-text="pretty(ioc.analysis)"></pre></div>
171 518
                 <div><h4 class="panel-mini-title">Evaluate File</h4><pre class="json-box" x-text="pretty(ioc.fileEval)"></pre></div>
172 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 566
             </div>
174 567
           </section>
175 568
 
@@ -193,6 +586,26 @@
193 586
                 <button class="btn btn-neutral" @click="loadIrisTickets()">Load</button>
194 587
               </div>
195 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 609
             </div>
197 610
           </section>
198 611
 
@@ -209,6 +622,26 @@
209 622
                 <div><h4 class="panel-mini-title">Health/Auth</h4><pre class="json-box" x-text="pretty(shuffle.status)"></pre></div>
210 623
                 <div><h4 class="panel-mini-title">Apps/Workflows</h4><pre class="json-box" x-text="pretty(shuffle.catalog)"></pre></div>
211 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 645
             </div>
213 646
             <div class="panel-block">
214 647
               <h3 class="panel-subtitle">Execute Workflow</h3>
@@ -241,6 +674,26 @@
241 674
               </div>
242 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 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 697
             </div>
245 698
             <div class="panel-block">
246 699
               <h3 class="panel-subtitle">Sync Wazuh to MVP</h3>
@@ -264,6 +717,16 @@
264 717
               </div>
265 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 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 730
             </div>
268 731
 
269 732
             <div class="panel-block">
@@ -316,6 +779,26 @@
316 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 780
               <button class="btn btn-primary mt-2" @click="runExplorerRequest()">Run Request</button>
318 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 802
             </div>
320 803
           </section>
321 804
         </main>