- name
- detection-event-case-lifecycle
- description
- Load when the prompt starts with "Detection event <alert_id>" followed by the alert as JSON. Takes one detection event from the SIEM (in either the API shape or the webhook shape) to a triaged Tracecat case and a published Slack alert. You read the alert from the prompt, dedupe it, attach it to a case or create one, and stop when the alert is already published or a person has already decided. Otherwise you investigate the underlying events in the SIEM, write the case, post or refresh the Work Object card, the brief, the evidence table and the owner ask, and keep the slack_work_objects row current. Load with business-context (when that skill exists), hypothesis-driven-triage, aws-cloud-incident-response-core and case-output.
- metadata
- {"tools":["tools.aws_boto3.call_api","core.cases.search_cases","core.cases.get_case","core.cases.create_case","core.cases.update_case","core.cases.add_case_tag","core.cases.create_comment","core.table.lookup","core.table.insert_row","tools.slack_sdk.call_method","tools.slack.post_message","tools.slack.update_message","tools.slack.list_replies","tools.slack.lookup_user_by_email"]}
# Detection event case lifecycle
You own a detection event from the moment it reaches you until the team can act on it in Slack. A detection event is one alert raised by a detection rule in the SIEM. The only deterministic steps before you are the receiver, which stores each alert in the `detection_events` table, and Triage alerts, which reads the row and starts one run of you per alert. Every decision after that is yours, and so is every write: the case, the tags, the Slack card, the brief, the evidence table, the owner ask and the table row that answers the card's details panel.
Work through the stages below in order. Each stage says when to stop. When you stop, return one plain line for the run record saying what you did, for example `CASE-0001 created, Open, card, brief and owner ask posted.` or `Alert <id> already published on CASE-0001; nothing to do.`
## Fixed values
| Name | Value |
|---|---|
| Workspace case URL | `https://<your-tracecat-host>/workspaces/<workspace-id>/cases/<case_id>` |
| Triage channel | `#security-alerts`, `C0123456789` |
| Work Object table | `slack_work_objects` |
| Alert link | the alert's `resultLink` when it has one, else `<siem-alert-url>` built from the alert id |
| Time format in anything a reader sees | `17 Sep 2026, 03:47 UTC` |
AWS calls, when you need one, use `tools.aws_boto3.call_api` with `service_name`, `method_name`, `params` and `region_name`. The configured credential already lands in your organisation's security audit account through `<your-read-only-audit-role>`, so leave `role_arn` empty. Call only read methods (`get_*`, `list_*`, `describe_*`).
## 1. Read the alert
The prompt is `Detection event <alert_id>` on the first line, then the alert as one JSON object. Nothing needs fetching: the JSON is the alert. It arrives in one of two shapes. Tell them apart by the top-level `detection` key. The field names below are one SIEM's alert format: `<adapt to your SIEM>`.
| Value | API shape (no `detection` key) | Webhook shape (alert under `detection`) |
|---|---|---|
| alert id | `id` | `detection.id` |
| detection name | `detectionName` | `detection.name` |
| detection title | the detection name | `detection.displayName` |
| what the rule detects | not carried; use the name and categories | `detection.description` |
| rule id | `detectionID` | `detection.detectionID` |
| alert severity | `severity` | `detection.severity`, with `detection.riskScore` |
| raised at | `createdAt` | `detection.executedAt` |
| categories, techniques | `categories`, `mitreAttacks` | `detection.categories`, `detection.mitreAttacks`, `detection.mitreTechniques` |
| rows | `results` is a JSON **string** holding one row as an array. Parse it and pair it with `columnNames` by position to get a row object. `recordsReturned` is the row count | `detection.results` is a list of row objects; `detection.resultCount` is the count |
| query window | not carried | `detection.params.from` and `detection.params.to` |
| actor, source address | `actor` (`id`, `email`, `username`), `srcIP` | from the rows |
| link | build it from the alert id | `detection.resultLink` |
| run id | `scheduledRunID` | not carried |
A column name can repeat in `columnNames` (for example `eventName` twice). Keep the first value for each name.
The field mapping in this stage is one example. For an alert from another source (a cloud threat finding, an identity alert, an endpoint alert), map that payload to the same case fields. Leave a field empty when the payload does not have it, and say so in the case.
Read these from the alert and its row and keep them for every later stage. Column names differ by rule, so take the first that is present, and write `not recorded` when none is:
| Value | Where it comes from |
|---|---|
| id, detection name, alert severity, raised at | the table above |
| account | row `recipientAccountId`, else `userIdentity.accountId`, else an `accountId` or `account` column |
| region | row `awsRegion`, else `region`, else the first entry of a `regions` column |
| principal | row `userIdentity.arn`, else `actor.id`, else `userIdentity.principalId`, else a `username`, `user` or `email` column |
| resource | the first present of the row's request target columns (`requestParameters.roleName`, `requestParameters.bucketName`, `requestParameters.groupId`, `requestParameters.instanceId`, `requestParameters.policyArn`), else a `resource` column, else `not recorded` |
| first seen, last seen | row `first_event` and `last_event`, else `eventTime` for both, else the query window, else raised at for both |
| count | the row's own count column when it has one (`creation_count`, `count`, `events`), else the row count |
| indicator | `srcIP`, else row `sourceIPAddress`, when it is a public address and not an AWS service name; a domain column when the rule has one |
**The row is the starting point, not the evidence.** The events behind it are in the SIEM. Stage 5 reads them through the SIEM MCP over the alert's own window.
**A GuardDuty finding relayed through the SIEM.** When the detection is a GuardDuty finding passed along (the name or categories say `guardduty`, or the row carries a finding id or a finding ARN), take the finding id, region and detector id from the row and read the finding in full with `guardduty` `get_findings`, as guardduty-case-lifecycle stage 1 describes. Use it as evidence in stage 5. The case identity, tags and Slack messages stay as this skill defines them.
## 2. Name the group
The detection name is the family. It feeds the group tag, and in hypothesis-driven-triage's `hypothesis-library.md` you choose the family whose questions are closest to what the rule detects (a policy or permission change reads as `config-change`, use of credentials as `credential-use`, a command in a cluster as `k8s-operator`); when none fits, use `generic`.
The group key is `<detection name>-<account>`. With no account, use the principal's last path segment in lower case, with every character outside `a-z`, `0-9` and `-` replaced by `-`, cut to 40 characters. With neither, the group key is the detection name alone.
## 3. Find the case
1. **Same alert.** Call `core.cases.search_cases` with tags `["alert-<id>"]`, status `["new", "in_progress", "on_hold"]`, `start_time` 90 days ago, `order_by` `created_at`, `sort` `desc`, `limit` 1. It is a hit when the returned case carries that tag.
2. **Already published.** An alert does not change after it is raised. On a same-alert hit, call `core.table.lookup` on `slack_work_objects` with column `external_ref_id` and the case id. With a row, the alert is triaged and published. Return `Alert <id> already published on <short_id>; nothing to do.` and stop. With no row, an earlier run did not finish: continue on that case.
3. **Same group.** With no alert hit, search tags `["group-<group key>"]` with the same statuses, `start_time` 24 hours ago, newest first, `limit` 1. It is a hit when the case carries that tag and carries neither `owner-confirmed` nor `owner-denied`.
4. **Attach** on either hit. Call `core.cases.update_case` on that case. Set these fields:
- `finding_ids`: the existing list, with this alert id appended if absent. The field is labelled Finding IDs; for a detection event it holds alert ids.
- `first_seen`: keep the existing value; set it only when empty.
- `finding_count`, `finding_type` (the detection name), `last_seen`, `principal`, `region`, `resource`: from this alert. Leave out any that is `not recorded`.
- `payload.finding_summary`: `{source: "<siem>", detection_id, account, count, finding_ids, first_seen, last_seen, region, resource_id, severity, type}`, where `type` is the detection name and `severity` the alert severity.
5. **Create** when neither search hit. Call `core.cases.create_case` with:
- summary `Triage in progress: <detection title>`
- description `Triage in progress.`
- status `new`, severity `low`
- `create_missing_tags` true
- tags `detection-event`, `source-<siem>`, `detection-<detection name>`, `group-<group key>`, `alert-<id>`, and `account-<account>` when the row gives an account
- fields `aws_account` (only when the account is an AWS account id), `finding_count`, `finding_ids` (`[<id>]`), `finding_type`, `first_seen`, `last_seen`, `principal`, `region`, `resource`, leaving out any that is `not recorded`
- the same `payload.finding_summary`
Keep the case id, its short id (`CASE-0001`), its URL, and whether you created it or attached to it.
## 4. Stop if a person has already decided
An attached case counts as decided when either is true:
- it carries `owner-confirmed` or `owner-denied`;
- its `disposition` field is `Inconclusive`.
On a decided case, add one comment with `core.cases.create_comment` and stop:
`Alert <id> (<detection name>) was raised again with <count> events, last seen <last seen, reader format>, and was attached. A person has already decided this case (<the decided tags and disposition, comma separated>), so the description, disposition and severity were left unchanged and no new triage ran.`
Write `1 event` when the count is 1.
## 5. Investigate
Load business-context if you have that skill, then hypothesis-driven-triage and aws-cloud-incident-response-core, and follow the method there. Those skills are written around GuardDuty findings. Read "finding" as "alert", "finding type" as "detection name", and where they say to fetch the finding with boto3, read the alert you were given and the events behind it in the SIEM instead. Build the investigation record that aws-cloud-incident-response-core defines. Gather these facts yourself:
- **Every alert on the case.** This alert is in the prompt. For each other id in `finding_ids`, the facts recorded in the case description and `payload.finding_summary` are what you have; say so where they limit a claim. Triage the case, not only this alert.
- **The events behind the alert.** With the SIEM MCP, read the source records the rule matched, for the alert's principal and resource, from 15 minutes before first seen to 15 minutes after last seen. Establish what was done, whether it succeeded, from where, and with which session. The row tells you which records: an `eventSource` and `eventName` mean the account activity records. Then widen to what the same principal did in the hour around it.
- **Report times.**
- Report time is now.
- The due time is now plus 3 days; the next update is now plus 1 day.
- The activity is still happening when the last event is within 24 hours of now.
- **The alert's own indicator.** Look it up with the threat enrichment tools you have (domain first, else the address), and with an IP reputation tool for an address.
- A first-seen date within a few minutes of now means the enrichment service had never seen the indicator before this lookup. Zero detections then means no prior record, not a clean verdict.
- An alert with no domain and no public address has no external indicator. Say so rather than leaving the field blank.
- **History.** Prior cases for the same rule (`core.cases.search_cases` with tags `["detection-<detection name>"]`, 90 days) with their outcomes, and how often the same principal did the same thing over 90 days in the SIEM. A rule that fires on the same automated principal every day is a pattern to name, with its count.
- **Shared workload role sessions, only when the actor is a shared workload role.** Some organisations run many tenants or jobs through one shared role that assumes other roles on their behalf. business-context names that role and its session naming pattern when your organisation has one. When the principal is that role or a session it assumed, query the SIEM for the role's cross-account `AssumeRole` sessions from 2 minutes before first seen to 2 minutes after last seen, grouped by tenant session prefix and role. The query below shows the logic in one SQL dialect; table, column and function names are `<adapt to your SIEM>`:
```sql
SELECT substring(JSONExtractString(rawLog, 'requestParameters', 'roleSessionName'), 1, <tenant prefix length>) AS tenant_session,
JSONExtractString(rawLog, 'requestParameters', 'roleArn') AS assumed_role,
count() AS events, min(receivedAt) AS first_event, max(receivedAt) AS last_event,
arrayStringConcat(arrayMap(x -> toString(x), arraySort(groupUniqArray(20)(toMinute(receivedAt)))), ',') AS minutes_of_hour
FROM aws_cloudtrail_logs
WHERE receivedAt >= parseDateTimeBestEffort('<first seen>') - INTERVAL 2 MINUTE
AND receivedAt <= parseDateTimeBestEffort('<last seen>') + INTERVAL 2 MINUTE
AND eventName = 'AssumeRole'
AND JSONExtractString(rawLog, 'userIdentity', 'arn') LIKE '%<your-shared-workload-role>%'
GROUP BY tenant_session, assumed_role ORDER BY events DESC LIMIT 10
```
An empty result means no tenant workload assumed a role in the window. Say that. When business-context names no shared workload role, skip this check.
## 6. Decide the values the case carries
Apply these rules exactly. They come out the same way every time for the same investigation. The case severity comes from your disposition, never from the alert's own severity; the alert severity is a fact you report.
| Value | Rule |
|---|---|
| Status | Your disposition status, except that Benign with a linked email reads **Open** until the linked person answers |
| Severity | Escalate → `critical`; Benign with no linked email → `low`; everything else → `medium` |
| Reason | Your disposition reason with any trailing space or full stop removed |
| Due date field | now plus 3 days, ISO 8601 (the only place ISO is used) |
## 7. Write the case
Load case-output. Write the report and assemble the description exactly as case-output's "Case description" section lays it out, with the status line, the nine headings, the closure sentences and the Appendix Report line. case-output is written for GuardDuty findings, so read it with these substitutions, and nothing else changed:
| case-output says | For a detection event |
|---|---|
| values from guardduty-case-lifecycle stage 6 | values from stage 6 of this skill |
| the finding, the finding type, GuardDuty's own description | the alert, the detection name in inline code, and what the rule detects (the rule's description when the alert carries one, else the name and categories in plain words, nothing added) |
| the findings table, one row per finding, each id linked to the GuardDuty console | the same table with the same six columns, one row per alert on the case, each alert id linked to its alert link from "Fixed values"; the Type column holds the detection name |
| every finding ID links to the GuardDuty console | every alert ID links to its alert link |
| Appendix `Finding IDs:` | `Alert IDs:` |
| title `<Finding family in plain words> on <resource or linked person> in <deployment name>` | `<what the rule detected, in plain words> on <resource or linked person> in <deployment name>`, 60 characters or fewer |
| card `rule` row: `<service>: <what was detected>` | the same shape, from the detection, for example `IAM: policy attached to a role` |
| self-check: at least one GuardDuty console link | at least one alert link, and no GuardDuty console link unless a relayed GuardDuty finding was read |
Run case-output's self-check with those substitutions and fix anything it catches before you write.
Then call `core.cases.update_case` on the case with:
- `summary`: your case title.
- `description`: the assembled description.
- `severity`: from stage 6.
- `fields`:
- `disposition`: the status from stage 6.
- `due_date`: from stage 6.
- `linked_person`: the linked person, or empty.
- `principal`: the actor principal, else the alert's principal, else empty.
Add each tag with `core.cases.add_case_tag` and `create_if_missing` true: `detection-event`, `source-<siem>`, `detection-<detection name>`, `group-<group key>`, `alert-<id>`, and `account-<account>` when there is one. Add `escalation` too when the status is Escalate. Leave the case's other tags as they are, and never remove `escalation`.
When you attached rather than created, add one comment:
`Alert <id> (<detection name>, <first seen, reader format>) attached and the case re-triaged. Status: <disposition status> (<confidence> confidence). <reason> Case alerts: <number of alert ids>.`
## 8. Publish to Slack
Follow `slack-delivery.md` in this skill. It covers the card, the brief, the evidence table, the owner ask and the `slack_work_objects` row, both for a new case and for re-triage of one already posted.
## Boundaries
- Read-only toward AWS, always.
- Never close a case, never set it to resolved, and never remove `escalation`.
- A case you create or update carries only what your evidence supports. Containment is a decision for a person and never an action of yours.
GitHubで見る