fix(cortex-xdr): give a full incident its own title (1.9.0)

Title and description both read the incident's description, so every
ticket showed the same sentence twice. The title is now the rule that
fired, with "(+N other alerts)" when several are folded in, or the
incident's console name; the description adds what each distinct rule
means.

network_artifacts[0] no longer overwrites the alert's own flow: the
artifacts are the fallback, and the destination country comes from the
artifact matching the alert's remote IP. Also maps the rule description
and id, and the XDR assignee.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 14:25:22 +00:00
parent 0f5f4a900e
commit 91f6b8158b
2 changed files with 34 additions and 13 deletions
File diff suppressed because one or more lines are too long
@@ -1,14 +1,18 @@
name: "Cortex XDR Incidents (full) → OCSF" name: "Cortex XDR Incidents (full) → OCSF"
description: "Maps one Cortex XDR incident fetched with its alerts and artifacts (incidents/get_multiple_incidents_extra_data/, results_path = reply.incidents) to OCSF finding fields. The script flattens the record, so incident fields sit at the top level — every expression written against get_incidents keeps working — while alerts[], file_artifacts[] and network_artifacts[] are plain lists beside them. Incident-level values are mapped first and the first alert's equivalents last, so the alert wins wherever the aggregate says nothing." description: "Maps one Cortex XDR incident fetched with its alerts and artifacts (incidents/get_multiple_incidents_extra_data/, results_path = reply.incidents) to OCSF finding fields. The script flattens the record, so incident fields sit at the top level — every expression written against get_incidents keeps working — while alerts[], file_artifacts[] and network_artifacts[] are plain lists beside them. Incident-level values are mapped first and the first alert's equivalents last, so the alert wins wherever the aggregate says nothing."
field_mappings: field_mappings:
# incident_name is null on most tenants (it is only set when someone renames # The title is the rule that fired, the description the sentence XDR writes
# the incident), and a mapping that resolves to nothing leaves the title to # about the incident. Both used to read `description`, so a ticket showed the
# the incident type's fallback. description is the sentence XDR itself shows. # same sentence twice and its title ran to the host and user it already names.
title: "description" # incident_name wins when someone renamed the incident in the console; the
description: "description" # count says the rule is only the first of several detections folded in.
title: "jsonata: incident_name ? incident_name : alerts[0].name ? alerts[0].name & (alert_count > 1 ? ' (+' & $string(alert_count - 1) & ' other alerts)' : '') : description"
# XDR's sentence first (rule, sensor, host, user), then what each distinct
# rule means — the incident sentence names a rule without explaining it.
description: "jsonata: $join($distinct([description ? description, alerts[name and description].(name & ' — ' & description)]), '\\n\\n')"
severity: "severity" severity: "severity"
# incident_source, not incident_sources[0]: this mapper reads dotted paths and # incident_source rather than incident_sources[0], kept for the tenants whose
# cannot index a list, so the script lifts the first sensor out for it. # mapper copy predates the jsonata: prefix; the script still lifts it out.
source: "incident_source" source: "incident_source"
# results_path = reply.incidents; source_path is JSONata over ONE flattened # results_path = reply.incidents; source_path is JSONata over ONE flattened
# incident. Paths absent from a given incident are skipped at ingestion, so # incident. Paths absent from a given incident are skipped at ingestion, so
@@ -17,13 +21,17 @@ field_mappings:
ocsf: ocsf:
# ── Finding ─────────────────────────────────────────────────────── # ── Finding ───────────────────────────────────────────────────────
- { source_path: "incident_id", ocsf_field: "finding_info.uid" } - { source_path: "incident_id", ocsf_field: "finding_info.uid" }
- { source_path: "incident_name ? incident_name : description", ocsf_field: "finding_info.title" } - { source_path: "incident_name ? incident_name : alerts[0].name ? alerts[0].name : description", ocsf_field: "finding_info.title" }
- { source_path: "description", ocsf_field: "finding_info.desc" } - { source_path: "description", ocsf_field: "finding_info.desc" }
- { source_path: "creation_time", ocsf_field: "finding_info.created_time" } - { source_path: "creation_time", ocsf_field: "finding_info.created_time" }
- { source_path: "modification_time", ocsf_field: "finding_info.modified_time" } - { source_path: "modification_time", ocsf_field: "finding_info.modified_time" }
- { source_path: "xdr_url", ocsf_field: "finding_info.src_url" } - { source_path: "xdr_url", ocsf_field: "finding_info.src_url" }
- { source_path: "alert_categories[0]", ocsf_field: "finding_info.analytic.category" } - { source_path: "alert_categories[0]", ocsf_field: "finding_info.analytic.category" }
- { source_path: "alerts[0].name", ocsf_field: "finding_info.analytic.name" } - { source_path: "alerts[0].name", ocsf_field: "finding_info.analytic.name" }
# What the rule means, and its id — the handle for an exclusion or a tuning
# ticket, which the rule name alone does not give.
- { source_path: "alerts[0].description", ocsf_field: "finding_info.analytic.desc" }
- { source_path: "alerts[0].matching_service_rule_id", ocsf_field: "finding_info.analytic.uid" }
# The detection that opened the incident, so an analyst reading the aggregate # The detection that opened the incident, so an analyst reading the aggregate
# can still pivot to the single alert in the XDR console. # can still pivot to the single alert in the XDR console.
- { source_path: "alerts[0].alert_id", ocsf_field: "finding_info.uid_alt" } - { source_path: "alerts[0].alert_id", ocsf_field: "finding_info.uid_alt" }
@@ -41,6 +49,9 @@ ocsf:
- { source_path: "alert_count", ocsf_field: "count" } - { source_path: "alert_count", ocsf_field: "count" }
- { source_path: "aggregated_score", ocsf_field: "risk_score" } - { source_path: "aggregated_score", ocsf_field: "risk_score" }
- { source_path: "tags", ocsf_field: "metadata.labels" } - { source_path: "tags", ocsf_field: "metadata.labels" }
# Who already holds the incident on the XDR side, so nobody picks it up twice.
- { source_path: "assigned_user_pretty_name", ocsf_field: "assignee.name" }
- { source_path: "assigned_user_mail", ocsf_field: "assignee.email_addr" }
- { source_path: "alerts[0].action_pretty", ocsf_field: "action" } - { source_path: "alerts[0].action_pretty", ocsf_field: "action" }
# What kind of activity the sensor saw ("Process Execution", "Network # What kind of activity the sensor saw ("Process Execution", "Network
# Connection"…). It is what tells an analyst, before opening anything, whether # Connection"…). It is what tells an analyst, before opening anything, whether
@@ -216,16 +227,28 @@ ocsf:
- { source_path: "alerts[0].action_registry_data", ocsf_field: "reg_value.data" } - { source_path: "alerts[0].action_registry_data", ocsf_field: "reg_value.data" }
- { source_path: "alerts[0].dns_query_name", ocsf_field: "dns_query.hostname" } - { source_path: "alerts[0].dns_query_name", ocsf_field: "dns_query.hostname" }
- { source_path: "alerts[0].user_agent", ocsf_field: "http_request.user_agent" } - { source_path: "alerts[0].user_agent", ocsf_field: "http_request.user_agent" }
# The network artifacts come first, as a fallback only: they are the
# incident's unordered set of destinations, and network_artifacts[0] used to
# come last and overwrite the alert's own flow piecemeal — an artifact's
# hostname and IP next to the alert's port, a destination the alert never
# connected to.
- { source_path: "network_artifacts[0].network_remote_ip", ocsf_field: "dst_endpoint.ip" }
- { source_path: "network_artifacts[0].network_remote_port", ocsf_field: "dst_endpoint.port" }
- { source_path: "network_artifacts[0].network_domain", ocsf_field: "dst_endpoint.hostname" }
- { source_path: "network_artifacts[0].network_country", ocsf_field: "dst_endpoint.location.country" }
- { source_path: "alerts[0].action_local_ip", ocsf_field: "src_endpoint.ip" } - { source_path: "alerts[0].action_local_ip", ocsf_field: "src_endpoint.ip" }
- { source_path: "alerts[0].action_local_port", ocsf_field: "src_endpoint.port" } - { source_path: "alerts[0].action_local_port", ocsf_field: "src_endpoint.port" }
- { source_path: "alerts[0].action_remote_ip", ocsf_field: "dst_endpoint.ip" } - { source_path: "alerts[0].action_remote_ip", ocsf_field: "dst_endpoint.ip" }
- { source_path: "alerts[0].action_remote_port", ocsf_field: "dst_endpoint.port" } - { source_path: "alerts[0].action_remote_port", ocsf_field: "dst_endpoint.port" }
- { source_path: "alerts[0].action_external_hostname", ocsf_field: "dst_endpoint.hostname" } - { source_path: "alerts[0].action_external_hostname", ocsf_field: "dst_endpoint.hostname" }
# The alert's action_country is UNKNOWN on most network alerts too; the
# artifact XDR built for the alert's own remote IP carries the geolocation.
- { source_path: "($ip := alerts[0].action_remote_ip; $ip ? network_artifacts[network_remote_ip = $ip][0].network_country)", ocsf_field: "dst_endpoint.location.country" }
# XDR writes the literal "UNKNOWN" rather than nothing when an alert has no # XDR writes the literal "UNKNOWN" rather than nothing when an alert has no
# network dimension at all, and mapping it would put a destination country on # network dimension at all, and mapping it would put a destination country on
# a process-execution incident that never had a destination. # a process-execution incident that never had a destination.
- { source_path: 'alerts[0].action_country != ''UNKNOWN'' ? alerts[0].action_country', ocsf_field: "dst_endpoint.location.country" } - { source_path: 'alerts[0].action_country != ''UNKNOWN'' ? alerts[0].action_country', ocsf_field: "dst_endpoint.location.country" }
# ── The artifact the incident is really about ───────────────────── # ── The file the incident is really about ─────────────────────────
# Last, because a file artifact is the incident's verdict on the file, where # Last, because a file artifact is the incident's verdict on the file, where
# the alert only reports what one detection touched. # the alert only reports what one detection touched.
- { source_path: "file_artifacts[0].file_name", ocsf_field: "file.name" } - { source_path: "file_artifacts[0].file_name", ocsf_field: "file.name" }
@@ -233,5 +256,3 @@ ocsf:
- { source_path: "file_artifacts[0].file_signature_vendor_name", ocsf_field: "file.company_name" } - { source_path: "file_artifacts[0].file_signature_vendor_name", ocsf_field: "file.company_name" }
- { source_path: "file_artifacts[0].file_signature_status", ocsf_field: "file.signature.value" } - { source_path: "file_artifacts[0].file_signature_status", ocsf_field: "file.signature.value" }
- { source_path: "file_artifacts[0].file_wildfire_verdict", ocsf_field: "malware.classifications" } - { source_path: "file_artifacts[0].file_wildfire_verdict", ocsf_field: "malware.classifications" }
- { source_path: "network_artifacts[0].network_remote_ip", ocsf_field: "dst_endpoint.ip" }
- { source_path: "network_artifacts[0].network_domain", ocsf_field: "dst_endpoint.hostname" }