iflow-mcp-m507_ai-soc-agent 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/METADATA +410 -0
- iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/RECORD +85 -0
- iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/WHEEL +5 -0
- iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/entry_points.txt +2 -0
- iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/licenses/LICENSE +21 -0
- iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/top_level.txt +1 -0
- src/__init__.py +8 -0
- src/ai_controller/README.md +139 -0
- src/ai_controller/__init__.py +12 -0
- src/ai_controller/agent_executor.py +596 -0
- src/ai_controller/cli/__init__.py +2 -0
- src/ai_controller/cli/main.py +243 -0
- src/ai_controller/session_manager.py +409 -0
- src/ai_controller/web/__init__.py +2 -0
- src/ai_controller/web/server.py +1181 -0
- src/ai_controller/web/static/css/README.md +102 -0
- src/api/__init__.py +13 -0
- src/api/case_management.py +271 -0
- src/api/edr.py +187 -0
- src/api/kb.py +136 -0
- src/api/siem.py +308 -0
- src/core/__init__.py +10 -0
- src/core/config.py +242 -0
- src/core/config_storage.py +684 -0
- src/core/dto.py +50 -0
- src/core/errors.py +36 -0
- src/core/logging.py +128 -0
- src/integrations/__init__.py +8 -0
- src/integrations/case_management/__init__.py +5 -0
- src/integrations/case_management/iris/__init__.py +11 -0
- src/integrations/case_management/iris/iris_client.py +885 -0
- src/integrations/case_management/iris/iris_http.py +274 -0
- src/integrations/case_management/iris/iris_mapper.py +263 -0
- src/integrations/case_management/iris/iris_models.py +128 -0
- src/integrations/case_management/thehive/__init__.py +8 -0
- src/integrations/case_management/thehive/thehive_client.py +193 -0
- src/integrations/case_management/thehive/thehive_http.py +147 -0
- src/integrations/case_management/thehive/thehive_mapper.py +190 -0
- src/integrations/case_management/thehive/thehive_models.py +125 -0
- src/integrations/cti/__init__.py +6 -0
- src/integrations/cti/local_tip/__init__.py +10 -0
- src/integrations/cti/local_tip/local_tip_client.py +90 -0
- src/integrations/cti/local_tip/local_tip_http.py +110 -0
- src/integrations/cti/opencti/__init__.py +10 -0
- src/integrations/cti/opencti/opencti_client.py +101 -0
- src/integrations/cti/opencti/opencti_http.py +418 -0
- src/integrations/edr/__init__.py +6 -0
- src/integrations/edr/elastic_defend/__init__.py +6 -0
- src/integrations/edr/elastic_defend/elastic_defend_client.py +351 -0
- src/integrations/edr/elastic_defend/elastic_defend_http.py +162 -0
- src/integrations/eng/__init__.py +10 -0
- src/integrations/eng/clickup/__init__.py +8 -0
- src/integrations/eng/clickup/clickup_client.py +513 -0
- src/integrations/eng/clickup/clickup_http.py +156 -0
- src/integrations/eng/github/__init__.py +8 -0
- src/integrations/eng/github/github_client.py +169 -0
- src/integrations/eng/github/github_http.py +158 -0
- src/integrations/eng/trello/__init__.py +8 -0
- src/integrations/eng/trello/trello_client.py +207 -0
- src/integrations/eng/trello/trello_http.py +162 -0
- src/integrations/kb/__init__.py +12 -0
- src/integrations/kb/fs_kb_client.py +313 -0
- src/integrations/siem/__init__.py +6 -0
- src/integrations/siem/elastic/__init__.py +6 -0
- src/integrations/siem/elastic/elastic_client.py +3319 -0
- src/integrations/siem/elastic/elastic_http.py +165 -0
- src/mcp/README.md +183 -0
- src/mcp/TOOLS.md +2827 -0
- src/mcp/__init__.py +13 -0
- src/mcp/__main__.py +18 -0
- src/mcp/agent_profiles.py +408 -0
- src/mcp/flow_agent_profiles.py +424 -0
- src/mcp/mcp_server.py +4086 -0
- src/mcp/rules_engine.py +487 -0
- src/mcp/runbook_manager.py +264 -0
- src/orchestrator/__init__.py +11 -0
- src/orchestrator/incident_workflow.py +244 -0
- src/orchestrator/tools_case.py +1085 -0
- src/orchestrator/tools_cti.py +359 -0
- src/orchestrator/tools_edr.py +315 -0
- src/orchestrator/tools_eng.py +378 -0
- src/orchestrator/tools_kb.py +156 -0
- src/orchestrator/tools_siem.py +1709 -0
- src/web/__init__.py +8 -0
- src/web/config_server.py +511 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Low-level HTTP client for IRIS.
|
|
3
|
+
|
|
4
|
+
This module is responsible for:
|
|
5
|
+
- authentication (API key header)
|
|
6
|
+
- building URLs
|
|
7
|
+
- making HTTP requests
|
|
8
|
+
- basic error handling
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, Dict, Optional
|
|
16
|
+
|
|
17
|
+
import requests
|
|
18
|
+
|
|
19
|
+
from ....core.errors import IntegrationError
|
|
20
|
+
from ....core.logging import get_logger
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
logger = get_logger("sami.integrations.iris.http")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class IrisHttpClient:
|
|
28
|
+
"""
|
|
29
|
+
Simple HTTP client for IRIS's REST API.
|
|
30
|
+
|
|
31
|
+
IRIS API documentation: https://docs.dfir-iris.org/
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
base_url: str
|
|
35
|
+
api_key: str
|
|
36
|
+
timeout_seconds: int = 30
|
|
37
|
+
verify_ssl: bool = True
|
|
38
|
+
|
|
39
|
+
def _headers(self) -> Dict[str, str]:
|
|
40
|
+
return {
|
|
41
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
"Accept": "application/json",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
def authenticate(self) -> None:
|
|
47
|
+
"""
|
|
48
|
+
Placeholder for any explicit authentication logic.
|
|
49
|
+
|
|
50
|
+
IRIS uses API keys in headers, so there may be no
|
|
51
|
+
separate login step. This method exists to match the design doc
|
|
52
|
+
and as a hook for future changes.
|
|
53
|
+
"""
|
|
54
|
+
logger.debug("IrisHttpClient.authenticate called (no-op for API key)")
|
|
55
|
+
|
|
56
|
+
def _build_url(self, endpoint: str) -> str:
|
|
57
|
+
"""
|
|
58
|
+
Build a full URL from a base URL and an endpoint.
|
|
59
|
+
|
|
60
|
+
IRIS API v2.0.0+ uses direct paths without /api/v1/ prefix for manage endpoints.
|
|
61
|
+
API endpoints (like /api/ping, /api/versions) use /api/ prefix.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
endpoint: API endpoint path (e.g., "/manage/cases/list" or "manage/cases/list")
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Full URL string
|
|
68
|
+
"""
|
|
69
|
+
base = self.base_url.rstrip("/")
|
|
70
|
+
endpoint = endpoint.lstrip("/")
|
|
71
|
+
|
|
72
|
+
# IRIS API structure:
|
|
73
|
+
# - /api/* for API endpoints (ping, versions, etc.)
|
|
74
|
+
# - /manage/* for management endpoints (cases, users, etc.)
|
|
75
|
+
# - /case/* for case-specific operations
|
|
76
|
+
# No need to add /api/v1/ prefix - use endpoint as-is
|
|
77
|
+
|
|
78
|
+
return f"{base}/{endpoint}"
|
|
79
|
+
|
|
80
|
+
def _handle_iris_error(self, response: requests.Response) -> None:
|
|
81
|
+
"""
|
|
82
|
+
Raise IntegrationError if the response indicates an error.
|
|
83
|
+
|
|
84
|
+
IRIS API returns responses in format: {"status": "success|error", "message": "...", "data": ...}
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
response: HTTP response object
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
IntegrationError: If the response indicates an error
|
|
91
|
+
"""
|
|
92
|
+
if response.status_code < 400:
|
|
93
|
+
# Check if response body indicates an error
|
|
94
|
+
try:
|
|
95
|
+
error_data = response.json()
|
|
96
|
+
if error_data.get("status") == "error":
|
|
97
|
+
message = error_data.get("message", "Unknown error")
|
|
98
|
+
raise IntegrationError(f"IRIS API error: {message}")
|
|
99
|
+
except (ValueError, IntegrationError):
|
|
100
|
+
# If it's already an IntegrationError, re-raise it
|
|
101
|
+
if isinstance(error_data, dict) and error_data.get("status") == "error":
|
|
102
|
+
raise
|
|
103
|
+
# Otherwise continue (might not be JSON or might be success)
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
# HTTP error status code
|
|
107
|
+
try:
|
|
108
|
+
error_data = response.json()
|
|
109
|
+
# IRIS API error format
|
|
110
|
+
if error_data.get("status") == "error":
|
|
111
|
+
message = error_data.get("message", f"HTTP {response.status_code}")
|
|
112
|
+
else:
|
|
113
|
+
message = error_data.get("message", f"HTTP {response.status_code}")
|
|
114
|
+
details = error_data.get("detail") or error_data.get("error", "")
|
|
115
|
+
full_message = f"{message}: {details}" if details else message
|
|
116
|
+
except Exception:
|
|
117
|
+
full_message = f"HTTP {response.status_code}: {response.text[:200]}"
|
|
118
|
+
|
|
119
|
+
raise IntegrationError(f"IRIS API error: {full_message}")
|
|
120
|
+
|
|
121
|
+
def request(
|
|
122
|
+
self,
|
|
123
|
+
method: str,
|
|
124
|
+
endpoint: str,
|
|
125
|
+
json_data: Optional[Dict[str, Any]] = None,
|
|
126
|
+
params: Optional[Dict[str, Any]] = None,
|
|
127
|
+
) -> Dict[str, Any]:
|
|
128
|
+
"""
|
|
129
|
+
Make an HTTP request to IRIS API.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
method: HTTP method (GET, POST, PUT, PATCH, DELETE)
|
|
133
|
+
endpoint: API endpoint path
|
|
134
|
+
json_data: JSON payload (for POST, PUT, PATCH)
|
|
135
|
+
params: Query parameters (for GET, etc.)
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Response JSON as dictionary
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
IntegrationError: If the request fails
|
|
142
|
+
"""
|
|
143
|
+
url = self._build_url(endpoint)
|
|
144
|
+
headers = self._headers()
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
logger.debug(f"IRIS {method} {url}")
|
|
148
|
+
if params:
|
|
149
|
+
logger.debug(f" Query params: {params}")
|
|
150
|
+
if json_data:
|
|
151
|
+
logger.debug(f" JSON payload: {json.dumps(json_data)[:200]}...")
|
|
152
|
+
|
|
153
|
+
response = requests.request(
|
|
154
|
+
method=method,
|
|
155
|
+
url=url,
|
|
156
|
+
headers=headers,
|
|
157
|
+
json=json_data,
|
|
158
|
+
params=params,
|
|
159
|
+
timeout=self.timeout_seconds,
|
|
160
|
+
verify=self.verify_ssl,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
logger.debug(f"IRIS response status: {response.status_code}")
|
|
164
|
+
if response.status_code >= 400:
|
|
165
|
+
logger.error(f"IRIS API error - Status: {response.status_code}, URL: {url}, Response: {response.text[:500]}")
|
|
166
|
+
|
|
167
|
+
self._handle_iris_error(response)
|
|
168
|
+
|
|
169
|
+
if response.status_code == 204: # No Content
|
|
170
|
+
return {}
|
|
171
|
+
|
|
172
|
+
response_data = response.json()
|
|
173
|
+
|
|
174
|
+
# IRIS API wraps responses in {"status": "success", "message": "", "data": ...}
|
|
175
|
+
# Extract the data field if present
|
|
176
|
+
if isinstance(response_data, dict):
|
|
177
|
+
if response_data.get("status") == "success" and "data" in response_data:
|
|
178
|
+
return response_data["data"]
|
|
179
|
+
elif response_data.get("status") == "error":
|
|
180
|
+
# Error already handled by _handle_iris_error, but just in case
|
|
181
|
+
message = response_data.get("message", "Unknown error")
|
|
182
|
+
raise IntegrationError(f"IRIS API error: {message}")
|
|
183
|
+
|
|
184
|
+
return response_data
|
|
185
|
+
|
|
186
|
+
except requests.exceptions.Timeout as e:
|
|
187
|
+
raise IntegrationError(f"IRIS API request timeout: {e}") from e
|
|
188
|
+
except requests.exceptions.RequestException as e:
|
|
189
|
+
raise IntegrationError(f"IRIS API request failed: {e}") from e
|
|
190
|
+
|
|
191
|
+
def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
192
|
+
"""GET request."""
|
|
193
|
+
return self.request("GET", endpoint, params=params)
|
|
194
|
+
|
|
195
|
+
def post(self, endpoint: str, json_data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
196
|
+
"""POST request."""
|
|
197
|
+
return self.request("POST", endpoint, json_data=json_data, params=params)
|
|
198
|
+
|
|
199
|
+
def post_file(
|
|
200
|
+
self,
|
|
201
|
+
endpoint: str,
|
|
202
|
+
file_path: str,
|
|
203
|
+
file_field: str = "file",
|
|
204
|
+
additional_data: Optional[Dict[str, Any]] = None,
|
|
205
|
+
params: Optional[Dict[str, Any]] = None,
|
|
206
|
+
) -> Dict[str, Any]:
|
|
207
|
+
"""
|
|
208
|
+
POST request with file upload.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
endpoint: API endpoint path
|
|
212
|
+
file_path: Path to file to upload
|
|
213
|
+
file_field: Form field name for the file (default: "file")
|
|
214
|
+
additional_data: Additional form data to include
|
|
215
|
+
params: Query parameters to include in the URL
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
Response JSON as dictionary
|
|
219
|
+
"""
|
|
220
|
+
import os
|
|
221
|
+
from pathlib import Path
|
|
222
|
+
|
|
223
|
+
url = self._build_url(endpoint)
|
|
224
|
+
headers = {
|
|
225
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
226
|
+
# Don't set Content-Type - let requests set it for multipart/form-data
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if not os.path.exists(file_path):
|
|
230
|
+
raise IntegrationError(f"File not found: {file_path}")
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
with open(file_path, "rb") as f:
|
|
234
|
+
files = {file_field: (os.path.basename(file_path), f)}
|
|
235
|
+
data = additional_data or {}
|
|
236
|
+
|
|
237
|
+
logger.debug(f"IRIS POST FILE {url}")
|
|
238
|
+
if params:
|
|
239
|
+
logger.debug(f" Query params: {params}")
|
|
240
|
+
logger.debug(f" File: {file_path}, Field: {file_field}")
|
|
241
|
+
|
|
242
|
+
response = requests.post(
|
|
243
|
+
url,
|
|
244
|
+
headers=headers,
|
|
245
|
+
files=files,
|
|
246
|
+
data=data,
|
|
247
|
+
params=params,
|
|
248
|
+
timeout=self.timeout_seconds,
|
|
249
|
+
verify=self.verify_ssl,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
logger.debug(f"IRIS response status: {response.status_code}")
|
|
253
|
+
if response.status_code >= 400:
|
|
254
|
+
logger.error(f"IRIS API error - Status: {response.status_code}, URL: {url}, Response: {response.text[:500]}")
|
|
255
|
+
|
|
256
|
+
self._handle_iris_error(response)
|
|
257
|
+
|
|
258
|
+
if response.status_code == 204:
|
|
259
|
+
return {}
|
|
260
|
+
|
|
261
|
+
return response.json()
|
|
262
|
+
except requests.exceptions.Timeout as e:
|
|
263
|
+
raise IntegrationError(f"IRIS API request timeout: {e}") from e
|
|
264
|
+
except requests.exceptions.RequestException as e:
|
|
265
|
+
raise IntegrationError(f"IRIS API request failed: {e}") from e
|
|
266
|
+
|
|
267
|
+
def patch(self, endpoint: str, json_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
268
|
+
"""PATCH request."""
|
|
269
|
+
return self.request("PATCH", endpoint, json_data=json_data)
|
|
270
|
+
|
|
271
|
+
def delete(self, endpoint: str) -> Dict[str, Any]:
|
|
272
|
+
"""DELETE request."""
|
|
273
|
+
return self.request("DELETE", endpoint)
|
|
274
|
+
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mapping logic between generic case management DTOs and IRIS models/payloads.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
from ....api.case_management import (
|
|
10
|
+
Case,
|
|
11
|
+
CaseAssignment,
|
|
12
|
+
CaseComment,
|
|
13
|
+
CaseObservable,
|
|
14
|
+
CasePriority,
|
|
15
|
+
CaseStatus,
|
|
16
|
+
CaseSummary,
|
|
17
|
+
)
|
|
18
|
+
from .iris_models import (
|
|
19
|
+
IrisCase,
|
|
20
|
+
IrisCasePriority,
|
|
21
|
+
IrisCaseStatus,
|
|
22
|
+
parse_iris_case,
|
|
23
|
+
parse_iris_ioc,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Generic → IRIS
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def case_to_iris_payload(case: Case) -> Dict[str, Any]:
|
|
31
|
+
"""
|
|
32
|
+
Convert a generic ``Case`` into an IRIS case creation/update payload.
|
|
33
|
+
"""
|
|
34
|
+
payload: Dict[str, Any] = {
|
|
35
|
+
"case_name": case.title,
|
|
36
|
+
"case_description": case.description or "",
|
|
37
|
+
"case_customer": 3, # Always use "All" client (customer_id: 3)
|
|
38
|
+
"case_soc_id": "soc_id_default", # Required field - default SOC ID
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Convert tags list to comma-separated string (only if tags exist)
|
|
42
|
+
if case.tags:
|
|
43
|
+
tags_str = ", ".join(case.tags)
|
|
44
|
+
payload["case_tags"] = tags_str # IRIS expects tags as a string, not a list
|
|
45
|
+
|
|
46
|
+
# Note: state_id and severity_id are not set during creation
|
|
47
|
+
# IRIS will set defaults (state_id: 3, severity_id: 4)
|
|
48
|
+
# These can be updated after case creation if needed
|
|
49
|
+
|
|
50
|
+
if case.assignee:
|
|
51
|
+
# Note: IRIS uses user_id, so we'd need to resolve username to ID
|
|
52
|
+
# For now, assume assignee can be a username or ID string
|
|
53
|
+
payload["case_owner"] = case.assignee
|
|
54
|
+
|
|
55
|
+
return payload
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def comment_to_iris_payload(comment: CaseComment) -> Dict[str, Any]:
|
|
59
|
+
"""Convert generic comment to IRIS payload."""
|
|
60
|
+
return {
|
|
61
|
+
"comment_content": comment.content,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def observable_type_to_iris_ioc_type_id(observable_type: str) -> int:
|
|
66
|
+
"""
|
|
67
|
+
Map generic observable type string to IRIS IOC type ID.
|
|
68
|
+
|
|
69
|
+
Common mappings:
|
|
70
|
+
- ip, ip-any -> 76 (ip-any)
|
|
71
|
+
- ip-dst -> 77
|
|
72
|
+
- ip-src -> 79
|
|
73
|
+
- domain -> 20
|
|
74
|
+
- url -> 141
|
|
75
|
+
- hash, md5 -> 90
|
|
76
|
+
- sha1 -> 111
|
|
77
|
+
- sha256 -> 113
|
|
78
|
+
- email -> 22
|
|
79
|
+
"""
|
|
80
|
+
type_lower = observable_type.lower()
|
|
81
|
+
|
|
82
|
+
# IP addresses
|
|
83
|
+
if type_lower in ("ip", "ip-any", "ip_address"):
|
|
84
|
+
return 76 # ip-any
|
|
85
|
+
elif type_lower == "ip-dst":
|
|
86
|
+
return 77
|
|
87
|
+
elif type_lower == "ip-src":
|
|
88
|
+
return 79
|
|
89
|
+
|
|
90
|
+
# Domains
|
|
91
|
+
elif type_lower in ("domain", "fqdn", "hostname"):
|
|
92
|
+
return 20 # domain
|
|
93
|
+
|
|
94
|
+
# URLs
|
|
95
|
+
elif type_lower in ("url", "uri"):
|
|
96
|
+
return 141 # url
|
|
97
|
+
|
|
98
|
+
# Hashes
|
|
99
|
+
elif type_lower in ("hash", "md5"):
|
|
100
|
+
return 90 # md5
|
|
101
|
+
elif type_lower == "sha1":
|
|
102
|
+
return 111
|
|
103
|
+
elif type_lower == "sha256":
|
|
104
|
+
return 113
|
|
105
|
+
|
|
106
|
+
# Email
|
|
107
|
+
elif type_lower in ("email", "email-address"):
|
|
108
|
+
return 22 # email
|
|
109
|
+
|
|
110
|
+
# Default to "other" if type not recognized
|
|
111
|
+
return 96 # other
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def observable_to_iris_payload(observable: CaseObservable) -> Dict[str, Any]:
|
|
115
|
+
"""Convert generic observable to IRIS IOC payload."""
|
|
116
|
+
# Convert tags list to comma-separated string if needed
|
|
117
|
+
tags_str = ", ".join(observable.tags) if observable.tags else ""
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
"ioc_type_id": observable_type_to_iris_ioc_type_id(observable.type),
|
|
121
|
+
"ioc_value": observable.value,
|
|
122
|
+
"ioc_tags": tags_str, # IRIS may expect string or list - try string first
|
|
123
|
+
"ioc_description": observable.description or "",
|
|
124
|
+
"ioc_tlp_id": 2, # Default to TLP:AMBER (1=WHITE, 2=AMBER, 3=GREEN, 4=RED)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def status_to_iris_status(case_status: CaseStatus) -> IrisCaseStatus:
|
|
129
|
+
"""Map generic CaseStatus to IRIS CaseStatus enum."""
|
|
130
|
+
mapping = {
|
|
131
|
+
CaseStatus.OPEN: IrisCaseStatus.OPEN,
|
|
132
|
+
CaseStatus.IN_PROGRESS: IrisCaseStatus.ONGOING,
|
|
133
|
+
CaseStatus.CLOSED: IrisCaseStatus.CLOSED,
|
|
134
|
+
}
|
|
135
|
+
return mapping.get(case_status, IrisCaseStatus.OPEN)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def status_to_iris_status_id(case_status: CaseStatus) -> int:
|
|
139
|
+
"""Map generic CaseStatus to IRIS status ID."""
|
|
140
|
+
# IRIS status IDs: typically 1=open, 2=ongoing, 3=closed, 4=archived
|
|
141
|
+
status_id_map = {
|
|
142
|
+
CaseStatus.OPEN: 1,
|
|
143
|
+
CaseStatus.IN_PROGRESS: 2,
|
|
144
|
+
CaseStatus.CLOSED: 3,
|
|
145
|
+
}
|
|
146
|
+
return status_id_map.get(case_status, 1)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def priority_to_iris_priority(priority: CasePriority) -> IrisCasePriority:
|
|
150
|
+
"""Map generic CasePriority to IRIS CasePriority enum."""
|
|
151
|
+
mapping = {
|
|
152
|
+
CasePriority.LOW: IrisCasePriority.LOW,
|
|
153
|
+
CasePriority.MEDIUM: IrisCasePriority.MEDIUM,
|
|
154
|
+
CasePriority.HIGH: IrisCasePriority.HIGH,
|
|
155
|
+
CasePriority.CRITICAL: IrisCasePriority.CRITICAL,
|
|
156
|
+
}
|
|
157
|
+
return mapping[priority]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def priority_id_to_case_priority(priority_id: Optional[int]) -> CasePriority:
|
|
161
|
+
"""Map IRIS priority ID to generic CasePriority."""
|
|
162
|
+
if priority_id is None:
|
|
163
|
+
return CasePriority.MEDIUM
|
|
164
|
+
|
|
165
|
+
# IRIS priority mapping: 4=low, 3=medium, 2=high, 1=critical
|
|
166
|
+
if priority_id >= 4:
|
|
167
|
+
return CasePriority.LOW
|
|
168
|
+
elif priority_id == 3:
|
|
169
|
+
return CasePriority.MEDIUM
|
|
170
|
+
elif priority_id == 2:
|
|
171
|
+
return CasePriority.HIGH
|
|
172
|
+
else:
|
|
173
|
+
return CasePriority.CRITICAL
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def status_id_to_case_status(status_id: Optional[int]) -> CaseStatus:
|
|
177
|
+
"""Map IRIS status ID to generic CaseStatus."""
|
|
178
|
+
if status_id is None:
|
|
179
|
+
return CaseStatus.OPEN
|
|
180
|
+
|
|
181
|
+
# IRIS status IDs: typically 1=open, 2=ongoing, 3=closed, 4=archived
|
|
182
|
+
if status_id == 1:
|
|
183
|
+
return CaseStatus.OPEN
|
|
184
|
+
elif status_id == 2:
|
|
185
|
+
return CaseStatus.IN_PROGRESS
|
|
186
|
+
else:
|
|
187
|
+
return CaseStatus.CLOSED
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# IRIS → Generic
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def iris_case_to_generic(raw: Dict[str, Any]) -> Case:
|
|
194
|
+
"""Convert IRIS case to generic Case."""
|
|
195
|
+
iris_case: IrisCase = parse_iris_case(raw)
|
|
196
|
+
|
|
197
|
+
priority = priority_id_to_case_priority(iris_case.case_priority_id)
|
|
198
|
+
status = status_id_to_case_status(iris_case.case_status_id)
|
|
199
|
+
|
|
200
|
+
return Case(
|
|
201
|
+
id=str(iris_case.case_id),
|
|
202
|
+
title=iris_case.case_name,
|
|
203
|
+
description=iris_case.case_description or "",
|
|
204
|
+
status=status,
|
|
205
|
+
priority=priority,
|
|
206
|
+
created_at=iris_case.case_open_date,
|
|
207
|
+
updated_at=iris_case.case_update_date or iris_case.case_open_date,
|
|
208
|
+
assignee=str(iris_case.case_owner_id) if iris_case.case_owner_id else None,
|
|
209
|
+
tags=iris_case.case_tags or [],
|
|
210
|
+
observables=None,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def iris_case_to_summary(raw: Dict[str, Any]) -> CaseSummary:
|
|
215
|
+
"""Convert IRIS case to generic CaseSummary."""
|
|
216
|
+
iris_case: IrisCase = parse_iris_case(raw)
|
|
217
|
+
|
|
218
|
+
priority = priority_id_to_case_priority(iris_case.case_priority_id)
|
|
219
|
+
status = status_id_to_case_status(iris_case.case_status_id)
|
|
220
|
+
|
|
221
|
+
return CaseSummary(
|
|
222
|
+
id=str(iris_case.case_id),
|
|
223
|
+
title=iris_case.case_name,
|
|
224
|
+
status=status,
|
|
225
|
+
priority=priority,
|
|
226
|
+
created_at=iris_case.case_open_date,
|
|
227
|
+
updated_at=iris_case.case_update_date or iris_case.case_open_date,
|
|
228
|
+
assignee=str(iris_case.case_owner_id) if iris_case.case_owner_id else None,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def iris_comment_to_generic(raw: Dict[str, Any], case_id: str) -> CaseComment:
|
|
233
|
+
"""Convert IRIS comment to generic CaseComment."""
|
|
234
|
+
from datetime import datetime
|
|
235
|
+
|
|
236
|
+
created_at_value = raw.get("comment_date") or raw.get("comment_added")
|
|
237
|
+
created_at = None
|
|
238
|
+
if isinstance(created_at_value, (int, float)):
|
|
239
|
+
created_at = datetime.fromtimestamp(created_at_value)
|
|
240
|
+
elif isinstance(created_at_value, str):
|
|
241
|
+
try:
|
|
242
|
+
created_at = datetime.fromisoformat(created_at_value.replace("Z", "+00:00"))
|
|
243
|
+
except Exception:
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
return CaseComment(
|
|
247
|
+
id=str(raw.get("comment_id", raw.get("id", 0))),
|
|
248
|
+
case_id=case_id,
|
|
249
|
+
author=raw.get("comment_user") or raw.get("user_name") or raw.get("user"),
|
|
250
|
+
content=raw.get("comment_content", raw.get("content", "")),
|
|
251
|
+
created_at=created_at,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def iris_ioc_to_observable(raw: Dict[str, Any], case_id: str) -> CaseObservable:
|
|
256
|
+
"""Convert IRIS IOC to generic CaseObservable."""
|
|
257
|
+
iris_ioc = parse_iris_ioc(raw)
|
|
258
|
+
return CaseObservable(
|
|
259
|
+
type=iris_ioc.ioc_type,
|
|
260
|
+
value=iris_ioc.ioc_value,
|
|
261
|
+
tags=iris_ioc.ioc_tags or [],
|
|
262
|
+
description=iris_ioc.ioc_description,
|
|
263
|
+
)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""
|
|
2
|
+
IRIS-specific data models.
|
|
3
|
+
|
|
4
|
+
These dataclasses are close to IRIS's API payloads but kept separate
|
|
5
|
+
from the generic DTOs defined under ``src/api``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IrisCaseStatus(str, Enum):
|
|
17
|
+
"""IRIS case status values."""
|
|
18
|
+
OPEN = "open"
|
|
19
|
+
ONGOING = "ongoing"
|
|
20
|
+
CLOSED = "closed"
|
|
21
|
+
ARCHIVED = "archived"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class IrisCasePriority(str, Enum):
|
|
25
|
+
"""IRIS case priority values."""
|
|
26
|
+
LOW = "low"
|
|
27
|
+
MEDIUM = "medium"
|
|
28
|
+
HIGH = "high"
|
|
29
|
+
CRITICAL = "critical"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class IrisUser:
|
|
34
|
+
"""IRIS user model."""
|
|
35
|
+
id: int
|
|
36
|
+
user: str
|
|
37
|
+
name: Optional[str] = None
|
|
38
|
+
email: Optional[str] = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class IrisIOC:
|
|
43
|
+
"""IRIS IOCs (Indicators of Compromise) model."""
|
|
44
|
+
id: int
|
|
45
|
+
ioc_type: str # e.g., "ip", "domain", "hash", etc.
|
|
46
|
+
ioc_value: str
|
|
47
|
+
ioc_tags: Optional[List[str]] = None
|
|
48
|
+
ioc_description: Optional[str] = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class IrisCase:
|
|
53
|
+
"""IRIS case model."""
|
|
54
|
+
case_id: int
|
|
55
|
+
case_name: str
|
|
56
|
+
case_description: Optional[str]
|
|
57
|
+
case_priority_id: Optional[int] # Maps to priority: 4=low, 3=medium, 2=high, 1=critical
|
|
58
|
+
case_open_date: Optional[datetime]
|
|
59
|
+
case_update_date: Optional[datetime]
|
|
60
|
+
case_status_id: Optional[int] # Maps to status
|
|
61
|
+
case_owner_id: Optional[int]
|
|
62
|
+
case_tags: Optional[List[str]] = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def parse_iris_case(raw: Dict[str, Any]) -> IrisCase:
|
|
66
|
+
"""
|
|
67
|
+
Parse a raw IRIS case dict into an ``IrisCase`` instance.
|
|
68
|
+
|
|
69
|
+
IRIS API uses integer IDs and different field names than TheHive.
|
|
70
|
+
"""
|
|
71
|
+
# IRIS uses timestamps or ISO format strings
|
|
72
|
+
open_date_value = raw.get("case_open_date") or raw.get("open_date")
|
|
73
|
+
open_date: Optional[datetime] = None
|
|
74
|
+
if isinstance(open_date_value, (int, float)):
|
|
75
|
+
open_date = datetime.fromtimestamp(open_date_value)
|
|
76
|
+
elif isinstance(open_date_value, str):
|
|
77
|
+
try:
|
|
78
|
+
open_date = datetime.fromisoformat(open_date_value.replace("Z", "+00:00"))
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
update_date_value = raw.get("case_update_date") or raw.get("update_date")
|
|
83
|
+
update_date: Optional[datetime] = None
|
|
84
|
+
if isinstance(update_date_value, (int, float)):
|
|
85
|
+
update_date = datetime.fromtimestamp(update_date_value)
|
|
86
|
+
elif isinstance(update_date_value, str):
|
|
87
|
+
try:
|
|
88
|
+
update_date = datetime.fromisoformat(update_date_value.replace("Z", "+00:00"))
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
return IrisCase(
|
|
93
|
+
case_id=int(raw.get("case_id", raw.get("id", 0))),
|
|
94
|
+
case_name=raw.get("case_name", raw.get("name", "")),
|
|
95
|
+
case_description=raw.get("case_description", raw.get("description")),
|
|
96
|
+
case_priority_id=raw.get("case_priority_id", raw.get("priority_id")),
|
|
97
|
+
case_open_date=open_date,
|
|
98
|
+
case_update_date=update_date,
|
|
99
|
+
case_status_id=raw.get("case_status_id", raw.get("status_id")),
|
|
100
|
+
case_owner_id=raw.get("case_owner_id", raw.get("owner_id")),
|
|
101
|
+
case_tags=raw.get("case_tags", raw.get("tags")) or [],
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def parse_iris_ioc(raw: Dict[str, Any]) -> IrisIOC:
|
|
106
|
+
"""
|
|
107
|
+
Parse a raw IRIS IOC dict.
|
|
108
|
+
"""
|
|
109
|
+
return IrisIOC(
|
|
110
|
+
id=int(raw.get("ioc_id", 0)),
|
|
111
|
+
ioc_type=raw.get("ioc_type", ""),
|
|
112
|
+
ioc_value=raw.get("ioc_value", ""),
|
|
113
|
+
ioc_tags=raw.get("ioc_tags") or [],
|
|
114
|
+
ioc_description=raw.get("ioc_description"),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def parse_iris_user(raw: Dict[str, Any]) -> IrisUser:
|
|
119
|
+
"""
|
|
120
|
+
Parse a raw IRIS user dict.
|
|
121
|
+
"""
|
|
122
|
+
return IrisUser(
|
|
123
|
+
id=int(raw.get("user_id", 0)),
|
|
124
|
+
user=raw.get("user", ""),
|
|
125
|
+
name=raw.get("name"),
|
|
126
|
+
email=raw.get("email"),
|
|
127
|
+
)
|
|
128
|
+
|