lass="add-code nl-64 ol-64"> 64
+  local eid
65
+  eid="$(random_id)"
66
+  emit_syslog "<165>$(date '+%b %d %H:%M:%S') ${CISCO_DEVICE_HOST} %SEC_LOGIN-4-LOGIN_FAILED: Login failed [user: ${CISCO_ADMIN_USER}] [Source: ${CISCO_SRC_IP}] [localport: 22] [Reason: Login Authentication Failed] at 19:30:00 UTC Tue Feb 17 2026 soc_mvp_test=true vendor=cisco product=ios event_id=${eid} event_type=cisco_ios_login_fail severity=medium"
67
+}
68
+
69
+send_ios_config_change() {
70
+  local eid
71
+  eid="$(random_id)"
72
+  emit_syslog "<165>$(date '+%b %d %H:%M:%S') ${CISCO_DEVICE_HOST} %SYS-5-CONFIG_I: Configured from console by ${CISCO_ADMIN_USER} on vty0 ( ${CISCO_SRC_IP} ) soc_mvp_test=true vendor=cisco product=ios event_id=${eid} event_type=cisco_config_change severity=low"
73
+}
74
+
75
+send_once() {
76
+  case "${SCENARIO}" in
77
+    asa_acl_deny)
78
+      send_asa_acl_deny
79
+      ;;
80
+    asa_vpn_auth_fail)
81
+      send_asa_vpn_auth_fail
82
+      ;;
83
+    ios_login_fail)
84
+      send_ios_login_fail
85
+      ;;
86
+    ios_config_change)
87
+      send_ios_config_change
88
+      ;;
89
+    all)
90
+      send_asa_acl_deny
91
+      send_asa_vpn_auth_fail
92
+      send_ios_login_fail
93
+      send_ios_config_change
94
+      ;;
95
+    *)
96
+      echo "error: unknown scenario '${SCENARIO}'"
97
+      echo "valid: asa_acl_deny | asa_vpn_auth_fail | ios_login_fail | ios_config_change | all"
98
+      exit 1
99
+      ;;
100
+  esac
101
+}
102
+
103
+for ((i=1; i<=COUNT; i++)); do
104
+  send_once
105
+  if [[ "${i}" -lt "${COUNT}" ]]; then
106
+    sleep "${DELAY}"
107
+  fi
108
+done

+ 16 - 6
scripts/send-wazuh-test-events.sh

@@ -11,11 +11,6 @@ WAZUH_TEST_SRC_IP="${WAZUH_TEST_SRC_IP:-203.0.113.10}"
11 11
 WAZUH_TEST_DOMAIN="${WAZUH_TEST_DOMAIN:-malicious.example}"
12 12
 WAZUH_TEST_USER="${WAZUH_TEST_USER:-guest.user}"
13 13
 
14
-if ! command -v nc >/dev/null 2>&1; then
15
-  echo "error: nc (netcat) is required"
16
-  exit 1
17
-fi
18
-
19 14
 if ! [[ "${COUNT}" =~ ^[0-9]+$ ]] || [[ "${COUNT}" -lt 1 ]]; then
20 15
   echo "error: count must be a positive integer"
21 16
   exit 1
@@ -23,8 +18,23 @@ fi
23 18
 
24 19
 emit_syslog() {
25 20
   local msg="$1"
26
-  if ! printf "%s\n" "${msg}" | nc -u -w1 "${WAZUH_SYSLOG_HOST}" "${WAZUH_SYSLOG_PORT}"; then
21
+  local sent="false"
22
+
23
+  if command -v nc >/dev/null 2>&1; then
24
+    if printf "%s\n" "${msg}" | nc -u -w1 "${WAZUH_SYSLOG_HOST}" "${WAZUH_SYSLOG_PORT}"; then
25
+      sent="true"
26
+    fi
27
+  fi
28
+
29
+  if [[ "${sent}" != "true" ]]; then
30
+    if printf "%s\n" "${msg}" >"/dev/udp/${WAZUH_SYSLOG_HOST}/${WAZUH_SYSLOG_PORT}" 2>/dev/null; then
31
+      sent="true"
32
+    fi
33
+  fi
34
+
35
+  if [[ "${sent}" != "true" ]]; then
27 36
     echo "error: failed to send syslog event to ${WAZUH_SYSLOG_HOST}:${WAZUH_SYSLOG_PORT}/udp"
37
+    echo "hint: install netcat or run with bash UDP support (/dev/udp)"
28 38
     return 1
29 39
   fi
30 40
   echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] sent: ${msg}"

+ 18 - 0
scripts/trigger-shuffle-workflow.sh

@@ -0,0 +1,18 @@
1
+#!/usr/bin/env bash
2
+set -euo pipefail
3
+
4
+WORKFLOW_ID="${1:-}"
5
+IOC_TYPE="${2:-domain}"
6
+IOC_VALUE="${3:-malicious.example}"
7
+
8
+if [[ -z "${WORKFLOW_ID}" ]]; then
9
+  echo "usage: $0 <workflow_id> [ioc_type] [ioc_value]"
10
+  echo "example: $0 123e4567-e89b-12d3-a456-426614174000 domain malicious.example"
11
+  exit 1
12
+fi
13
+
14
+curl -sS -X POST "http://localhost:8088/action/trigger-shuffle" \
15
+  -H "Content-Type: application/json" \
16
+  -d "{\"workflow_id\":\"${WORKFLOW_ID}\",\"execution_argument\":{\"ioc_type\":\"${IOC_TYPE}\",\"ioc_value\":\"${IOC_VALUE}\",\"source_event\":{\"event_id\":\"cli-$(date +%s)\",\"asset\":{\"hostname\":\"fw01\"},\"network\":{\"src_ip\":\"198.51.100.25\"}}}}"
17
+
18
+echo

+ 221 - 0
shuffle-workflows/ioc-enrichment-workflow.json

@@ -0,0 +1,221 @@
1
+{
2
+  "workflow_as_code": false,
3
+  "actions": [
4
+    {
5
+      "app_name": "Shuffle Tools",
6
+      "app_version": "1.2.0",
7
+      "description": "Triggered by a webhook, expects an 'ioc' parameter.",
8
+      "app_id": "0671c57b-3af6-43f7-9501-b2f916c127c8",
9
+      "errors": [],
10
+      "id": "9723d04e-b735-41b9-ab7e-2e9e60cb0296",
11
+      "is_valid": true,
12
+      "isStartNode": true,
13
+      "sharing": true,
14
+      "label": "Webhook Trigger (IOC Input)",
15
+      "public": true,
16
+      "generated": false,
17
+      "large_image": "",
18
+      "environment": "Shuffle",
19
+      "name": "webhook",
20
+      "parameters": [
21
+        {
22
+          "name": "ioc",
23
+          "value": "",
24
+          "description": "Indicator of Compromise to enrich.",
25
+          "required": true,
26
+          "multiline": false,
27
+          "multiselect": false,
28
+          "options": null,
29
+          "action_field": "",
30
+          "variant": "",
31
+          "configuration": false,
32
+          "tags": null,
33
+          "schema": { "type": "" },
34
+          "skip_multicheck": false,
35
+          "value_replace": null,
36
+          "unique_toggled": false,
37
+          "error": "",
38
+          "hidden": false
39
+        }
40
+      ],
41
+      "execution_variable": { "description": "", "id": "", "name": "", "value": "" },
42
+      "position": { "x": 100, "y": 100 },
43
+      "authentication_id": "",
44
+      "category": "",
45
+      "reference_url": "",
46
+      "sub_action": false,
47
+      "run_magic_output": false,
48
+      "run_magic_input": false,
49
+      "execution_delay": 0,
50
+      "category_label": null,
51
+      "suggestion": false,
52
+      "parent_controlled": false,
53
+      "source_workflow": "",
54
+      "source_execution": ""
55
+    },
56
+    {
57
+      "app_name": "Shuffle Tools",
58
+      "app_version": "1.2.0",
59
+      "description": "Simulates enrichment by repeating the IOC.",
60
+      "app_id": "0671c57b-3af6-43f7-9501-b2f916c127c8",
61
+      "errors": [],
62
+      "id": "6fd41008-7bbe-4cbd-be28-5aa828100464",
63
+      "is_valid": true,
64
+      "isStartNode": false,
65
+      "sharing": true,
66
+      "label": "Simulated IOC Enrichment",
67
+      "public": true,
68
+      "generated": false,
69
+      "large_image": "",
70
+      "environment": "Shuffle",
71
+      "name": "repeat_back_to_me",
72
+      "parameters": [
73
+        {
74
+          "name": "call",
75
+          "value": "{{actions.9723d04e-b735-41b9-ab7e-2e9e60cb0296.ioc}} - Enriched!",
76
+          "description": "The IOC to be 'enriched'.",
77
+          "required": false,
78
+          "multiline": false,
79
+          "multiselect": false,
80
+          "options": null,
81
+          "action_field": "",
82
+          "variant": "",
83
+          "configuration": false,
84
+          "tags": null,
85
+          "schema": { "type": "" },
86
+          "skip_multicheck": false,
87
+          "value_replace": null,
88
+          "unique_toggled": false,
89
+          "error": "",
90
+          "hidden": false
91
+        }
92
+      ],
93
+      "execution_variable": { "description": "", "id": "", "name": "", "value": "" },
94
+      "position": { "x": 400, "y": 100 },
95
+      "authentication_id": "",
96
+      "category": "",
97
+      "reference_url": "",
98
+      "sub_action": false,
99
+      "run_magic_output": false,
100
+      "run_magic_input": false,
101
+      "execution_delay": 0,
102
+      "category_label": null,
103
+      "suggestion": false,
104
+      "parent_controlled": false,
105
+      "source_workflow": "",
106
+      "source_execution": ""
107
+    }
108
+  ],
109
+  "branches": [
110
+    {
111
+      "id": "branch-1",
112
+      "source": "9723d04e-b735-41b9-ab7e-2e9e60cb0296",
113
+      "destination": "6fd41008-7bbe-4cbd-be28-5aa828100464",
114
+      "success": true,
115
+      "label": ""
116
+    }
117
+  ],
118
+  "visual_branches": null,
119
+  "triggers": [],
120
+  "comments": [],
121
+  "configuration": {
122
+    "exit_on_error": false,
123
+    "start_from_top": false,
124
+    "skip_notifications": false
125
+  },
126
+  "created": 1771344402,
127
+  "edited": 1771344402,
128
+  "last_runtime": 0,
129
+  "due_date": 0,
130
+  "id": "d610e708-799d-423b-be21-c3683a6d89e2",
131
+  "is_valid": true,
132
+  "name": "Sample IOC Enrichment Workflow",
133
+  "description": "A sample workflow to demonstrate IOC enrichment via Shuffle API upload.",
134
+  "start": "9723d04e-b735-41b9-ab7e-2e9e60cb0296",
135
+  "owner": "1050bd5b-b1bb-4c22-acfb-94156cdc0567",
136
+  "sharing": "private",
137
+  "execution_org": {
138
+    "name": "default",
139
+    "id": "03264040-f718-4a61-b9ac-61c7cac3fe99",
140
+    "users": [],
141
+    "role": "admin",
142
+    "child_orgs": null,
143
+    "region_url": "",
144
+    "is_partner": false,
145
+    "image": "",
146
+    "creator_org": "",
147
+    "branding": {
148
+      "enable_chat": false,
149
+      "home_url": "",
150
+      "theme": "",
151
+      "documentation_link": "",
152
+      "global_user": false,
153
+      "support_email": "",
154
+      "logout_url": "",
155
+      "brand_color": "",
156
+      "brand_name": ""
157
+    }
158
+  },
159
+  "org_id": "03264040-f718-4a61-b9ac-61c7cac3fe99",
160
+  "workflow_variables": null,
161
+  "execution_environment": "",
162
+  "previously_saved": true,
163
+  "categories": {
164
+    "intel": {
165
+      "name": "intel",
166
+      "count": 0,
167
+      "id": "",
168
+      "description": "",
169
+      "large_image": ""
170
+    }
171
+  },
172
+  "example_argument": "",
173
+  "public": false,
174
+  "default_return_value": "",
175
+  "contact_info": { "name": "", "url": "" },
176
+  "published_id": "",
177
+  "revision_id": "",
178
+  "usecase_ids": null,
179
+  "input_questions": null,
180
+  "form_control": {
181
+    "input_markdown": "",
182
+    "output_yields": null,
183
+    "cleanup_actions": null,
184
+    "form_width": 0
185
+  },
186
+  "blogpost": "",
187
+  "video": "",
188
+  "status": "test",
189
+  "workflow_type": "",
190
+  "generated": false,
191
+  "hidden": false,
192
+  "background_processing": false,
193
+  "updated_by": "root",
194
+  "validated": false,
195
+  "validation": {
196
+    "valid": false,
197
+    "changed_at": 0,
198
+    "last_valid": 0,
199
+    "validation_ran": false,
200
+    "notifications_created": 0,
201
+    "environment": "",
202
+    "workflow_id": "",
203
+    "execution_id": "",
204
+    "node_id": "",
205
+    "total_problems": 0,
206
+    "errors": [],
207
+    "subflow_apps": []
208
+  },
209
+  "parentorg_workflow": "",
210
+  "childorg_workflow_ids": null,
211
+  "suborg_distribution": [],
212
+  "backup_config": {
213
+    "onprem_backup": false,
214
+    "upload_repo": "",
215
+    "upload_branch": "",
216
+    "upload_username": "",
217
+    "upload_token": "",
218
+    "tokens_encrypted": false
219
+  },
220
+  "auth_groups": null
221
+}

Разлика између датотеке није приказан због своје велике величине
+ 374 - 0
shuffle-workflows/mvp-ioc-upload-multinode-sample.json


Разлика између датотеке није приказан због своје велике величине
+ 246 - 0
shuffle-workflows/mvp-ioc-upload-sample.json


+ 88 - 0
shuffle-workflows/sample-ioc-playbook.md

@@ -0,0 +1,88 @@
1
+# Shuffle Sample Workflow: IOC Enrichment and Incident Routing
2
+
3
+Use this workflow in Shuffle UI for the SOC flow:
4
+Wazuh -> Shuffle -> soc-integrator -> IRIS/PagerDuty
5
+
6
+## Workflow Name
7
+
8
+`SOC IOC Enrichment - Sample`
9
+
10
+## Trigger
11
+
12
+- App: `Shuffle Tools`
13
+- Function: `Webhook` (or workflow execute API)
14
+- Expected input payload example:
15
+
16
+```json
17
+{
18
+  "ioc_type": "domain",
19
+  "ioc_value": "malicious.example",
20
+  "source_event": {
21
+    "event_id": "evt-123",
22
+    "asset": {"hostname": "fw01"},
23
+    "network": {"src_ip": "198.51.100.25"}
24
+  }
25
+}
26
+```
27
+
28
+## Steps
29
+
30
+1. Parse Input
31
+- Read `ioc_type` and `ioc_value`.
32
+- Normalize lowercase for domains.
33
+
34
+2. Enrichment (HTTP or app connectors)
35
+- Query one or more sources:
36
+  - VirusTotal
37
+  - AbuseIPDB (for IP)
38
+  - OTX / MISP (optional)
39
+- Extract fields:
40
+  - `confidence` (0.0-1.0)
41
+  - `reputation` (`malicious|suspicious|unknown|benign`)
42
+  - `evidence` (short text)
43
+
44
+3. Decision
45
+- If `reputation in [malicious, suspicious]` OR `confidence >= 0.7` => `matched=true`
46
+- Else `matched=false`
47
+
48
+4. Callback to soc-integrator
49
+- HTTP POST to:
50
+  - `http://soc-integrator:8080/mvp/ioc/evaluate`
51
+- Headers:
52
+  - `Content-Type: application/json`
53
+  - `X-Internal-API-Key: dev-internal-key` (replace in production)
54
+- Body:
55
+
56
+```json
57
+{
58
+  "ioc_type": "${ioc_type}",
59
+  "ioc_value": "${ioc_value}",
60
+  "source_event": {
61
+    "event_id": "${source_event.event_id}",
62
+    "asset": "${source_event.asset}",
63
+    "network": "${source_event.network}",
64
+    "shuffle": {
65
+      "matched": "${matched}",
66
+      "confidence": "${confidence}",
67
+      "reputation": "${reputation}",
68
+      "evidence": "${evidence}"
69
+    }
70
+  }
71
+}
72
+```
73
+
74
+5. Optional Escalation Branch
75
+- If `matched=true` and `confidence >= 0.9`, also call:
76
+  - `http://soc-integrator:8080/action/create-incident`
77
+
78
+## Expected Result
79
+
80
+- soc-integrator receives IOC evaluation.
81
+- MVP pipeline creates/updates incident.
82
+- IRIS case and PagerDuty stub actions follow your existing policy.
83
+
84
+## Notes
85
+
86
+- Keep final decision in soc-integrator to centralize policy.
87
+- Use Shuffle for enrichment and automation fan-out.
88
+- Rotate API keys before production use.

+ 9 - 1
soc-integrator/.env

@@ -11,9 +11,17 @@ SOC_INTEGRATOR_DB_PASSWORD=soc_integrator_password
11 11
 WAZUH_BASE_URL=https://wazuh.manager:55000
12 12
 WAZUH_USERNAME=wazuh-wui
13 13
 WAZUH_PASSWORD=MyS3cr37P450r.*-
14
+WAZUH_INDEXER_URL=https://wazuh.indexer:9200
15
+WAZUH_INDEXER_USERNAME=admin
16
+WAZUH_INDEXER_PASSWORD=SecretPassword
17
+WAZUH_AUTO_SYNC_ENABLED=true
18
+WAZUH_AUTO_SYNC_INTERVAL_SECONDS=5
19
+WAZUH_AUTO_SYNC_QUERY=*
20
+WAZUH_AUTO_SYNC_LIMIT=50
21
+WAZUH_AUTO_SYNC_MINUTES=120
14 22
 
15 23
 SHUFFLE_BASE_URL=http://shuffle-backend:5001
16
-SHUFFLE_API_KEY=95286d1a-1d02-4dd6-8de1-9832e326871f
24
+SHUFFLE_API_KEY=e9bf8031-038a-4ea9-9639-13eb08d535ab
17 25
 SHUFFLE_USERNAME=
18 26
 SHUFFLE_PASSWORD=
19 27
 

+ 8 - 0
soc-integrator/.env.example

@@ -11,6 +11,14 @@ SOC_INTEGRATOR_DB_PASSWORD=soc_integrator_password
11 11
 WAZUH_BASE_URL=https://wazuh.manager:55000
12 12
 WAZUH_USERNAME=wazuh-wui
13 13
 WAZUH_PASSWORD=MyS3cr37P450r.*-
14
+WAZUH_INDEXER_URL=https://wazuh.indexer:9200
15
+WAZUH_INDEXER_USERNAME=admin
16
+WAZUH_INDEXER_PASSWORD=SecretPassword
17
+WAZUH_AUTO_SYNC_ENABLED=true
18
+WAZUH_AUTO_SYNC_INTERVAL_SECONDS=5
19
+WAZUH_AUTO_SYNC_QUERY=*
20
+WAZUH_AUTO_SYNC_LIMIT=50
21
+WAZUH_AUTO_SYNC_MINUTES=120
14 22
 
15 23
 SHUFFLE_BASE_URL=http://shuffle-backend:5001
16 24
 SHUFFLE_API_KEY=

+ 50 - 1
soc-integrator/app/adapters/wazuh.py

@@ -4,10 +4,21 @@ import httpx
4 4
 
5 5
 
6 6
 class WazuhAdapter:
7
-    def __init__(self, base_url: str, username: str, password: str) -> None:
7
+    def __init__(
8
+        self,
9
+        base_url: str,
10
+        username: str,
11
+        password: str,
12
+        indexer_url: str | None = None,
13
+        indexer_username: str | None = None,
14
+        indexer_password: str | None = None,
15
+    ) -> None:
8 16
         self.base_url = base_url.rstrip("/")
9 17
         self.username = username
10 18
         self.password = password
19
+        self.indexer_url = (indexer_url or "").rstrip("/")
20
+        self.indexer_username = indexer_username
21
+        self.indexer_password = indexer_password
11 22
 
12 23
     async def _authenticate(self, client: httpx.AsyncClient) -> str:
13 24
         auth_url = f"{self.base_url}/security/user/authenticate?raw=true"
@@ -80,3 +91,41 @@ class WazuhAdapter:
80 91
         async with httpx.AsyncClient(verify=False, timeout=20.0) as client:
81 92
             token = await self._authenticate(client)
82 93
             return await self._get_with_bearer(client, token, "/manager/logs", params=params)
94
+
95
+    async def search_alerts(
96
+        self,
97
+        query: str,
98
+        limit: int = 50,
99
+        minutes: int = 120,
100
+    ) -> dict[str, Any]:
101
+        if not self.indexer_url:
102
+            raise RuntimeError("Wazuh indexer URL is not configured.")
103
+
104
+        body: dict[str, Any] = {
105
+            "size": limit,
106
+            "sort": [{"@timestamp": {"order": "desc"}}],
107
+            "query": {
108
+                "bool": {
109
+                    "must": [{"query_string": {"query": query}}],
110
+                    "filter": [
111
+                        {
112
+                            "range": {
113
+                                "@timestamp": {
114
+                                    "gte": f"now-{minutes}m",
115
+                                    "lte": "now",
116
+                                }
117
+                            }
118
+                        }
119
+                    ],
120
+                }
121
+            },
122
+        }
123
+
124
+        async with httpx.AsyncClient(
125
+            verify=False,
126
+            timeout=20.0,
127
+            auth=(self.indexer_username, self.indexer_password),
128
+        ) as client:
129
+            response = await client.post(f"{self.indexer_url}/wazuh-alerts-*/_search", json=body)
130
+            response.raise_for_status()
131
+            return response.json()

+ 8 - 0
soc-integrator/app/config.py

@@ -18,6 +18,14 @@ class Settings(BaseSettings):
18 18
     wazuh_base_url: str = "https://wazuh.manager:55000"
19 19
     wazuh_username: str = "wazuh-wui"
20 20
     wazuh_password: str = "MyS3cr37P450r.*-"
21
+    wazuh_indexer_url: str = "https://wazuh.indexer:9200"
22
+    wazuh_indexer_username: str = "admin"
23
+    wazuh_indexer_password: str = "SecretPassword"
24
+    wazuh_auto_sync_enabled: bool = True
25
+    wazuh_auto_sync_interval_seconds: int = 60
26
+    wazuh_auto_sync_query: str = "*"
27
+    wazuh_auto_sync_limit: int = 50
28
+    wazuh_auto_sync_minutes: int = 120
21 29
 
22 30
     shuffle_base_url: str = "http://shuffle-backend:5001"
23 31
     shuffle_api_key: str = ""

+ 3 - 0
soc-integrator/app/db.py

@@ -81,5 +81,8 @@ def init_schema() -> None:
81 81
             "CREATE INDEX IF NOT EXISTS idx_incident_events_incident_key_created_at ON incident_events(incident_key, created_at DESC);"
82 82
         )
83 83
         cur.execute(
84
+            "CREATE INDEX IF NOT EXISTS idx_incident_events_source_event_id ON incident_events(source, event_id);"
85
+        )
86
+        cur.execute(
84 87
             "CREATE INDEX IF NOT EXISTS idx_escalation_audit_incident_key_attempted_at ON escalation_audit(incident_key, attempted_at DESC);"
85 88
         )

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

@@ -1,4 +1,8 @@
1
-from fastapi import FastAPI, HTTPException
1
+import asyncio
2
+import logging
3
+from datetime import datetime, timezone
4
+
5
+from fastapi import Depends, FastAPI, HTTPException
2 6
 
3 7
 from app.adapters.iris import IrisAdapter
4 8
 from app.adapters.pagerduty import PagerDutyAdapter
@@ -20,11 +24,15 @@ from app.security import require_internal_api_key
20 24
 from app.services.mvp_service import MvpService
21 25
 
22 26
 app = FastAPI(title=settings.app_name, version="0.1.0")
27
+logger = logging.getLogger(__name__)
23 28
 
24 29
 wazuh_adapter = WazuhAdapter(
25 30
     base_url=settings.wazuh_base_url,
26 31
     username=settings.wazuh_username,
27 32
     password=settings.wazuh_password,
33
+    indexer_url=settings.wazuh_indexer_url,
34
+    indexer_username=settings.wazuh_indexer_username,
35
+    indexer_password=settings.wazuh_indexer_password,
28 36
 )
29 37
 shuffle_adapter = ShuffleAdapter(
30 38
     base_url=settings.shuffle_base_url,
@@ -50,10 +58,73 @@ mvp_service = MvpService(
50 58
 app.include_router(build_mvp_router(mvp_service, require_internal_api_key))
51 59
 
52 60
 
61
+async def _wazuh_auto_sync_loop() -> None:
62
+    interval = max(5, int(settings.wazuh_auto_sync_interval_seconds))
63
+    while True:
64
+        started_at = datetime.now(timezone.utc).isoformat()
65
+        try:
66
+            app.state.wazuh_auto_sync_state["running"] = True
67
+            app.state.wazuh_auto_sync_state["last_started_at"] = started_at
68
+            result = await mvp_service.sync_wazuh_alerts(
69
+                query=settings.wazuh_auto_sync_query,
70
+                limit=settings.wazuh_auto_sync_limit,
71
+                minutes=settings.wazuh_auto_sync_minutes,
72
+            )
73
+            app.state.wazuh_auto_sync_state["last_status"] = "ok"
74
+            app.state.wazuh_auto_sync_state["last_result"] = result
75
+            app.state.wazuh_auto_sync_state["last_finished_at"] = datetime.now(timezone.utc).isoformat()
76
+            logger.info(
77
+                "wazuh auto-sync processed=%s ingested=%s skipped=%s failed=%s ioc_evaluated=%s ioc_matched=%s ioc_rejected=%s",
78
+                result.get("processed", 0),
79
+                result.get("ingested", 0),
80
+                result.get("skipped_existing", 0),
81
+                result.get("failed", 0),
82
+                result.get("ioc_evaluated", 0),
83
+                result.get("ioc_matched", 0),
84
+                result.get("ioc_rejected", 0),
85
+            )
86
+        except Exception as exc:
87
+            app.state.wazuh_auto_sync_state["last_status"] = "error"
88
+            app.state.wazuh_auto_sync_state["last_error"] = str(exc)
89
+            app.state.wazuh_auto_sync_state["last_finished_at"] = datetime.now(timezone.utc).isoformat()
90
+            logger.exception("wazuh auto-sync failed: %s", exc)
91
+        finally:
92
+            app.state.wazuh_auto_sync_state["running"] = False
93
+        await asyncio.sleep(interval)
94
+
95
+
53 96
 @app.on_event("startup")
54 97
 async def startup() -> None:
55 98
     init_schema()
56 99
     repo.ensure_policy()
100
+    app.state.wazuh_auto_sync_state = {
101
+        "running": False,
102
+        "last_status": None,
103
+        "last_started_at": None,
104
+        "last_finished_at": None,
105
+        "last_error": None,
106
+        "last_result": None,
107
+    }
108
+    if settings.wazuh_auto_sync_enabled:
109
+        app.state.wazuh_auto_sync_task = asyncio.create_task(_wazuh_auto_sync_loop())
110
+        logger.info(
111
+            "wazuh auto-sync enabled interval=%ss limit=%s minutes=%s query=%s",
112
+            settings.wazuh_auto_sync_interval_seconds,
113
+            settings.wazuh_auto_sync_limit,
114
+            settings.wazuh_auto_sync_minutes,
115
+            settings.wazuh_auto_sync_query,
116
+        )
117
+
118
+
119
+@app.on_event("shutdown")
120
+async def shutdown() -> None:
121
+    task = getattr(app.state, "wazuh_auto_sync_task", None)
122
+    if task:
123
+        task.cancel()
124
+        try:
125
+            await task
126
+        except asyncio.CancelledError:
127
+            pass
57 128
 
58 129
 
59 130
 @app.get("/health", response_model=ApiResponse)
@@ -297,3 +368,35 @@ async def wazuh_manager_logs(
297 368
     except Exception as exc:
298 369
         raise HTTPException(status_code=502, detail=f"Wazuh call failed: {exc}") from exc
299 370
     return ApiResponse(data={"wazuh": result})
371
+
372
+
373
+@app.post("/wazuh/sync-to-mvp", response_model=ApiResponse, dependencies=[Depends(require_internal_api_key)])
374
+async def wazuh_sync_to_mvp(
375
+    limit: int = 50,
376
+    minutes: int = 120,
377
+    q: str = "soc_mvp_test=true OR event_type:*",
378
+) -> ApiResponse:
379
+    try:
380
+        result = await mvp_service.sync_wazuh_alerts(query=q, limit=limit, minutes=minutes)
381
+    except Exception as exc:
382
+        raise HTTPException(status_code=502, detail=f"Wazuh sync failed: {exc}") from exc
383
+    return ApiResponse(data={"sync": result})
384
+
385
+
386
+@app.get("/wazuh/auto-sync/status", response_model=ApiResponse)
387
+async def wazuh_auto_sync_status() -> ApiResponse:
388
+    state = getattr(app.state, "wazuh_auto_sync_state", {})
389
+    task = getattr(app.state, "wazuh_auto_sync_task", None)
390
+    return ApiResponse(
391
+        data={
392
+            "enabled": settings.wazuh_auto_sync_enabled,
393
+            "task_running": bool(task and not task.done()),
394
+            "settings": {
395
+                "interval_seconds": settings.wazuh_auto_sync_interval_seconds,
396
+                "limit": settings.wazuh_auto_sync_limit,
397
+                "minutes": settings.wazuh_auto_sync_minutes,
398
+                "query": settings.wazuh_auto_sync_query,
399
+            },
400
+            "state": state,
401
+        }
402
+    )

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

@@ -37,6 +37,14 @@ DEFAULT_POLICY: dict[str, Any] = {
37 37
 
38 38
 
39 39
 class MvpRepository:
40
+    def has_event(self, source: str, event_id: str) -> bool:
41
+        with get_conn() as conn, conn.cursor() as cur:
42
+            cur.execute(
43
+                "SELECT 1 FROM incident_events WHERE source = %s AND event_id = %s LIMIT 1",
44
+                (source, event_id),
45
+            )
46
+            return cur.fetchone() is not None
47
+
40 48
     def ensure_policy(self) -> None:
41 49
         with get_conn() as conn, conn.cursor() as cur:
42 50
             cur.execute("SELECT id FROM policy_config WHERE id = 1")

+ 254 - 8
soc-integrator/app/services/mvp_service.py

@@ -1,6 +1,8 @@
1 1
 from __future__ import annotations
2 2
 
3 3
 import hashlib
4
+import logging
5
+import re
4 6
 import time
5 7
 from datetime import datetime, timezone
6 8
 from typing import Any
@@ -14,6 +16,8 @@ from app.adapters.wazuh import WazuhAdapter
14 16
 from app.config import settings
15 17
 from app.repositories.mvp_repo import MvpRepository
16 18
 
19
+logger = logging.getLogger(__name__)
20
+
17 21
 
18 22
 class MvpService:
19 23
     def __init__(
@@ -112,6 +116,146 @@ class MvpService:
112 116
             return str(data.get("case_id"))
113 117
         return None
114 118
 
119
+    def _parse_kv_pairs(self, text: str) -> dict[str, str]:
120
+        pattern = r"([A-Za-z0-9_]+)=('(?:[^']*)'|\"(?:[^\"]*)\"|[^\s]+)"
121
+        out: dict[str, str] = {}
122
+        for key, raw in re.findall(pattern, text):
123
+            value = raw.strip().strip("'").strip('"')
124
+            out[key] = value
125
+        return out
126
+
127
+    def _severity_from_rule_level(self, rule_level: Any) -> str:
128
+        try:
129
+            level = int(rule_level)
130
+        except (TypeError, ValueError):
131
+            return "medium"
132
+        if level >= 12:
133
+            return "critical"
134
+        if level >= 8:
135
+            return "high"
136
+        if level >= 4:
137
+            return "medium"
138
+        return "low"
139
+
140
+    def _event_type_from_text(self, text: str, parsed: dict[str, str]) -> str:
141
+        explicit = parsed.get("event_type")
142
+        if explicit:
143
+            return explicit
144
+        lowered = text.lower()
145
+        if "vpn" in lowered and ("geo" in lowered or "country" in lowered):
146
+            return "vpn_geo_anomaly"
147
+        if "domain" in lowered or "dns" in lowered:
148
+            return "ioc_dns"
149
+        if "c2" in lowered or "ips" in lowered or "ip " in lowered:
150
+            return "ioc_ips"
151
+        if "auth" in lowered and "fail" in lowered:
152
+            return "auth_anomaly"
153
+        return "generic"
154
+
155
+    def _normalize_wazuh_hit(self, hit: dict[str, Any]) -> dict[str, Any]:
156
+        src = hit.get("_source", {})
157
+        full_log = str(src.get("full_log", ""))
158
+        parsed = self._parse_kv_pairs(full_log)
159
+        event_id = str(parsed.get("event_id") or src.get("id") or hit.get("_id") or f"wazuh-{int(time.time())}")
160
+        timestamp = (
161
+            src.get("@timestamp")
162
+            or src.get("timestamp")
163
+            or datetime.now(timezone.utc).isoformat()
164
+        )
165
+        rule = src.get("rule", {}) if isinstance(src.get("rule"), dict) else {}
166
+        rule_desc = str(rule.get("description") or "")
167
+        event_type = self._event_type_from_text(full_log, parsed)
168
+        severity = str(parsed.get("severity", "")).lower() or self._severity_from_rule_level(rule.get("level"))
169
+
170
+        src_ip = parsed.get("src_ip")
171
+        dst_ip = parsed.get("dst_ip")
172
+        domain = parsed.get("query") or parsed.get("domain")
173
+        country = parsed.get("country")
174
+        user = parsed.get("user") or (src.get("agent", {}) or {}).get("name")
175
+
176
+        title = rule_desc or f"Wazuh alert {rule.get('id', '')}".strip()
177
+        description = full_log or rule_desc or "Wazuh alert"
178
+
179
+        return {
180
+            "source": "wazuh",
181
+            "event_type": event_type,
182
+            "event_id": event_id,
183
+            "timestamp": timestamp,
184
+            "severity": severity if severity in {"low", "medium", "high", "critical"} else "medium",
185
+            "title": title,
186
+            "description": description,
187
+            "asset": {
188
+                "user": user,
189
+                "hostname": (src.get("agent", {}) or {}).get("name"),
190
+                "agent_id": (src.get("agent", {}) or {}).get("id"),
191
+            },
192
+            "network": {
193
+                "src_ip": src_ip,
194
+                "dst_ip": dst_ip,
195
+                "domain": domain,
196
+                "country": country,
197
+            },
198
+            "tags": ["wazuh", event_type, f"rule_{rule.get('id', 'unknown')}"],
199
+            "risk_context": {
200
+                "outside_thailand": bool(country and str(country).upper() != "TH"),
201
+            },
202
+            "raw": src,
203
+            "payload": {},
204
+        }
205
+
206
+    def _to_float(self, value: Any, default: float = 0.0) -> float:
207
+        try:
208
+            return float(value)
209
+        except (TypeError, ValueError):
210
+            return default
211
+
212
+    def _severity_from_confidence(self, confidence: float) -> str:
213
+        if confidence >= 0.9:
214
+            return "high"
215
+        if confidence >= 0.7:
216
+            return "medium"
217
+        return "low"
218
+
219
+    def _extract_shuffle_verdict(self, shuffle_result: dict[str, Any] | None) -> dict[str, Any]:
220
+        if not isinstance(shuffle_result, dict):
221
+            return {
222
+                "matched": False,
223
+                "confidence": 0.0,
224
+                "severity": "low",
225
+                "evidence": "",
226
+                "iocs": [],
227
+                "reason": "no_shuffle_result",
228
+            }
229
+
230
+        flat = dict(shuffle_result)
231
+        nested = shuffle_result.get("result")
232
+        if isinstance(nested, dict):
233
+            merged = dict(nested)
234
+            merged.update(flat)
235
+            flat = merged
236
+
237
+        confidence = self._to_float(flat.get("confidence"), 0.0)
238
+        matched_raw = flat.get("matched")
239
+        if isinstance(matched_raw, bool):
240
+            matched = matched_raw
241
+            reason = "shuffle_explicit"
242
+        else:
243
+            matched = confidence >= 0.7
244
+            reason = "confidence_threshold_fallback"
245
+
246
+        severity_raw = str(flat.get("severity", "")).lower()
247
+        severity = severity_raw if severity_raw in {"low", "medium", "high", "critical"} else self._severity_from_confidence(confidence)
248
+
249
+        return {
250
+            "matched": matched,
251
+            "confidence": confidence,
252
+            "severity": severity,
253
+            "evidence": str(flat.get("evidence", "")),
254
+            "iocs": flat.get("iocs", []),
255
+            "reason": reason,
256
+            "raw": shuffle_result,
257
+        }
258
+
115 259
     async def ingest_incident(self, event: dict[str, Any]) -> dict[str, Any]:
116 260
         policy = self.repo.get_policy()
117 261
         incident_key = self._incident_key(event)
@@ -233,27 +377,48 @@ class MvpService:
233 377
         policy = self.repo.get_policy()
234 378
         workflow_id = str(policy.get("shuffle", {}).get("ioc_workflow_id", "")).strip()
235 379
 
236
-        matched = True
237
-        confidence = 0.7
238 380
         shuffle_result: dict[str, Any] | None = None
239 381
 
240 382
         if workflow_id:
241 383
             shuffle_result = await self.shuffle_adapter.trigger_workflow(workflow_id, payload)
384
+        verdict = self._extract_shuffle_verdict(shuffle_result)
385
+        matched = bool(verdict["matched"])
386
+        confidence = self._to_float(verdict["confidence"], 0.0)
387
+
388
+        logger.info(
389
+            "ioc evaluation workflow_id=%s matched=%s confidence=%.2f",
390
+            workflow_id or "<none>",
391
+            matched,
392
+            confidence,
393
+        )
242 394
 
243 395
         if matched:
396
+            src_event = payload.get("source_event", {})
397
+            event_id = src_event.get("event_id") or f"ioc-{int(time.time())}"
398
+            if not isinstance(event_id, str):
399
+                event_id = str(event_id)
400
+
401
+            description = f"IOC evaluation result confidence={confidence:.2f}"
402
+            evidence = str(verdict.get("evidence", "")).strip()
403
+            if evidence:
404
+                description = f"{description} evidence={evidence[:180]}"
405
+
244 406
             event = {
245 407
                 "source": "shuffle",
246 408
                 "event_type": "ioc_dns" if payload.get("ioc_type") == "domain" else "ioc_ips",
247
-                "event_id": payload.get("source_event", {}).get("event_id") or f"ioc-{int(time.time())}",
409
+                "event_id": event_id,
248 410
                 "timestamp": datetime.now(timezone.utc).isoformat(),
249
-                "severity": "medium",
411
+                "severity": verdict["severity"],
250 412
                 "title": f"IOC match: {payload.get('ioc_value', 'unknown')}",
251
-                "description": "IOC evaluation result",
252
-                "asset": payload.get("source_event", {}).get("asset", {}),
253
-                "network": payload.get("source_event", {}).get("network", {}),
413
+                "description": description,
414
+                "asset": src_event.get("asset", {}),
415
+                "network": src_event.get("network", {}),
254 416
                 "tags": ["ioc", str(payload.get("ioc_type", "unknown"))],
255 417
                 "risk_context": {},
256
-                "raw": payload,
418
+                "raw": {
419
+                    "payload": payload,
420
+                    "shuffle": verdict.get("raw"),
421
+                },
257 422
                 "payload": {},
258 423
             }
259 424
             ingest_result = await self.ingest_incident(event)
@@ -263,6 +428,10 @@ class MvpService:
263 428
         return {
264 429
             "matched": matched,
265 430
             "confidence": confidence,
431
+            "severity": verdict["severity"],
432
+            "evidence": verdict["evidence"],
433
+            "iocs": verdict["iocs"],
434
+            "decision_source": verdict["reason"],
266 435
             "shuffle": shuffle_result,
267 436
             "result": ingest_result,
268 437
         }
@@ -310,6 +479,83 @@ class MvpService:
310 479
             "escalation_stub_sent": ingest_result.get("escalation_stub_sent", False),
311 480
         }
312 481
 
482
+    async def sync_wazuh_alerts(
483
+        self,
484
+        query: str = "soc_mvp_test=true OR event_type:*",
485
+        limit: int = 50,
486
+        minutes: int = 120,
487
+    ) -> dict[str, Any]:
488
+        raw = await self.wazuh_adapter.search_alerts(query=query, limit=limit, minutes=minutes)
489
+        hits = (raw.get("hits", {}) or {}).get("hits", []) if isinstance(raw, dict) else []
490
+
491
+        processed = 0
492
+        ingested = 0
493
+        skipped_existing = 0
494
+        failed = 0
495
+        errors: list[str] = []
496
+        created_incidents: list[str] = []
497
+        ioc_evaluated = 0
498
+        ioc_matched = 0
499
+        ioc_rejected = 0
500
+
501
+        for hit in hits:
502
+            processed += 1
503
+            event = self._normalize_wazuh_hit(hit)
504
+            event_id = str(event.get("event_id", "")).strip()
505
+            if event_id and self.repo.has_event("wazuh", event_id):
506
+                skipped_existing += 1
507
+                continue
508
+            try:
509
+                if event.get("event_type") in {"ioc_dns", "ioc_ips"}:
510
+                    ioc_evaluated += 1
511
+                    payload = {
512
+                        "ioc_type": "domain" if event.get("event_type") == "ioc_dns" else "ip",
513
+                        "ioc_value": (event.get("network", {}) or {}).get("domain")
514
+                        or (event.get("network", {}) or {}).get("dst_ip")
515
+                        or (event.get("network", {}) or {}).get("src_ip")
516
+                        or "unknown",
517
+                        "source_event": {
518
+                            "event_id": event.get("event_id"),
519
+                            "asset": event.get("asset", {}),
520
+                            "network": event.get("network", {}),
521
+                            "raw": event.get("raw", {}),
522
+                        },
523
+                    }
524
+                    ioc_result = await self.evaluate_ioc(payload)
525
+                    if ioc_result.get("matched"):
526
+                        ioc_matched += 1
527
+                        ingested += 1
528
+                        incident_key = str((ioc_result.get("result", {}) or {}).get("incident_key", ""))
529
+                        if incident_key:
530
+                            created_incidents.append(incident_key)
531
+                    else:
532
+                        ioc_rejected += 1
533
+                else:
534
+                    result = await self.ingest_incident(event)
535
+                    ingested += 1
536
+                    incident_key = str(result.get("incident_key", ""))
537
+                    if incident_key:
538
+                        created_incidents.append(incident_key)
539
+            except Exception as exc:
540
+                failed += 1
541
+                errors.append(f"{event_id or 'unknown_event'}: {exc}")
542
+
543
+        return {
544
+            "query": query,
545
+            "window_minutes": minutes,
546
+            "limit": limit,
547
+            "processed": processed,
548
+            "ingested": ingested,
549
+            "skipped_existing": skipped_existing,
550
+            "failed": failed,
551
+            "ioc_evaluated": ioc_evaluated,
552
+            "ioc_matched": ioc_matched,
553
+            "ioc_rejected": ioc_rejected,
554
+            "incident_keys": created_incidents,
555
+            "errors": errors[:10],
556
+            "total_hits": (raw.get("hits", {}).get("total", {}) if isinstance(raw, dict) else {}),
557
+        }
558
+
313 559
     async def dependency_health(self) -> dict[str, Any]:
314 560
         out: dict[str, Any] = {}
315 561
 

+ 7 - 0
wazuh-docker/single-node/config/wazuh_cluster/local_rules.xml

@@ -0,0 +1,7 @@
1
+<group name="soc_mvp_test,">
2
+  <rule id="100200" level="10">
3
+    <match>soc_mvp_test=true</match>
4
+    <description>SOC MVP synthetic test event detected</description>
5
+    <group>soc_mvp_test,syslog,</group>
6
+  </rule>
7
+</group>

+ 6 - 0
wazuh-docker/single-node/config/wazuh_cluster/wazuh_manager.conf

@@ -30,6 +30,12 @@
30 30
     <protocol>tcp</protocol>
31 31
     <queue_size>131072</queue_size>
32 32
   </remote>
33
+  <remote>
34
+    <connection>syslog</connection>
35
+    <port>514</port>
36
+    <protocol>udp</protocol>
37
+    <allowed-ips>0.0.0.0/0</allowed-ips>
38
+  </remote>
33 39
 
34 40
   <!-- Policy monitoring -->
35 41
   <rootcheck>

+ 2 - 1
wazuh-docker/single-node/docker-compose.yml

@@ -41,7 +41,8 @@ services:
41 41
       - ./config/wazuh_indexer_ssl_certs/root-ca-manager.pem:/etc/ssl/root-ca.pem
42 42
       - ./config/wazuh_indexer_ssl_certs/wazuh.manager.pem:/etc/ssl/filebeat.pem
43 43
       - ./config/wazuh_indexer_ssl_certs/wazuh.manager-key.pem:/etc/ssl/filebeat.key
44
-      - ./config/wazuh_cluster/wazuh_manager.conf:/wazuh-config-mount/etc/ossec.conf
44
+      - ./config/wazuh_cluster/wazuh_manager.conf:/var/ossec/etc/ossec.conf
45
+      - ./config/wazuh_cluster/local_rules.xml:/var/ossec/etc/rules/local_rules.xml
45 46
 
46 47
   wazuh.indexer:
47 48
     image: wazuh/wazuh-indexer:4.14.3

tum/soc - Gogs: Simplico Git Service

暫無描述

tum 9de2549954 first commit 1 月之前
..
README.md 9de2549954 first commit 1 月之前

README.md

CHANGES

In November 2024, we moved this to its own repistory: https://github.com/shuffle/app_sdk