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.
Files changed (85) hide show
  1. iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/METADATA +410 -0
  2. iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/RECORD +85 -0
  3. iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/WHEEL +5 -0
  4. iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/entry_points.txt +2 -0
  5. iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/licenses/LICENSE +21 -0
  6. iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/top_level.txt +1 -0
  7. src/__init__.py +8 -0
  8. src/ai_controller/README.md +139 -0
  9. src/ai_controller/__init__.py +12 -0
  10. src/ai_controller/agent_executor.py +596 -0
  11. src/ai_controller/cli/__init__.py +2 -0
  12. src/ai_controller/cli/main.py +243 -0
  13. src/ai_controller/session_manager.py +409 -0
  14. src/ai_controller/web/__init__.py +2 -0
  15. src/ai_controller/web/server.py +1181 -0
  16. src/ai_controller/web/static/css/README.md +102 -0
  17. src/api/__init__.py +13 -0
  18. src/api/case_management.py +271 -0
  19. src/api/edr.py +187 -0
  20. src/api/kb.py +136 -0
  21. src/api/siem.py +308 -0
  22. src/core/__init__.py +10 -0
  23. src/core/config.py +242 -0
  24. src/core/config_storage.py +684 -0
  25. src/core/dto.py +50 -0
  26. src/core/errors.py +36 -0
  27. src/core/logging.py +128 -0
  28. src/integrations/__init__.py +8 -0
  29. src/integrations/case_management/__init__.py +5 -0
  30. src/integrations/case_management/iris/__init__.py +11 -0
  31. src/integrations/case_management/iris/iris_client.py +885 -0
  32. src/integrations/case_management/iris/iris_http.py +274 -0
  33. src/integrations/case_management/iris/iris_mapper.py +263 -0
  34. src/integrations/case_management/iris/iris_models.py +128 -0
  35. src/integrations/case_management/thehive/__init__.py +8 -0
  36. src/integrations/case_management/thehive/thehive_client.py +193 -0
  37. src/integrations/case_management/thehive/thehive_http.py +147 -0
  38. src/integrations/case_management/thehive/thehive_mapper.py +190 -0
  39. src/integrations/case_management/thehive/thehive_models.py +125 -0
  40. src/integrations/cti/__init__.py +6 -0
  41. src/integrations/cti/local_tip/__init__.py +10 -0
  42. src/integrations/cti/local_tip/local_tip_client.py +90 -0
  43. src/integrations/cti/local_tip/local_tip_http.py +110 -0
  44. src/integrations/cti/opencti/__init__.py +10 -0
  45. src/integrations/cti/opencti/opencti_client.py +101 -0
  46. src/integrations/cti/opencti/opencti_http.py +418 -0
  47. src/integrations/edr/__init__.py +6 -0
  48. src/integrations/edr/elastic_defend/__init__.py +6 -0
  49. src/integrations/edr/elastic_defend/elastic_defend_client.py +351 -0
  50. src/integrations/edr/elastic_defend/elastic_defend_http.py +162 -0
  51. src/integrations/eng/__init__.py +10 -0
  52. src/integrations/eng/clickup/__init__.py +8 -0
  53. src/integrations/eng/clickup/clickup_client.py +513 -0
  54. src/integrations/eng/clickup/clickup_http.py +156 -0
  55. src/integrations/eng/github/__init__.py +8 -0
  56. src/integrations/eng/github/github_client.py +169 -0
  57. src/integrations/eng/github/github_http.py +158 -0
  58. src/integrations/eng/trello/__init__.py +8 -0
  59. src/integrations/eng/trello/trello_client.py +207 -0
  60. src/integrations/eng/trello/trello_http.py +162 -0
  61. src/integrations/kb/__init__.py +12 -0
  62. src/integrations/kb/fs_kb_client.py +313 -0
  63. src/integrations/siem/__init__.py +6 -0
  64. src/integrations/siem/elastic/__init__.py +6 -0
  65. src/integrations/siem/elastic/elastic_client.py +3319 -0
  66. src/integrations/siem/elastic/elastic_http.py +165 -0
  67. src/mcp/README.md +183 -0
  68. src/mcp/TOOLS.md +2827 -0
  69. src/mcp/__init__.py +13 -0
  70. src/mcp/__main__.py +18 -0
  71. src/mcp/agent_profiles.py +408 -0
  72. src/mcp/flow_agent_profiles.py +424 -0
  73. src/mcp/mcp_server.py +4086 -0
  74. src/mcp/rules_engine.py +487 -0
  75. src/mcp/runbook_manager.py +264 -0
  76. src/orchestrator/__init__.py +11 -0
  77. src/orchestrator/incident_workflow.py +244 -0
  78. src/orchestrator/tools_case.py +1085 -0
  79. src/orchestrator/tools_cti.py +359 -0
  80. src/orchestrator/tools_edr.py +315 -0
  81. src/orchestrator/tools_eng.py +378 -0
  82. src/orchestrator/tools_kb.py +156 -0
  83. src/orchestrator/tools_siem.py +1709 -0
  84. src/web/__init__.py +8 -0
  85. 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
+