feat(cortex-xdr): fetch incidents with their alerts, not a 21-field summary

incidents/get_incidents/ answers with a summary and nothing else: 21 fields,
no hosts, no users, no MITRE, no tags, and not one of the alerts the incident
aggregates. Ingesting through it leaves an incident whose raw payload says
almost nothing about what happened — and the shipped mapper had been written
for a richer shape than the endpoint ever returns, mapping hosts[0], users[0]
and mitre_* that simply are not in that response.

incidents/get_multiple_incidents_extra_data/ returns the same incidents with
39 fields, every alert in full — 156 fields each — and the file and network
artifacts. It is what the reference client fetches through (demisto/content,
CortexXDRIR.get_multiple_incidents_extra_data), and full_alert_fields must be
set or the nested alerts come back trimmed to a handful of fields.

Records arrive as {incident, alerts, network_artifacts, file_artifacts} with
each nested block wrapped as {total_count, data}. The script flattens them, so
every expression written against get_incidents keeps working — the summary's
21 fields are a subset of these 39 — while the alerts and artifacts land beside
them as plain lists, and their total_count says when a list is a sample rather
than the whole set. incident_sources is lifted into a scalar for the same
reason severity was on the alerts side: the incident-field mapper reads dotted
paths and cannot index a list.

get_incidents stays, for cheap polling, and now says in its description what it
does and does not carry.

The mapper maps the aggregate first and the first alert last, so the alert
fills in whatever the aggregate leaves silent — including the detection anchor,
since an XDR incident's detection_time is usually null while its alerts carry
theirs. Verified against the vendor's recorded response
(test_data/get_multiple_incidents_extra_data.json): 33 of 52 entries resolve,
severity critical lands on 5, source reads "XDR Agent", and the anchor falls
through to the alert's detection timestamp.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-18 23:41:27 +02:00
parent d06f8ea413
commit a6e1245c79
3 changed files with 275 additions and 2 deletions
@@ -0,0 +1,83 @@
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."
field_mappings:
# incident_name is null on most tenants (it is only set when someone renames
# the incident), and a mapping that resolves to nothing leaves the title to
# the incident type's fallback. description is the sentence XDR itself shows.
title: "description"
description: "description"
severity: "severity"
# incident_source, not incident_sources[0]: this mapper reads dotted paths and
# cannot index a list, so the script lifts the first sensor out for it.
source: "incident_source"
# results_path = reply.incidents; source_path is JSONata over ONE flattened
# incident. Paths absent from a given incident are skipped at ingestion, so
# entries for fields a tenant never emits are safe. Where two entries target the
# same OCSF field, the LAST non-empty one wins.
ocsf:
# ── Finding ───────────────────────────────────────────────────────
- { source_path: "incident_id", ocsf_field: "finding_info.uid" }
- { source_path: "incident_name ? incident_name : description", ocsf_field: "finding_info.title" }
- { source_path: "description", ocsf_field: "finding_info.desc" }
- { source_path: "creation_time", ocsf_field: "finding_info.created_time" }
- { source_path: "modification_time", ocsf_field: "finding_info.modified_time" }
- { source_path: "xdr_url", ocsf_field: "finding_info.src_url" }
- { source_path: "alert_categories[0]", ocsf_field: "finding_info.analytic.category" }
- { source_path: "alerts[0].name", ocsf_field: "finding_info.analytic.name" }
# ── Detection time: the MTTD anchor ───────────────────────────────
# Weakest first, strongest last. detection_time is often null on an XDR
# incident, and then the first alert's own detection timestamp is the honest
# anchor; incident creation is the last resort.
- { source_path: "creation_time", ocsf_field: "time" }
- { source_path: "alerts[0].detection_timestamp", ocsf_field: "time" }
- { source_path: "detection_time", ocsf_field: "time" }
# ── Incident state ────────────────────────────────────────────────
- { source_path: "severity", ocsf_field: "severity" }
- { source_path: "status", ocsf_field: "status" }
- { source_path: "resolve_comment", ocsf_field: "status_detail" }
- { source_path: "alert_count", ocsf_field: "count" }
- { source_path: "aggregated_score", ocsf_field: "risk_score" }
- { source_path: "tags", ocsf_field: "metadata.labels" }
- { source_path: "alerts[0].action_pretty", ocsf_field: "action" }
# ── Product identity ──────────────────────────────────────────────
- { source_path: "'Cortex XDR'", ocsf_field: "metadata.product.name" }
- { source_path: "'Palo Alto Networks'", ocsf_field: "metadata.product.vendor_name" }
- { source_path: "incident_sources[0]", ocsf_field: "metadata.log_source" }
# ── MITRE ATT&CK: the aggregate, else the first alert ─────────────
- { source_path: "mitre_tactics_ids_and_names[0]", ocsf_field: "attacks.tactic.name" }
- { source_path: "mitre_techniques_ids_and_names[0]", ocsf_field: "attacks.technique.name" }
- { source_path: "alerts[0].mitre_tactic_id_and_name[0]", ocsf_field: "attacks.tactic.name" }
- { source_path: "alerts[0].mitre_technique_id_and_name[0]", ocsf_field: "attacks.technique.name" }
# ── Affected host / user ──────────────────────────────────────────
# An incident's hosts are 'hostname:agent_id' strings; an alert names them plainly.
- { source_path: "$split(hosts[0], ':')[0]", ocsf_field: "src_endpoint.hostname" }
- { source_path: "$split(hosts[0], ':')[0]", ocsf_field: "device.hostname" }
- { source_path: "alerts[0].host_name", ocsf_field: "src_endpoint.hostname" }
- { source_path: "alerts[0].host_name", ocsf_field: "device.hostname" }
- { source_path: "alerts[0].host_ip[0]", ocsf_field: "device.ip" }
- { source_path: "alerts[0].host_ip[0]", ocsf_field: "src_endpoint.ip" }
- { source_path: "alerts[0].endpoint_id", ocsf_field: "device.uid" }
- { source_path: "alerts[0].agent_os_type", ocsf_field: "device.os.type" }
- { source_path: "users[0]", ocsf_field: "user.name" }
- { source_path: "alerts[0].user_name", ocsf_field: "user.name" }
# ── What the first alert actually saw ─────────────────────────────
- { source_path: "alerts[0].actor_process_image_name", ocsf_field: "process.name" }
- { source_path: "alerts[0].actor_process_command_line", ocsf_field: "process.cmd_line" }
- { source_path: "alerts[0].actor_process_image_path", ocsf_field: "process.path" }
- { source_path: "alerts[0].actor_process_image_sha256", ocsf_field: "process.file.hashes.sha256" }
- { source_path: "alerts[0].causality_actor_process_image_name", ocsf_field: "process.parent_process.name" }
- { source_path: "alerts[0].action_file_path", ocsf_field: "file.path" }
- { source_path: "alerts[0].action_file_name", ocsf_field: "file.name" }
- { source_path: "alerts[0].action_file_sha256", ocsf_field: "file.hashes.sha256" }
- { source_path: "alerts[0].action_file_md5", ocsf_field: "file.hashes.md5" }
- { 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_external_hostname", ocsf_field: "dst_endpoint.hostname" }
# ── The artifact the incident is really about ─────────────────────
# Last, because a file artifact is the incident's verdict on the file, where
# 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_sha256", ocsf_field: "file.hashes.sha256" }
- { 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" }