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,885 @@
|
|
|
1
|
+
"""
|
|
2
|
+
IRIS implementation of the generic ``CaseManagementClient`` interface.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import List, Optional, Dict, Any
|
|
8
|
+
|
|
9
|
+
from ....api.case_management import (
|
|
10
|
+
Case,
|
|
11
|
+
CaseAssignment,
|
|
12
|
+
CaseComment,
|
|
13
|
+
CaseManagementClient,
|
|
14
|
+
CaseObservable,
|
|
15
|
+
CaseSearchQuery,
|
|
16
|
+
CaseStatus,
|
|
17
|
+
CaseSummary,
|
|
18
|
+
)
|
|
19
|
+
from ....core.config import SamiConfig
|
|
20
|
+
from ....core.errors import IntegrationError
|
|
21
|
+
from ....core.logging import get_logger
|
|
22
|
+
from .iris_http import IrisHttpClient
|
|
23
|
+
from .iris_mapper import (
|
|
24
|
+
case_to_iris_payload,
|
|
25
|
+
comment_to_iris_payload,
|
|
26
|
+
iris_case_to_generic,
|
|
27
|
+
iris_case_to_summary,
|
|
28
|
+
iris_comment_to_generic,
|
|
29
|
+
iris_ioc_to_observable,
|
|
30
|
+
observable_to_iris_payload,
|
|
31
|
+
status_to_iris_status_id,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
logger = get_logger("sami.integrations.iris.client")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class IRISCaseManagementClient(CaseManagementClient):
|
|
39
|
+
"""
|
|
40
|
+
Case management client backed by IRIS.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, http_client: IrisHttpClient) -> None:
|
|
44
|
+
self._http = http_client
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_config(cls, config: SamiConfig) -> "IRISCaseManagementClient":
|
|
48
|
+
"""
|
|
49
|
+
Factory to construct a client from ``SamiConfig``.
|
|
50
|
+
"""
|
|
51
|
+
if not config.iris:
|
|
52
|
+
raise IntegrationError("IRIS configuration is not set in SamiConfig")
|
|
53
|
+
|
|
54
|
+
http_client = IrisHttpClient(
|
|
55
|
+
base_url=config.iris.base_url,
|
|
56
|
+
api_key=config.iris.api_key,
|
|
57
|
+
timeout_seconds=config.iris.timeout_seconds,
|
|
58
|
+
verify_ssl=config.iris.verify_ssl,
|
|
59
|
+
)
|
|
60
|
+
return cls(http_client=http_client)
|
|
61
|
+
|
|
62
|
+
# Core CRUD operations
|
|
63
|
+
|
|
64
|
+
def create_case(self, case: Case) -> Case:
|
|
65
|
+
"""
|
|
66
|
+
Create a new case in IRIS.
|
|
67
|
+
|
|
68
|
+
Uses IRIS API endpoint: POST /manage/cases/add
|
|
69
|
+
"""
|
|
70
|
+
payload = case_to_iris_payload(case)
|
|
71
|
+
raw = self._http.post("/manage/cases/add", json_data=payload)
|
|
72
|
+
return iris_case_to_generic(raw)
|
|
73
|
+
|
|
74
|
+
def get_case(self, case_id: str) -> Case:
|
|
75
|
+
"""
|
|
76
|
+
Get a case by ID from IRIS.
|
|
77
|
+
|
|
78
|
+
Uses IRIS API endpoint: GET /case/case-summary/update (with cid parameter)
|
|
79
|
+
Note: IRIS v2.0.0+ requires cid parameter for access control
|
|
80
|
+
"""
|
|
81
|
+
# IRIS v2.0.0+ requires cid parameter
|
|
82
|
+
raw = self._http.get("/case/case-summary/update", params={"cid": case_id})
|
|
83
|
+
return iris_case_to_generic(raw)
|
|
84
|
+
|
|
85
|
+
def list_cases(
|
|
86
|
+
self,
|
|
87
|
+
status: Optional[CaseStatus] = None,
|
|
88
|
+
limit: int = 50,
|
|
89
|
+
) -> List[CaseSummary]:
|
|
90
|
+
"""
|
|
91
|
+
List cases from IRIS, optionally filtered by status.
|
|
92
|
+
|
|
93
|
+
Uses IRIS API endpoint: GET /manage/cases/list
|
|
94
|
+
"""
|
|
95
|
+
params: dict = {}
|
|
96
|
+
if status is not None:
|
|
97
|
+
params["state_id"] = status_to_iris_status_id(status)
|
|
98
|
+
|
|
99
|
+
# IRIS API uses /manage/cases/list endpoint
|
|
100
|
+
response = self._http.get("/manage/cases/list", params=params)
|
|
101
|
+
|
|
102
|
+
# Response is already unwrapped by iris_http (returns data field)
|
|
103
|
+
# IRIS returns a list of cases directly
|
|
104
|
+
if isinstance(response, list):
|
|
105
|
+
raw_list = response
|
|
106
|
+
elif isinstance(response, dict) and "data" in response:
|
|
107
|
+
raw_list = response["data"]
|
|
108
|
+
else:
|
|
109
|
+
raw_list = []
|
|
110
|
+
|
|
111
|
+
return [iris_case_to_summary(raw) for raw in raw_list[:limit]]
|
|
112
|
+
|
|
113
|
+
def search_cases(self, query: CaseSearchQuery) -> List[CaseSummary]:
|
|
114
|
+
"""
|
|
115
|
+
Search cases in IRIS.
|
|
116
|
+
|
|
117
|
+
Uses IRIS API endpoint: GET /manage/cases/filter
|
|
118
|
+
"""
|
|
119
|
+
params: dict = {}
|
|
120
|
+
if query.text:
|
|
121
|
+
params["search"] = query.text
|
|
122
|
+
if query.status:
|
|
123
|
+
params["state_id"] = status_to_iris_status_id(query.status)
|
|
124
|
+
if query.assignee:
|
|
125
|
+
params["owner_id"] = query.assignee
|
|
126
|
+
|
|
127
|
+
# IRIS API uses /manage/cases/filter for searching
|
|
128
|
+
response = self._http.get("/manage/cases/filter", params=params)
|
|
129
|
+
|
|
130
|
+
# Response is already unwrapped by iris_http
|
|
131
|
+
if isinstance(response, list):
|
|
132
|
+
raw_list = response
|
|
133
|
+
elif isinstance(response, dict) and "data" in response:
|
|
134
|
+
raw_list = response["data"]
|
|
135
|
+
else:
|
|
136
|
+
raw_list = []
|
|
137
|
+
|
|
138
|
+
summaries = [iris_case_to_summary(raw) for raw in raw_list]
|
|
139
|
+
return summaries[:query.limit]
|
|
140
|
+
|
|
141
|
+
def update_case(self, case_id: str, updates: dict) -> Case:
|
|
142
|
+
"""
|
|
143
|
+
Update a case in IRIS.
|
|
144
|
+
|
|
145
|
+
Uses IRIS API endpoint: POST /manage/cases/update/{case_id}
|
|
146
|
+
Note: IRIS v2.0.0+ uses POST for updates, not PATCH
|
|
147
|
+
"""
|
|
148
|
+
# Always ensure client is set to "All" (customer_id: 3)
|
|
149
|
+
updates = updates.copy() # Don't modify the original dict
|
|
150
|
+
updates["case_customer"] = 3
|
|
151
|
+
|
|
152
|
+
raw = self._http.post(f"/manage/cases/update/{case_id}", json_data=updates)
|
|
153
|
+
return iris_case_to_generic(raw)
|
|
154
|
+
|
|
155
|
+
def delete_case(self, case_id: str) -> None:
|
|
156
|
+
"""
|
|
157
|
+
Delete a case from IRIS.
|
|
158
|
+
|
|
159
|
+
Uses IRIS API endpoint: POST /manage/cases/delete/{case_id}
|
|
160
|
+
Note: IRIS v2.0.0+ uses POST for deletion, not DELETE
|
|
161
|
+
"""
|
|
162
|
+
self._http.post(f"/manage/cases/delete/{case_id}")
|
|
163
|
+
|
|
164
|
+
# Comments, notes and observables
|
|
165
|
+
|
|
166
|
+
def add_case_comment(
|
|
167
|
+
self,
|
|
168
|
+
case_id: str,
|
|
169
|
+
content: str,
|
|
170
|
+
author: Optional[str] = None,
|
|
171
|
+
) -> CaseComment:
|
|
172
|
+
"""
|
|
173
|
+
Add a comment/note to a case in IRIS.
|
|
174
|
+
|
|
175
|
+
On newer IRIS versions the `/comments/add` endpoint may not be available
|
|
176
|
+
anymore. To ensure comments/notes are visible in the GUI for all users,
|
|
177
|
+
this implementation uses the **Case notes** API:
|
|
178
|
+
|
|
179
|
+
1. Create a notes group for the case (if needed) via
|
|
180
|
+
``POST /case/notes/groups/add?cid=<case_id>``.
|
|
181
|
+
2. Create a note inside that group via ``POST /case/notes/add`` with
|
|
182
|
+
body containing ``note_title``, ``note_content`` and ``group_id``.
|
|
183
|
+
|
|
184
|
+
This maps back to the generic ``CaseComment`` DTO.
|
|
185
|
+
"""
|
|
186
|
+
# Defensive: normalise case_id / cid
|
|
187
|
+
cid_int = int(case_id) if isinstance(case_id, str) and case_id.isdigit() else case_id
|
|
188
|
+
|
|
189
|
+
# 1) Create a notes group for this case.
|
|
190
|
+
# We create a lightweight group each time; IRIS handles multiple groups per case.
|
|
191
|
+
group_title = f"Notes for case {case_id}"
|
|
192
|
+
group_payload: Dict[str, Any] = {"group_title": group_title}
|
|
193
|
+
group_raw = self._http.post("/case/notes/groups/add", json_data=group_payload, params={"cid": cid_int})
|
|
194
|
+
group_id = group_raw.get("group_id")
|
|
195
|
+
if group_id is None:
|
|
196
|
+
raise IntegrationError("IRIS did not return group_id when creating notes group")
|
|
197
|
+
|
|
198
|
+
# 2) Create the note itself.
|
|
199
|
+
# Use the first part of the content as the title if possible.
|
|
200
|
+
note_title = (content or "").strip().splitlines()[0] or "Case note"
|
|
201
|
+
if len(note_title) > 120:
|
|
202
|
+
note_title = note_title[:117] + "..."
|
|
203
|
+
|
|
204
|
+
note_payload: Dict[str, Any] = {
|
|
205
|
+
"note_title": note_title,
|
|
206
|
+
"note_content": content,
|
|
207
|
+
"group_id": group_id,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
note_raw = self._http.post("/case/notes/add", json_data=note_payload, params={"cid": cid_int})
|
|
211
|
+
|
|
212
|
+
# Map note response to generic CaseComment DTO
|
|
213
|
+
from datetime import datetime
|
|
214
|
+
|
|
215
|
+
comment_id = str(note_raw.get("note_id", note_raw.get("id", 0)))
|
|
216
|
+
created_at_str = note_raw.get("note_creationdate") or note_raw.get("note_lastupdate")
|
|
217
|
+
created_at: Optional[datetime] = None
|
|
218
|
+
if isinstance(created_at_str, str):
|
|
219
|
+
try:
|
|
220
|
+
created_at = datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
|
|
221
|
+
except Exception:
|
|
222
|
+
created_at = None
|
|
223
|
+
|
|
224
|
+
return CaseComment(
|
|
225
|
+
id=comment_id,
|
|
226
|
+
case_id=str(case_id),
|
|
227
|
+
author=author,
|
|
228
|
+
content=content,
|
|
229
|
+
created_at=created_at,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def add_case_observable(
|
|
233
|
+
self,
|
|
234
|
+
case_id: str,
|
|
235
|
+
observable: CaseObservable,
|
|
236
|
+
) -> CaseObservable:
|
|
237
|
+
"""
|
|
238
|
+
Add an observable (IOC) to a case in IRIS.
|
|
239
|
+
|
|
240
|
+
Uses IRIS API endpoint: POST /case/ioc/add
|
|
241
|
+
Note: IRIS v2.0.0+ requires cid parameter
|
|
242
|
+
"""
|
|
243
|
+
payload = observable_to_iris_payload(observable)
|
|
244
|
+
payload["cid"] = case_id
|
|
245
|
+
raw = self._http.post("/case/ioc/add", json_data=payload)
|
|
246
|
+
return iris_ioc_to_observable(raw, case_id=case_id)
|
|
247
|
+
|
|
248
|
+
# Status and assignment
|
|
249
|
+
|
|
250
|
+
def update_case_status(
|
|
251
|
+
self,
|
|
252
|
+
case_id: str,
|
|
253
|
+
status: CaseStatus,
|
|
254
|
+
) -> Case:
|
|
255
|
+
"""
|
|
256
|
+
Update the status of a case in IRIS.
|
|
257
|
+
|
|
258
|
+
Uses IRIS API endpoint: POST /manage/cases/update/{case_id}
|
|
259
|
+
"""
|
|
260
|
+
payload = {
|
|
261
|
+
"state_id": status_to_iris_status_id(status),
|
|
262
|
+
"case_customer": 3, # Always use "All" client (customer_id: 3)
|
|
263
|
+
}
|
|
264
|
+
raw = self._http.post(f"/manage/cases/update/{int(case_id)}", json_data=payload)
|
|
265
|
+
return iris_case_to_generic(raw)
|
|
266
|
+
|
|
267
|
+
def assign_case(
|
|
268
|
+
self,
|
|
269
|
+
case_id: str,
|
|
270
|
+
assignee: str,
|
|
271
|
+
) -> CaseAssignment:
|
|
272
|
+
"""
|
|
273
|
+
Assign a case to a user in IRIS.
|
|
274
|
+
|
|
275
|
+
Uses IRIS API endpoint: POST /manage/cases/update/{case_id}
|
|
276
|
+
"""
|
|
277
|
+
# IRIS uses owner_id, so assignee should be the user ID
|
|
278
|
+
payload = {"case_owner_id": assignee}
|
|
279
|
+
raw = self._http.post(f"/manage/cases/update/{case_id}", json_data=payload)
|
|
280
|
+
case = iris_case_to_generic(raw)
|
|
281
|
+
from datetime import datetime
|
|
282
|
+
|
|
283
|
+
return CaseAssignment(
|
|
284
|
+
case_id=case.id or case_id,
|
|
285
|
+
assignee=case.assignee or assignee,
|
|
286
|
+
assigned_at=case.updated_at or datetime.utcnow(),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
# Linking and timeline
|
|
290
|
+
|
|
291
|
+
def link_cases(
|
|
292
|
+
self,
|
|
293
|
+
source_case_id: str,
|
|
294
|
+
target_case_id: str,
|
|
295
|
+
link_type: str,
|
|
296
|
+
) -> None:
|
|
297
|
+
"""
|
|
298
|
+
Link two cases in IRIS.
|
|
299
|
+
|
|
300
|
+
Uses IRIS API endpoint: POST /case/case-link/add
|
|
301
|
+
Note: IRIS v2.0.0+ requires cid parameter
|
|
302
|
+
"""
|
|
303
|
+
payload = {
|
|
304
|
+
"cid": source_case_id,
|
|
305
|
+
"case_id": int(target_case_id),
|
|
306
|
+
"link_type": link_type,
|
|
307
|
+
}
|
|
308
|
+
self._http.post("/case/case-link/add", json_data=payload)
|
|
309
|
+
|
|
310
|
+
def get_case_timeline(self, case_id: str) -> List[CaseComment]:
|
|
311
|
+
"""
|
|
312
|
+
Get the timeline (comments) for a case in IRIS.
|
|
313
|
+
|
|
314
|
+
Uses IRIS API endpoint: GET /comments/list
|
|
315
|
+
Note: IRIS v2.0.0+ requires cid parameter
|
|
316
|
+
"""
|
|
317
|
+
# IRIS API requires cid and object_type parameters
|
|
318
|
+
raw_list = self._http.get("/comments/list", params={"cid": case_id, "object_type": "case"})
|
|
319
|
+
|
|
320
|
+
# Response is already unwrapped by iris_http
|
|
321
|
+
if isinstance(raw_list, list):
|
|
322
|
+
pass # Already a list
|
|
323
|
+
elif isinstance(raw_list, dict) and "data" in raw_list:
|
|
324
|
+
raw_list = raw_list["data"]
|
|
325
|
+
else:
|
|
326
|
+
raw_list = []
|
|
327
|
+
|
|
328
|
+
return [iris_comment_to_generic(raw, case_id=case_id) for raw in raw_list]
|
|
329
|
+
|
|
330
|
+
# Timeline events
|
|
331
|
+
|
|
332
|
+
def add_case_timeline_event(
|
|
333
|
+
self,
|
|
334
|
+
case_id: str,
|
|
335
|
+
title: str,
|
|
336
|
+
content: str,
|
|
337
|
+
source: Optional[str] = None,
|
|
338
|
+
category_id: Optional[int] = None,
|
|
339
|
+
tags: Optional[List[str]] = None,
|
|
340
|
+
color: Optional[str] = None,
|
|
341
|
+
event_date: Optional[str] = None,
|
|
342
|
+
include_in_summary: bool = True,
|
|
343
|
+
include_in_graph: bool = True,
|
|
344
|
+
sync_iocs_assets: bool = True,
|
|
345
|
+
asset_ids: Optional[List[int]] = None,
|
|
346
|
+
ioc_ids: Optional[List[int]] = None,
|
|
347
|
+
custom_attributes: Optional[Dict[str, Any]] = None,
|
|
348
|
+
raw: Optional[str] = None,
|
|
349
|
+
tz: Optional[str] = None,
|
|
350
|
+
) -> Dict[str, Any]:
|
|
351
|
+
"""
|
|
352
|
+
Add an event to a case timeline in IRIS.
|
|
353
|
+
|
|
354
|
+
Uses IRIS API endpoint: POST /case/timeline/events/add
|
|
355
|
+
as documented in IRIS API reference v2.0.2
|
|
356
|
+
(Case timeline → Add a new event).
|
|
357
|
+
"""
|
|
358
|
+
from datetime import datetime, timezone
|
|
359
|
+
|
|
360
|
+
cid_int = int(case_id) if isinstance(case_id, str) and case_id.isdigit() else case_id
|
|
361
|
+
|
|
362
|
+
# Build reasonable defaults following the API example.
|
|
363
|
+
if event_date is None:
|
|
364
|
+
# Use current UTC time in 'YYYY-MM-DDTHH:MM:SS.mmm' format
|
|
365
|
+
# as shown in IRIS API examples.
|
|
366
|
+
now = datetime.utcnow()
|
|
367
|
+
event_date = now.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]
|
|
368
|
+
if tz is None:
|
|
369
|
+
tz = "+00:00"
|
|
370
|
+
|
|
371
|
+
payload: Dict[str, Any] = {
|
|
372
|
+
"event_title": title,
|
|
373
|
+
"event_content": content,
|
|
374
|
+
"event_source": source or "SamiGPT",
|
|
375
|
+
"event_category_id": str(category_id or 5),
|
|
376
|
+
"event_in_summary": include_in_summary,
|
|
377
|
+
"event_in_graph": include_in_graph,
|
|
378
|
+
"event_color": color or "#1572E899",
|
|
379
|
+
"event_date": event_date,
|
|
380
|
+
"event_sync_iocs_assets": sync_iocs_assets,
|
|
381
|
+
"event_tags": ",".join(tags) if tags else "",
|
|
382
|
+
"event_tz": tz,
|
|
383
|
+
"custom_attributes": custom_attributes or {},
|
|
384
|
+
"event_raw": raw or content,
|
|
385
|
+
# Always include these arrays; IRIS may require them even if empty
|
|
386
|
+
"event_assets": [],
|
|
387
|
+
"event_iocs": [],
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if asset_ids:
|
|
391
|
+
payload["event_assets"] = [int(a) for a in asset_ids]
|
|
392
|
+
if ioc_ids:
|
|
393
|
+
payload["event_iocs"] = [int(i) for i in ioc_ids]
|
|
394
|
+
|
|
395
|
+
raw_resp = self._http.post(
|
|
396
|
+
"/case/timeline/events/add",
|
|
397
|
+
json_data=payload,
|
|
398
|
+
params={"cid": cid_int},
|
|
399
|
+
)
|
|
400
|
+
return raw_resp
|
|
401
|
+
|
|
402
|
+
def list_case_timeline_events(self, case_id: str) -> List[Dict[str, Any]]:
|
|
403
|
+
"""
|
|
404
|
+
List timeline events for a case in IRIS.
|
|
405
|
+
|
|
406
|
+
Uses IRIS API endpoint: GET /case/timeline/events/list
|
|
407
|
+
"""
|
|
408
|
+
cid_int = int(case_id) if isinstance(case_id, str) and case_id.isdigit() else case_id
|
|
409
|
+
raw = self._http.get("/case/timeline/events/list", params={"cid": cid_int})
|
|
410
|
+
|
|
411
|
+
# Response structure:
|
|
412
|
+
# {"status": "...", "message": "...", "data": {"state": {...}, "timeline": [ ... ]}}
|
|
413
|
+
if isinstance(raw, dict) and "data" in raw:
|
|
414
|
+
data = raw["data"] or {}
|
|
415
|
+
timeline = data.get("timeline") or []
|
|
416
|
+
else:
|
|
417
|
+
timeline = []
|
|
418
|
+
|
|
419
|
+
return timeline
|
|
420
|
+
|
|
421
|
+
# Tasks
|
|
422
|
+
|
|
423
|
+
def add_case_task(
|
|
424
|
+
self,
|
|
425
|
+
case_id: str,
|
|
426
|
+
title: str,
|
|
427
|
+
description: str,
|
|
428
|
+
assignee: Optional[str] = None,
|
|
429
|
+
priority: str = "medium", # accepted but not sent – IRIS task API doesn't use priority
|
|
430
|
+
status: str = "pending",
|
|
431
|
+
assignees: Optional[List[str]] = None,
|
|
432
|
+
tags: Optional[List[str]] = None,
|
|
433
|
+
custom_attributes: Optional[dict] = None,
|
|
434
|
+
) -> Dict[str, Any]:
|
|
435
|
+
"""
|
|
436
|
+
Add a task to a case in IRIS.
|
|
437
|
+
|
|
438
|
+
Uses IRIS API endpoint: POST /case/tasks/add
|
|
439
|
+
as documented in the IRIS API reference v2.0.2
|
|
440
|
+
(see [Case tasks → Add a case task](https://docs.dfir-iris.org/_static/iris_api_reference_v2.0.2.html#tag/Case-tasks/operation/post-case-add-task)).
|
|
441
|
+
|
|
442
|
+
Request body:
|
|
443
|
+
{
|
|
444
|
+
"task_assignees_id": [1],
|
|
445
|
+
"task_description": "",
|
|
446
|
+
"task_status_id": 1,
|
|
447
|
+
"task_tags": "",
|
|
448
|
+
"task_title": "dummy title",
|
|
449
|
+
"custom_attributes": {}
|
|
450
|
+
}
|
|
451
|
+
"""
|
|
452
|
+
# IRIS v2.0.0+ requires cid as query parameter in URI
|
|
453
|
+
cid_int = int(case_id) if isinstance(case_id, str) and case_id.isdigit() else case_id
|
|
454
|
+
|
|
455
|
+
payload: Dict[str, Any] = {
|
|
456
|
+
"task_title": title,
|
|
457
|
+
"task_description": description,
|
|
458
|
+
"task_status_id": self._task_status_to_id(status),
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
# Build task_assignees_id (list of integers)
|
|
462
|
+
assignee_ids: List[int] = []
|
|
463
|
+
if assignees:
|
|
464
|
+
for a in assignees:
|
|
465
|
+
try:
|
|
466
|
+
assignee_ids.append(int(a))
|
|
467
|
+
except (TypeError, ValueError):
|
|
468
|
+
continue
|
|
469
|
+
elif assignee is not None:
|
|
470
|
+
try:
|
|
471
|
+
assignee_ids.append(int(assignee))
|
|
472
|
+
except (TypeError, ValueError):
|
|
473
|
+
pass
|
|
474
|
+
else:
|
|
475
|
+
# Fallback to current user (ID 1) if no assignee is provided.
|
|
476
|
+
# This matches the official API example and ensures compatibility
|
|
477
|
+
# when the caller doesn't specify an assignee explicitly.
|
|
478
|
+
assignee_ids.append(1)
|
|
479
|
+
|
|
480
|
+
if assignee_ids:
|
|
481
|
+
payload["task_assignees_id"] = assignee_ids
|
|
482
|
+
|
|
483
|
+
# task_tags is a single string; join list if provided
|
|
484
|
+
if tags:
|
|
485
|
+
payload["task_tags"] = ",".join(tags)
|
|
486
|
+
|
|
487
|
+
# custom_attributes is an arbitrary JSON object
|
|
488
|
+
if custom_attributes:
|
|
489
|
+
payload["custom_attributes"] = custom_attributes
|
|
490
|
+
|
|
491
|
+
# Call the tasks endpoint with cid as query parameter (required since v2.0.0)
|
|
492
|
+
raw = self._http.post("/case/tasks/add", json_data=payload, params={"cid": cid_int})
|
|
493
|
+
return raw
|
|
494
|
+
|
|
495
|
+
def _task_status_to_id(self, status: str) -> int:
|
|
496
|
+
"""Map task status string to IRIS status ID."""
|
|
497
|
+
status_map = {
|
|
498
|
+
"pending": 1,
|
|
499
|
+
"in_progress": 2,
|
|
500
|
+
"completed": 3,
|
|
501
|
+
"blocked": 4,
|
|
502
|
+
}
|
|
503
|
+
return status_map.get(status.lower(), 1)
|
|
504
|
+
|
|
505
|
+
def _priority_to_task_priority_id(self, priority: str) -> int:
|
|
506
|
+
"""Map priority string to IRIS priority ID."""
|
|
507
|
+
priority_map = {
|
|
508
|
+
"low": 1,
|
|
509
|
+
"medium": 2,
|
|
510
|
+
"high": 3,
|
|
511
|
+
"critical": 4,
|
|
512
|
+
}
|
|
513
|
+
return priority_map.get(priority.lower(), 2)
|
|
514
|
+
|
|
515
|
+
def list_case_tasks(self, case_id: str) -> List[Dict[str, Any]]:
|
|
516
|
+
"""
|
|
517
|
+
List tasks for a case in IRIS.
|
|
518
|
+
|
|
519
|
+
Uses IRIS API endpoint: GET /case/tasks/list (plural)
|
|
520
|
+
"""
|
|
521
|
+
cid_int = int(case_id) if isinstance(case_id, str) and case_id.isdigit() else case_id
|
|
522
|
+
raw_list = self._http.get("/case/tasks/list", params={"cid": cid_int})
|
|
523
|
+
|
|
524
|
+
if isinstance(raw_list, list):
|
|
525
|
+
return raw_list
|
|
526
|
+
elif isinstance(raw_list, dict) and "data" in raw_list:
|
|
527
|
+
return raw_list["data"]
|
|
528
|
+
else:
|
|
529
|
+
return []
|
|
530
|
+
|
|
531
|
+
def update_case_task_status(
|
|
532
|
+
self,
|
|
533
|
+
case_id: str,
|
|
534
|
+
task_id: str,
|
|
535
|
+
status: str,
|
|
536
|
+
) -> Dict[str, Any]:
|
|
537
|
+
"""
|
|
538
|
+
Update the status of a task in a case.
|
|
539
|
+
|
|
540
|
+
Uses IRIS API endpoint: POST /case/tasks/update/{task_id}
|
|
541
|
+
as documented in the IRIS API reference v2.0.2
|
|
542
|
+
|
|
543
|
+
Endpoint format: /case/tasks/update/{task_id}
|
|
544
|
+
Query parameter: cid (case ID) - required for v2.0.0+
|
|
545
|
+
Request body:
|
|
546
|
+
{
|
|
547
|
+
"task_status_id": 2,
|
|
548
|
+
"task_assignees_id": [1], # Include existing assignees to avoid validation errors
|
|
549
|
+
...
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
Note: task_id is in the path, not in the request body.
|
|
553
|
+
|
|
554
|
+
To avoid server-side validation issues with deprecated fields, we fetch
|
|
555
|
+
the current task first to get its assignees, then include them in the update.
|
|
556
|
+
"""
|
|
557
|
+
cid_int = int(case_id) if isinstance(case_id, str) and case_id.isdigit() else case_id
|
|
558
|
+
task_id_int = int(task_id) if isinstance(task_id, str) and task_id.isdigit() else task_id
|
|
559
|
+
|
|
560
|
+
# IRIS API requires certain fields to be present in update requests.
|
|
561
|
+
# Even though the docs say fields are optional, the server may require
|
|
562
|
+
# task_title and other fields. We need to fetch the current task first
|
|
563
|
+
# to include all required fields in the update.
|
|
564
|
+
|
|
565
|
+
# Fetch current task using GET /case/tasks/{task_id} endpoint
|
|
566
|
+
current_task_data: Optional[Dict[str, Any]] = None
|
|
567
|
+
try:
|
|
568
|
+
endpoint = f"/case/tasks/{task_id_int}"
|
|
569
|
+
task_response = self._http.get(endpoint, params={"cid": cid_int})
|
|
570
|
+
# The GET endpoint returns the task data directly (not wrapped in "data" field)
|
|
571
|
+
if isinstance(task_response, dict):
|
|
572
|
+
# Check if it's wrapped in "data" field (some endpoints do this)
|
|
573
|
+
if "data" in task_response and isinstance(task_response["data"], dict):
|
|
574
|
+
current_task_data = task_response["data"]
|
|
575
|
+
# Otherwise, the response is the task data directly
|
|
576
|
+
elif "id" in task_response or "task_title" in task_response:
|
|
577
|
+
current_task_data = task_response
|
|
578
|
+
except Exception:
|
|
579
|
+
# If fetching task fails, we'll try a minimal update
|
|
580
|
+
pass
|
|
581
|
+
|
|
582
|
+
# Build payload with required fields from current task
|
|
583
|
+
payload: Dict[str, Any] = {
|
|
584
|
+
"task_status_id": self._task_status_to_id(status),
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
# Include existing task fields to satisfy API requirements
|
|
588
|
+
if current_task_data:
|
|
589
|
+
# Include task_title (required by server even if docs say optional)
|
|
590
|
+
if "task_title" in current_task_data:
|
|
591
|
+
payload["task_title"] = current_task_data["task_title"]
|
|
592
|
+
|
|
593
|
+
# Include task_description if present
|
|
594
|
+
if "task_description" in current_task_data:
|
|
595
|
+
payload["task_description"] = current_task_data.get("task_description", "")
|
|
596
|
+
|
|
597
|
+
# Include task_tags if present
|
|
598
|
+
if "task_tags" in current_task_data:
|
|
599
|
+
payload["task_tags"] = current_task_data.get("task_tags", "")
|
|
600
|
+
|
|
601
|
+
# Extract and include assignees
|
|
602
|
+
assignees = current_task_data.get('task_assignees', [])
|
|
603
|
+
assignee_ids: List[int] = []
|
|
604
|
+
if assignees and isinstance(assignees, list):
|
|
605
|
+
for assignee in assignees:
|
|
606
|
+
if isinstance(assignee, dict):
|
|
607
|
+
assignee_id = assignee.get('id')
|
|
608
|
+
elif isinstance(assignee, (int, str)):
|
|
609
|
+
assignee_id = assignee
|
|
610
|
+
else:
|
|
611
|
+
continue
|
|
612
|
+
if assignee_id:
|
|
613
|
+
try:
|
|
614
|
+
assignee_ids.append(int(assignee_id))
|
|
615
|
+
except (TypeError, ValueError):
|
|
616
|
+
continue
|
|
617
|
+
|
|
618
|
+
# If no assignees found, default to user 1 (matches add_case_task behavior)
|
|
619
|
+
if not assignee_ids:
|
|
620
|
+
assignee_ids = [1]
|
|
621
|
+
|
|
622
|
+
payload["task_assignees_id"] = assignee_ids
|
|
623
|
+
|
|
624
|
+
# Include custom_attributes if present
|
|
625
|
+
if "custom_attributes" in current_task_data:
|
|
626
|
+
payload["custom_attributes"] = current_task_data.get("custom_attributes", {})
|
|
627
|
+
else:
|
|
628
|
+
# If we couldn't fetch the task, use minimal required fields
|
|
629
|
+
# Default to user 1 for assignees (matches add_case_task behavior)
|
|
630
|
+
payload["task_assignees_id"] = [1]
|
|
631
|
+
# Note: This may fail if task_title is required, but we'll try anyway
|
|
632
|
+
|
|
633
|
+
# Call the tasks update endpoint with task_id in path and cid as query parameter
|
|
634
|
+
endpoint = f"/case/tasks/update/{task_id_int}"
|
|
635
|
+
# Debug: log the payload being sent (remove in production if needed)
|
|
636
|
+
# import logging; logging.getLogger(__name__).debug(f"Updating task {task_id_int} with payload: {payload}")
|
|
637
|
+
raw = self._http.post(endpoint, json_data=payload, params={"cid": cid_int})
|
|
638
|
+
return raw
|
|
639
|
+
|
|
640
|
+
# Assets
|
|
641
|
+
|
|
642
|
+
def add_case_asset(
|
|
643
|
+
self,
|
|
644
|
+
case_id: str,
|
|
645
|
+
asset_name: str,
|
|
646
|
+
asset_type: str,
|
|
647
|
+
description: Optional[str] = None,
|
|
648
|
+
ip_address: Optional[str] = None,
|
|
649
|
+
hostname: Optional[str] = None,
|
|
650
|
+
tags: Optional[List[str]] = None,
|
|
651
|
+
asset_domain: Optional[str] = None,
|
|
652
|
+
compromise_status_id: Optional[int] = None,
|
|
653
|
+
analysis_status_id: Optional[int] = None,
|
|
654
|
+
custom_attributes: Optional[Dict[str, Any]] = None,
|
|
655
|
+
) -> Dict[str, Any]:
|
|
656
|
+
"""
|
|
657
|
+
Add an asset to a case in IRIS.
|
|
658
|
+
|
|
659
|
+
Uses IRIS API endpoint: POST /case/assets/add (plural)
|
|
660
|
+
Note: IRIS v2.0.0+ requires cid parameter and asset_type_id (integer) instead of asset_type (string)
|
|
661
|
+
"""
|
|
662
|
+
# Map asset_type string to asset_type_id integer
|
|
663
|
+
asset_type_id = self._asset_type_to_id(asset_type)
|
|
664
|
+
cid_int = int(case_id) if isinstance(case_id, str) and case_id.isdigit() else case_id
|
|
665
|
+
|
|
666
|
+
# Build payload according to IRIS API v2.0.2 (Case assets → Add a new asset)
|
|
667
|
+
# See: https://docs.dfir-iris.org/_static/iris_api_reference_v2.0.2.html#tag/Case-assets/operation/post-case-asset-add
|
|
668
|
+
payload: Dict[str, Any] = {
|
|
669
|
+
"asset_type_id": str(asset_type_id),
|
|
670
|
+
"asset_name": asset_name,
|
|
671
|
+
"asset_description": description or "",
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if asset_domain:
|
|
675
|
+
payload["asset_domain"] = asset_domain
|
|
676
|
+
if ip_address:
|
|
677
|
+
payload["asset_ip"] = ip_address
|
|
678
|
+
# Optional informational field
|
|
679
|
+
payload["asset_info"] = ""
|
|
680
|
+
|
|
681
|
+
# Default compromise / analysis status if not provided
|
|
682
|
+
payload["asset_compromise_status_id"] = str(compromise_status_id or 1)
|
|
683
|
+
payload["analysis_status_id"] = str(analysis_status_id or 3)
|
|
684
|
+
|
|
685
|
+
# No IOC links by default
|
|
686
|
+
payload["ioc_links"] = []
|
|
687
|
+
|
|
688
|
+
if tags:
|
|
689
|
+
payload["asset_tags"] = ",".join(tags)
|
|
690
|
+
|
|
691
|
+
# custom_attributes is an arbitrary JSON object
|
|
692
|
+
payload["custom_attributes"] = custom_attributes or {}
|
|
693
|
+
|
|
694
|
+
# IRIS v2.0.0+ requires cid as query parameter in URI (per API documentation)
|
|
695
|
+
raw = self._http.post("/case/assets/add", json_data=payload, params={"cid": cid_int})
|
|
696
|
+
return raw
|
|
697
|
+
|
|
698
|
+
def _asset_type_to_id(self, asset_type: str) -> int:
|
|
699
|
+
"""Map asset type string to IRIS asset_type_id."""
|
|
700
|
+
# IRIS asset types: 1=Account, 2=Host, 3=Network, 4=Other
|
|
701
|
+
# Map common types to IRIS IDs
|
|
702
|
+
type_map = {
|
|
703
|
+
"endpoint": 2, # Host
|
|
704
|
+
"server": 2, # Host
|
|
705
|
+
"host": 2, # Host
|
|
706
|
+
"network": 3, # Network
|
|
707
|
+
"user_account": 1, # Account
|
|
708
|
+
"account": 1, # Account
|
|
709
|
+
"application": 4, # Other
|
|
710
|
+
"other": 4, # Other
|
|
711
|
+
}
|
|
712
|
+
return type_map.get(asset_type.lower(), 2) # Default to Host
|
|
713
|
+
|
|
714
|
+
def list_case_assets(self, case_id: str) -> List[Dict[str, Any]]:
|
|
715
|
+
"""
|
|
716
|
+
List assets for a case in IRIS.
|
|
717
|
+
|
|
718
|
+
Uses IRIS API endpoint: GET /case/assets/list (plural)
|
|
719
|
+
"""
|
|
720
|
+
cid_int = int(case_id) if isinstance(case_id, str) and case_id.isdigit() else case_id
|
|
721
|
+
raw_list = self._http.get("/case/assets/list", params={"cid": cid_int})
|
|
722
|
+
|
|
723
|
+
if isinstance(raw_list, list):
|
|
724
|
+
return raw_list
|
|
725
|
+
elif isinstance(raw_list, dict) and "data" in raw_list:
|
|
726
|
+
return raw_list["data"]
|
|
727
|
+
else:
|
|
728
|
+
return []
|
|
729
|
+
|
|
730
|
+
# Evidence
|
|
731
|
+
|
|
732
|
+
def list_evidence_types(self) -> List[Dict[str, Any]]:
|
|
733
|
+
"""
|
|
734
|
+
List evidence types supported by IRIS.
|
|
735
|
+
|
|
736
|
+
Uses IRIS API endpoint: GET /manage/evidence-types/list
|
|
737
|
+
"""
|
|
738
|
+
raw = self._http.get("/manage/evidence-types/list")
|
|
739
|
+
|
|
740
|
+
if isinstance(raw, list):
|
|
741
|
+
return raw
|
|
742
|
+
elif isinstance(raw, dict) and "data" in raw:
|
|
743
|
+
return raw["data"] or []
|
|
744
|
+
else:
|
|
745
|
+
return []
|
|
746
|
+
|
|
747
|
+
def _resolve_evidence_type_id(self, evidence_type: str) -> Optional[int]:
|
|
748
|
+
"""
|
|
749
|
+
Resolve an evidence type name or numeric string to a type_id.
|
|
750
|
+
Returns None if not found.
|
|
751
|
+
"""
|
|
752
|
+
# If it's already an integer string, use it directly
|
|
753
|
+
if evidence_type.isdigit():
|
|
754
|
+
return int(evidence_type)
|
|
755
|
+
|
|
756
|
+
types = self.list_evidence_types()
|
|
757
|
+
for t in types:
|
|
758
|
+
if str(t.get("name", "")).lower() == evidence_type.lower():
|
|
759
|
+
return int(t.get("id"))
|
|
760
|
+
return None
|
|
761
|
+
|
|
762
|
+
def add_case_evidence(
|
|
763
|
+
self,
|
|
764
|
+
case_id: str,
|
|
765
|
+
file_path: str,
|
|
766
|
+
description: Optional[str] = None,
|
|
767
|
+
evidence_type: Optional[str] = None,
|
|
768
|
+
custom_attributes: Optional[Dict[str, Any]] = None,
|
|
769
|
+
) -> Dict[str, Any]:
|
|
770
|
+
"""
|
|
771
|
+
Add evidence (file) to a case in IRIS.
|
|
772
|
+
|
|
773
|
+
Uses IRIS API endpoint: POST /case/evidences/add (plural)
|
|
774
|
+
Note: IRIS v2.0.0+ requires cid parameter and filename field.
|
|
775
|
+
|
|
776
|
+
Evidence types are managed by IRIS and can be listed with
|
|
777
|
+
``list_evidence_types()``. Common log-related evidence types include:
|
|
778
|
+
|
|
779
|
+
- Logs - Linux
|
|
780
|
+
- Logs - Windows EVTX
|
|
781
|
+
- Logs - Windows EVT
|
|
782
|
+
- Logs - MacOS
|
|
783
|
+
- Logs - Generic
|
|
784
|
+
- Logs - Firewall
|
|
785
|
+
- Logs - Proxy
|
|
786
|
+
- Logs - DNS
|
|
787
|
+
- Logs - Email
|
|
788
|
+
|
|
789
|
+
The ``evidence_type`` parameter MUST match one of the IRIS evidence
|
|
790
|
+
type names (or a valid numeric type_id as a string). If it does not,
|
|
791
|
+
an IntegrationError is raised listing the allowed types.
|
|
792
|
+
"""
|
|
793
|
+
import os
|
|
794
|
+
import hashlib
|
|
795
|
+
|
|
796
|
+
# Get filename and file size from file_path
|
|
797
|
+
filename = os.path.basename(file_path)
|
|
798
|
+
file_size = os.path.getsize(file_path)
|
|
799
|
+
cid_int = int(case_id) if isinstance(case_id, str) and case_id.isdigit() else case_id
|
|
800
|
+
|
|
801
|
+
# Compute a hash of the file content (SHA256). IRIS only requires a string.
|
|
802
|
+
hasher = hashlib.sha256()
|
|
803
|
+
with open(file_path, "rb") as f:
|
|
804
|
+
for chunk in iter(lambda: f.read(8192), b""):
|
|
805
|
+
hasher.update(chunk)
|
|
806
|
+
file_hash = hasher.hexdigest()
|
|
807
|
+
|
|
808
|
+
# evidence_type is required – force users to pick a valid IRIS evidence type
|
|
809
|
+
if not evidence_type:
|
|
810
|
+
raise IntegrationError(
|
|
811
|
+
"Evidence type is required for IRIS. "
|
|
812
|
+
"Call list_evidence_types() to see allowed types, e.g. "
|
|
813
|
+
"'Logs - Linux', 'Logs - Windows EVTX', 'Logs - Windows EVT', "
|
|
814
|
+
"'Logs - MacOS', 'Logs - Generic', 'Logs - Firewall', "
|
|
815
|
+
"'Logs - Proxy', 'Logs - DNS', 'Logs - Email'."
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
# Build metadata payload according to IRIS API v2.0.2 (Case evidences → Add an evidence):
|
|
819
|
+
# Required fields: filename, file_size, file_hash, file_description, custom_attributes
|
|
820
|
+
base_custom_attrs: Dict[str, Any] = custom_attributes.copy() if custom_attributes else {}
|
|
821
|
+
|
|
822
|
+
# Resolve evidence type to type_id and enforce validity
|
|
823
|
+
type_id = self._resolve_evidence_type_id(evidence_type)
|
|
824
|
+
if type_id is None:
|
|
825
|
+
types = self.list_evidence_types()
|
|
826
|
+
allowed_names = [str(t.get("name")) for t in types if t.get("name")]
|
|
827
|
+
raise IntegrationError(
|
|
828
|
+
f"Invalid evidence type '{evidence_type}'. "
|
|
829
|
+
f"Allowed types include: {', '.join(allowed_names[:20])}"
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
base_custom_attrs["evidence_type_name"] = evidence_type
|
|
833
|
+
|
|
834
|
+
additional_data = {
|
|
835
|
+
"filename": filename,
|
|
836
|
+
"file_size": str(file_size),
|
|
837
|
+
"file_hash": file_hash,
|
|
838
|
+
"file_description": description or filename,
|
|
839
|
+
"custom_attributes": base_custom_attrs,
|
|
840
|
+
"type_id": type_id,
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
# Use cid as query parameter and send JSON body as specified by the API.
|
|
844
|
+
# Note: On this IRIS version, /case/evidences/add expects application/json,
|
|
845
|
+
# not multipart form-data.
|
|
846
|
+
raw = self._http.post(
|
|
847
|
+
"/case/evidences/add",
|
|
848
|
+
json_data=additional_data,
|
|
849
|
+
params={"cid": cid_int},
|
|
850
|
+
)
|
|
851
|
+
return raw
|
|
852
|
+
|
|
853
|
+
def list_case_evidence(self, case_id: str) -> List[Dict[str, Any]]:
|
|
854
|
+
"""
|
|
855
|
+
List evidence for a case in IRIS.
|
|
856
|
+
|
|
857
|
+
Uses IRIS API endpoint: GET /case/evidences/list (plural)
|
|
858
|
+
"""
|
|
859
|
+
cid_int = int(case_id) if isinstance(case_id, str) and case_id.isdigit() else case_id
|
|
860
|
+
raw_list = self._http.get("/case/evidences/list", params={"cid": cid_int})
|
|
861
|
+
|
|
862
|
+
if isinstance(raw_list, list):
|
|
863
|
+
return raw_list
|
|
864
|
+
elif isinstance(raw_list, dict) and "data" in raw_list:
|
|
865
|
+
return raw_list["data"]
|
|
866
|
+
else:
|
|
867
|
+
return []
|
|
868
|
+
|
|
869
|
+
# Health check
|
|
870
|
+
|
|
871
|
+
def ping(self) -> bool:
|
|
872
|
+
"""
|
|
873
|
+
Check if IRIS API is reachable.
|
|
874
|
+
|
|
875
|
+
Uses IRIS API endpoint: GET /api/ping
|
|
876
|
+
"""
|
|
877
|
+
try:
|
|
878
|
+
# IRIS has a dedicated ping endpoint
|
|
879
|
+
response = self._http.get("/api/ping")
|
|
880
|
+
# Ping returns {"status": "success", "message": "pong", "data": []}
|
|
881
|
+
return True
|
|
882
|
+
except IntegrationError:
|
|
883
|
+
logger.exception("IRIS ping failed")
|
|
884
|
+
return False
|
|
885
|
+
|