15
"""
16
17
from datetime import datetime
18
+from typing import Any
19
+from typing import Dict
20
from typing import List
21
from typing import Optional
22
from typing import Tuple
23
24
from loguru import logger
25
from sqlalchemy import select
26
+from sqlalchemy import update
27
from sqlalchemy.ext.asyncio import AsyncSession
28
from sqlalchemy.orm import selectinload
29
30
from app.incidents.models import Alert
31
+from app.incidents.models import Asset
32
from app.incidents.models import Case
33
from app.incidents.models import CaseAlertLink
34
from app.incidents.models import CaseTask
51
return CaseTaskResponse(
52
id=task.id,
53
case_id=task.case_id,
54
+ alert_id=task.alert_id,
55
template_task_id=task.template_task_id,
56
title=task.title,
57
description=task.description,
88
3. ``source`` only (customer_code IS NULL), prefer is_default
89
4. Global default (both NULL, is_default=True)
90
91
+ **Conditional templates are excluded from every tier.** A template with a
92
+ ``match_field`` set is saying "fire only when this condition matches" — it
93
+ participates in selection exclusively via ``pick_templates_for_alert``. If
94
+ its condition is rejected, it must not silently re-enter via the fallback
95
+ tier; that would defeat the whole point of having a condition.
96
+
97
Returns the template with tasks eagerly loaded, or None if no match.
98
"""
99
101
stmt = (
102
select(CaseTemplate)
103
.options(selectinload(CaseTemplate.tasks))
104
+ .where(CaseTemplate.match_field.is_(None))
105
.where(customer_filter)
106
.where(source_filter)
107
.order_by(CaseTemplate.is_default.desc(), CaseTemplate.created_at.desc())
147
return None
148
149
150
+# ---------------------------------------------------------------------------
151
+# Conditional template selection (field-match against the raw Wazuh document)
152
+# ---------------------------------------------------------------------------
153
+
154
+
155
+async def _fetch_raw_event_for_alert(
156
+ alert: Alert,
157
+ session: AsyncSession,
158
+) -> Optional[Dict[str, Any]]:
159
+ """
160
+ Return the raw Wazuh document for this alert's first asset, or None if
161
+ there is no asset or the OpenSearch fetch raises.
162
+
163
+ Picks the lowest-id asset deterministically; the original-event coordinates
164
+ are typically the same across an alert's assets, but if they diverge we
165
+ prefer the asset that landed first. Talks to the Wazuh indexer directly
166
+ rather than going through ``fetch_alert_details`` because we don't want
167
+ its syslog_type validation gate — for field-match we only need a key/value
168
+ bag.
169
+ """
170
+ asset_stmt = select(Asset).where(Asset.alert_linked == alert.id).order_by(Asset.id.asc()).limit(1)
171
+ asset = (await session.execute(asset_stmt)).scalars().first()
172
+ if asset is None:
173
+ logger.info(
174
+ f"pick_templates_for_alert: alert id={alert.id} has no asset row; " "field-match templates are skipped for this alert.",
175
+ )
176
+ return None
177
+
178
+ try:
179
+ from app.connectors.wazuh_indexer.utils.universal import (
180
+ create_wazuh_indexer_client_async,
181
+ )
182
+
183
+ es_client = await create_wazuh_indexer_client_async("Wazuh-Indexer")
184
+ doc = await es_client.get(index=asset.index_name, id=asset.index_id)
185
+ return doc.get("_source") or {}
186
+ except Exception as e:
187
+ logger.warning(
188
+ f"pick_templates_for_alert: raw-event fetch failed for alert id={alert.id} "
189
+ f"(index={asset.index_name}, id={asset.index_id}): {e}. "
190
+ "Falling back to non-field-match templates.",
191
+ )
192
+ return None
193
+
194
+
195
+async def pick_templates_for_alert(
196
+ case: Case,
197
+ alert: Alert,
198
+ session: AsyncSession,
199
+) -> List[CaseTemplate]:
200
+ """
201
+ Return every CaseTemplate that should auto-apply for this (case, alert).
202
+
203
+ Two-stage selection:
204
+
205
+ 1. **Field-match templates** (both ``match_field`` and ``match_value``
206
+ set) scoped to this case's customer + alert's source are evaluated
207
+ against the raw Wazuh document fetched via the alert's first asset.
208
+ Every template whose ``document[match_field] == match_value`` is
209
+ included — they layer additively.
210
+ 2. **Fallback.** If zero field-match templates fired (no candidates, no
211
+ matches, or the raw-event fetch raised), use the existing single-
212
+ template tier picker (``customer+source > customer > source > global
213
+ default``) and return its result as a one-element list.
214
+
215
+ This split is deliberate: a generic "wazuh global" template shouldn't
216
+ drown a specific "sysmon event 1" one. Returns an empty list if nothing
217
+ applies.
218
+ """
219
+ field_match_stmt = (
220
+ select(CaseTemplate)
221
+ .options(selectinload(CaseTemplate.tasks))
222
+ .where(CaseTemplate.match_field.is_not(None))
223
+ .where(CaseTemplate.match_value.is_not(None))
224
+ .where(
225
+ (CaseTemplate.customer_code == case.customer_code) | (CaseTemplate.customer_code.is_(None)),
226
+ )
227
+ .where(
228
+ (CaseTemplate.source == alert.source) | (CaseTemplate.source.is_(None)),
229
+ )
230
+ )
231
+ candidates = list((await session.execute(field_match_stmt)).scalars().all())
232
+
233
+ matched: List[CaseTemplate] = []
234
+ if candidates:
235
+ raw_event = await _fetch_raw_event_for_alert(alert, session)
236
+ if raw_event is not None:
237
+ for tmpl in candidates:
238
+ doc_value = raw_event.get(tmpl.match_field)
239
+ if doc_value is None:
240
+ continue
241
+ # Wazuh top-level values arrive as strings already (numeric
242
+ # fields like data_win_system_eventID are quoted: "1"). Coerce
243
+ # defensively so a numeric value in the document still matches
244
+ # the string in match_value.
245
+ if str(doc_value) == tmpl.match_value:
246
+ matched.append(tmpl)
247
+
248
+ if matched:
249
+ logger.info(
250
+ f"pick_templates_for_alert: {len(matched)} field-match template(s) fired "
251
+ f"for case id={case.id} alert id={alert.id}: {[t.id for t in matched]}",
252
+ )
253
+ return matched
254
+
255
+ fallback = await pick_template_for_case(
256
+ customer_code=case.customer_code,
257
+ source=alert.source,
258
+ session=session,
259
+ )
260
+ return [fallback] if fallback is not None else []
261
+
262
+
263
# ---------------------------------------------------------------------------
264
# Template application (snapshot copy)
265
# ---------------------------------------------------------------------------
271
actor: str,
272
session: AsyncSession,
273
*,
274
+ alert_id: Optional[int] = None,
275
commit: bool = True,
276
) -> List[CaseTask]:
277
"""
282
mutate the CaseTask rows. ``template_task_id`` is preserved as a soft
283
link for analytics / future "this came from template X" UI hints.
284
285
+ When ``alert_id`` is provided, every materialized CaseTask row is
286
+ stamped with that alert id (so the Tasks UI can group tasks under
287
+ their originating alert). The caller is responsible for ensuring the
288
+ alert is linked to the case; this function trusts the caller. ``None``
289
+ produces case-wide / general tasks.
290
+
291
Set ``commit=False`` when calling from inside another transaction
292
(e.g., immediately after a Case is created in the same flow); the
293
caller is then responsible for the commit.
310
for tmpl_task in sorted(template.tasks, key=lambda t: (t.order_index, t.id)):
311
case_task = CaseTask(
312
case_id=case_id,
313
+ alert_id=alert_id,
314
template_task_id=tmpl_task.id,
315
title=tmpl_task.title,
316
description=tmpl_task.description,
336
337
await session.flush() # ensure case_task.id is populated for the audit payloads
338
339
+ template_applied_payload = payload_template_applied(
340
+ template_id=template.id,
341
+ template_name=template.name,
342
+ tasks_added=len(new_tasks),
343
+ )
344
+ if alert_id is not None:
345
+ template_applied_payload["alert_id"] = alert_id
346
+
347
await emit_case_event(
348
session=session,
349
case_id=case_id,
350
event_type=CaseEventType.TEMPLATE_APPLIED,
351
actor=actor,
211
- payload=payload_template_applied(
212
- template_id=template.id,
213
- template_name=template.name,
214
- tasks_added=len(new_tasks),
215
- ),
352
+ payload=template_applied_payload,
353
commit=False,
354
)
355
for ct in new_tasks:
364
mandatory=ct.mandatory,
365
source="template",
366
template_id=template.id,
367
+ alert_id=alert_id,
368
),
369
commit=False,
370
)
386
387
async def auto_apply_template_for_new_case(
388
case: Case,
389
+ alert: Alert,
390
actor: str,
391
session: AsyncSession,
253
- *,
254
- source_hint: Optional[str] = None,
255
-) -> Optional[Tuple[CaseTemplate, List[CaseTask]]]:
256
- """
257
- Convenience wrapper used from ``create_case_from_alert`` and
258
- ``create_case``. Picks the best matching template using the case's
259
- customer_code and an optional ``source_hint`` (the originating
260
- alert's source — first-alert-wins per Phase 3 design decision).
261
-
262
- Returns (template, tasks) on success, or None if no template
263
- matched. ``commit=False`` so the caller's transaction stays in
264
- control; caller commits after this returns.
392
+) -> List[Tuple[CaseTemplate, List[CaseTask]]]:
393
"""
266
- template = await pick_template_for_case(
267
- customer_code=case.customer_code,
268
- source=source_hint,
269
- session=session,
270
- )
271
- if template is None:
272
- return None
394
+ Run the per-alert auto-apply selection for a case and apply every
395
+ template the new ``pick_templates_for_alert`` returns.
396
274
- tasks = await apply_template_to_case(
275
- case_id=case.id,
276
- template_id=template.id,
277
- actor=actor,
278
- session=session,
279
- commit=False,
280
- )
281
- return template, tasks
397
+ Field-match templates (when present and the raw event matches) layer
398
+ additively; otherwise the legacy tier picker contributes one fallback
399
+ template. All materialized CaseTask rows are stamped with ``alert.id``
400
+ so they group under the originating alert in the UI.
401
+
402
+ Returns the list of (template, tasks) pairs applied. ``commit=False`` on
403
+ each apply call — the caller's transaction stays in control.
404
+ """
405
+ templates = await pick_templates_for_alert(case=case, alert=alert, session=session)
406
+ applied: List[Tuple[CaseTemplate, List[CaseTask]]] = []
407
+ for template in templates:
408
+ tasks = await apply_template_to_case(
409
+ case_id=case.id,
410
+ template_id=template.id,
411
+ actor=actor,
412
+ session=session,
413
+ alert_id=alert.id,
414
+ commit=False,
415
+ )
416
+ applied.append((template, tasks))
417
+ return applied
418
419
420
# ---------------------------------------------------------------------------
441
)
442
443
444
+async def is_alert_linked_to_case(
445
+ case_id: int,
446
+ alert_id: int,
447
+ session: AsyncSession,
448
+) -> bool:
449
+ """True iff a CaseAlertLink row exists for this (case, alert) pair."""
450
+ stmt = select(CaseAlertLink.alert_id).where(CaseAlertLink.case_id == case_id).where(CaseAlertLink.alert_id == alert_id).limit(1)
451
+ result = await session.execute(stmt)
452
+ return result.scalar_one_or_none() is not None
453
+
454
+
455
async def add_case_task(
456
case_id: int,
457
request: CaseTaskCreate,
458
actor: str,
459
session: AsyncSession,
460
) -> CaseTaskOperationResponse:
314
- """Add a custom task to a case mid-investigation. template_task_id is NULL."""
461
+ """Add a custom task to a case mid-investigation. template_task_id is NULL.
462
+
463
+ When ``request.alert_id`` is provided, validates the alert is currently
464
+ linked to the case — analysts shouldn't be able to attach tasks to alerts
465
+ that aren't part of the case. Otherwise the task is created as case-wide.
466
+ """
467
try:
468
case_result = await session.execute(select(Case).where(Case.id == case_id))
469
if case_result.scalar_one_or_none() is None:
473
message=f"Case id={case_id} not found",
474
)
475
476
+ if request.alert_id is not None:
477
+ if not await is_alert_linked_to_case(case_id, request.alert_id, session):
478
+ return CaseTaskOperationResponse(
479
+ task=None,
480
+ success=False,
481
+ message=(
482
+ f"Alert id={request.alert_id} is not linked to case id={case_id}; "
483
+ "link the alert first or omit alert_id for a case-wide task."
484
+ ),
485
+ )
486
+
487
task = CaseTask(
488
case_id=case_id,
489
+ alert_id=request.alert_id,
490
template_task_id=None,
491
title=request.title,
492
description=request.description,
512
title=task.title,
513
mandatory=task.mandatory,
514
source="custom",
515
+ alert_id=task.alert_id,
516
),
517
commit=False,
518
)
722
# ---------------------------------------------------------------------------
723
724
725
+async def orphan_tasks_for_alert(
726
+ case_id: int,
727
+ alert_id: int,
728
+ session: AsyncSession,
729
+ *,
730
+ commit: bool = True,
731
+) -> int:
732
+ """
733
+ Set ``alert_id = NULL`` on every CaseTask in this case that was attached
734
+ to the unlinked alert. Tasks survive (snapshot-preserving semantics) and
735
+ become case-wide / general tasks.
736
+
737
+ Returns the number of rows affected so the caller can include it in the
738
+ timeline payload for the unlink event.
739
+ """
740
+ stmt = (
741
+ update(CaseTask)
742
+ .where(CaseTask.case_id == case_id)
743
+ .where(CaseTask.alert_id == alert_id)
744
+ .values(alert_id=None, updated_at=datetime.utcnow())
745
+ )
746
+ result = await session.execute(stmt)
747
+ affected = result.rowcount or 0
748
+ if commit:
749
+ await session.commit()
750
+ if affected:
751
+ logger.info(
752
+ f"Orphaned {affected} task(s) on case id={case_id} when alert id={alert_id} was unlinked",
753
+ )
754
+ return affected
755
+
756
+
757
async def get_first_alert_source_for_case(
758
case_id: int,
759
session: AsyncSession,