From 91f6b8158b745836e8e8a338604a1a767d437665 Mon Sep 17 00:00:00 2001 From: Guillaume BOURGEOIS Date: Mon, 5 Oct 2026 14:25:22 +0000 Subject: [PATCH] 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 --- integrations/cortex-xdr/manifest.yaml | 4 +- .../mappers/get_incidents_full.yaml | 43 ++++++++++++++----- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/integrations/cortex-xdr/manifest.yaml b/integrations/cortex-xdr/manifest.yaml index 11c31c8..9472402 100644 --- a/integrations/cortex-xdr/manifest.yaml +++ b/integrations/cortex-xdr/manifest.yaml @@ -1,8 +1,8 @@ id: cortex_xdr name: Cortex XDR -version: 1.8.0 +version: 1.9.0 description: "Palo Alto Cortex XDR (public API v1) — incident and alert ingestion + write-back, endpoint isolation/scan/delete/tagging, XQL queries against the data lake, RTR scripts, hash block/allow lists, file quarantine/restore/retrieval, alert exclusions, external alert push (parsed/CEF), device-control violations, audits, distributions and RBAC/risk." -changelog: "1.8.0 — update_incident lists its status and manual_severity values as enums, so the playbook editor offers them in a drop-down and the command bar refuses a misspelt one. The status list is the API's: resolved_other joins it and resolved_auto leaves it, since that resolution is set by Cortex XDR itself. 1.7.0 — New endpoint_count command: counts endpoints matching filters without listing them. The endpoints grid already answers with reply.total_count — the number of matches, independent of the page asked for — so endpoint_count asks for a one-record page and reads it, where get-endpoints has to walk the estate 100 records at a time to reach the same figure, and only filters on four fields. Filters cover the twelve list fields the API accepts (hostname, alias, endpoint id, internal and public IP, group, platform, distribution, status, isolation, scan status, username), add first_seen/last_seen bounds taking ISO8601 or epoch ms, and leave a raw JSON `filters` escape hatch for the rest. The estate `total` comes back next to `count` so the figure reads as a proportion, and a filter too broad to mean anything shows as matching everything rather than passing for an answer. | 1.6.0 — Declare the risk tier on the 25 commands that carried none: 10 safe_write, 15 destructive. An unset risk is read as destructive by the agent approval gate, so the 10 that only annotate sat behind the same prompt as the ones that change the estate. | 1.5.0 — The full-incident mapper now carries the investigation. XDR names action_process_image_* the process an alert is actually about — the child a prevention module blocked — and it was mapped nowhere, so the single most investigable string in the payload, that process’s command line, existed only inside raw: on a Child Process Protection alert, a WScript.exe invocation on a .js file sitting in the user’s Downloads folder. It fills process.* now, the group the SentinelOne (targetProcessInfo) and Carbon Black (childproc_*) mappers already reserve for the process acted upon. The chain above it lands in process.lineage as OCSF Process objects, root-first and ancestry only, which is what lineage means in OCSF; a generation XDR leaves entirely null — os_actor on an agent-side prevention — drops out rather than showing as an empty level. Every alert’s chain also travels under a process_tree context key, deliberately not an OCSF field: process.lineage describes the ancestry of ONE process, and an XDR incident groups N alerts, each with its own chain and often a shared ancestor, which OCSF has no shape for. Nodes there carry their role because the chain is positional — nothing in the payload links a child to a parent, action_process_instance_id is null, and the *_causality_id fields name the causality GROUP rather than the node, so merging chains on a causality id fuses two distinct processes. Also mapped, all for the same reason: the actor’s pid and MD5, the causality actor’s command line (mapped in the alerts feed but not here), the endpoint’s FQDN, AD domain, MAC and OS build, the protection module that fired and the agent version, the firing alert’s id, the incident id as the correlation key both feeds join on, the activity kind, and the registry, DNS and user-agent fields the other alert kinds in this feed carry. action_country is dropped when it is the literal UNKNOWN, which XDR writes on alerts that have no network dimension at all: mapping it put a destination country on process-execution incidents that never had a destination. 1.4.0 — XQL. Four commands reach the data lake: cortex-xdr-xql-query runs a query and waits for its rows, cortex-xdr-xql-start-query and cortex-xdr-xql-get-query-results split that work in two when the query outlives a command, and cortex-xdr-xql-get-quota says what the day’s hunting has already cost — an XQL query is billed by the data it scans. Two things the API makes easy to get wrong are handled here. Past 1000 rows it stops answering with results and hands over a stream id instead: gzipped JSON lines, unpacked into rows rather than dropped on the playbook as an archive. And a query carrying no limit stage is served the tenant’s own default, so one is appended (100 rows, configurable) unless the query already has its own. Waiting is a budget, not a promise: a command is killed at 60 seconds, so a query still running comes back PENDING with its execution_id, which the results command collects afterwards — re-running the query would be charged against the quota a second time. 1.3.2 — The mappers fill the Actor group, as the HarfangLab and SentinelOne mappers already did. XDR names the process that acted actor_process_* and its account actor_effective_username, but both landed outside the actor: the process in process.*, the account in user.name — the OCSF slot for the user an event was done TO. So a Cortex tenant answered nothing to a correlation or pre-processing rule written on actor.user.name / actor.process.name, and any rule reading user.name as the victim read the attacker instead. actor_process_* now maps to actor.process.*, causality_actor_process_* to actor.process.parent_process.*, actor_effective_username to actor.user.name; process.* is left free for the process acted upon, which is what it holds in the SentinelOne (targetProcessInfo) and Carbon Black (childproc_*) mappers. On get_incidents_full the incident's impacted users[] and the first alert's user_name both wrote user.name, so the alert's actor overwrote the impacted user; they are now two fields. Rules and columns written against the old Cortex paths need repointing. 1.3.1 — get_incidents_full can watermark on modification_time (modified_after), which is what ingestion wants: an XDR incident keeps growing after creation, and a creation_time watermark fetches it once and never looks again, so every alert that joins it afterwards is lost. The full-incident fetch command is named cortex-xdr-get-incidents-full (id get_incidents_full), not cortex-xdr-fetch-incidents: sitting next to cortex-xdr-get-incidents in the command list, it now reads as what it is — the same call, everything included. A rule created against the old id must be pointed at the new one. 1.3.0 — Richer incident ingestion (get_incidents_full, on get_multiple_incidents_extra_data): incidents now arrive with their alerts in full and their file/network artifacts, where get_incidents only ever answered a 21-field summary carrying neither hosts, users, MITRE nor a single alert. Alert ingestion, and the alerts endpoint answers at last: get_alerts was sending the incidents dialect ({filters, search_from, search_to, sort}) to a grid endpoint that speaks request_data.filter_data (SEARCH_FIELD/SEARCH_TYPE/SEARCH_VALUE blocks, paging.from/to, sort as a list), and every call came back HTTP 500. Body rebuilt from the reference client, rows unwrapped out of their alert_fields envelope, severity code and the dotted status.progress key derived into readable fields. Alert ingestion: get_alerts is now a fetch command (results path reply.alerts, dedup on alert_id, incremental on source_insert_ts) with a bundled OCSF mapper and a Cortex XDR Alert incident type, so detections can be ingested alongside — or instead of — incidents. The incident mapper is fixed on the way past: its severity expression compared strings, which the mapping engine cannot do, so every ingested incident silently took the rule's default severity; it also now carries a detection anchor so MTTD is measurable. It pages past the API's 100-results-per-call ceiling, and sorts oldest-first on an incremental fetch so a truncated window drops the alerts the next poll can still see. 1.2.1 — Connection troubleshooting: the URL is normalised to the tenant host (a pasted /public_api/v1 or console path no longer breaks the call), a non-JSON reply reports the status, content type and body instead of a bare JSON parse error, missing key/key ID is caught up front, nonce and timestamp are sent in both auth modes as the reference client does, and test_connection now probes get_incidents. 1.2.0 — Incident write-back (update_incident: status/severity/assignment/resolve comment) and external alert push (insert_parsed_alerts, insert_cef_alerts). 1.1.0 — Full command coverage: added delete/alias/tag endpoints, abort scan, original alerts, script metadata/code/snippet/exec-status, file retrieval (+details), alert exclusions, device-control violations, audits, distribution url/status/create and RBAC (users, roles, groups, risk score, risky users/hosts). 1.0.0 — Initial release: incident ingestion (get_incidents) with OCSF mapper, endpoints, isolate/unisolate, scan, hash blocklist/allowlist, quarantine/restore, run script + results, alerts retrieval, distributions and action status. Standard or Advanced API authentication." +changelog: "1.9.0 — The full-incident mapper gives an incident a title of its own. Title and description both read the incident's description, so every ticket showed the same sentence twice, and the title ran to the host and user it already named. The title is now the rule that fired (the first alert's name), with '(+N other alerts)' when several detections are folded into the incident, or the incident's name when someone renamed it in the console. The description keeps XDR's sentence and adds what each distinct rule means, from the alerts' own descriptions. The network destination no longer mixes two flows: network_artifacts[0] came last and overwrote the alert's connection piecemeal — one artifact's hostname and IP next to the alert's port, a destination the alert never reached. The artifacts are now only the fallback, and the destination country is read from the artifact XDR built for the alert's own remote IP, since action_country is UNKNOWN on most network alerts. Also mapped: the rule's description and id (finding_info.analytic.desc / uid), and the XDR assignee (assignee.name, assignee.email_addr). The bundled template is replaced on update; a mapper cloned from it keeps its own expressions and needs the same change by hand. | 1.8.0 — update_incident lists its status and manual_severity values as enums, so the playbook editor offers them in a drop-down and the command bar refuses a misspelt one. The status list is the API's: resolved_other joins it and resolved_auto leaves it, since that resolution is set by Cortex XDR itself. 1.7.0 — New endpoint_count command: counts endpoints matching filters without listing them. The endpoints grid already answers with reply.total_count — the number of matches, independent of the page asked for — so endpoint_count asks for a one-record page and reads it, where get-endpoints has to walk the estate 100 records at a time to reach the same figure, and only filters on four fields. Filters cover the twelve list fields the API accepts (hostname, alias, endpoint id, internal and public IP, group, platform, distribution, status, isolation, scan status, username), add first_seen/last_seen bounds taking ISO8601 or epoch ms, and leave a raw JSON `filters` escape hatch for the rest. The estate `total` comes back next to `count` so the figure reads as a proportion, and a filter too broad to mean anything shows as matching everything rather than passing for an answer. | 1.6.0 — Declare the risk tier on the 25 commands that carried none: 10 safe_write, 15 destructive. An unset risk is read as destructive by the agent approval gate, so the 10 that only annotate sat behind the same prompt as the ones that change the estate. | 1.5.0 — The full-incident mapper now carries the investigation. XDR names action_process_image_* the process an alert is actually about — the child a prevention module blocked — and it was mapped nowhere, so the single most investigable string in the payload, that process’s command line, existed only inside raw: on a Child Process Protection alert, a WScript.exe invocation on a .js file sitting in the user’s Downloads folder. It fills process.* now, the group the SentinelOne (targetProcessInfo) and Carbon Black (childproc_*) mappers already reserve for the process acted upon. The chain above it lands in process.lineage as OCSF Process objects, root-first and ancestry only, which is what lineage means in OCSF; a generation XDR leaves entirely null — os_actor on an agent-side prevention — drops out rather than showing as an empty level. Every alert’s chain also travels under a process_tree context key, deliberately not an OCSF field: process.lineage describes the ancestry of ONE process, and an XDR incident groups N alerts, each with its own chain and often a shared ancestor, which OCSF has no shape for. Nodes there carry their role because the chain is positional — nothing in the payload links a child to a parent, action_process_instance_id is null, and the *_causality_id fields name the causality GROUP rather than the node, so merging chains on a causality id fuses two distinct processes. Also mapped, all for the same reason: the actor’s pid and MD5, the causality actor’s command line (mapped in the alerts feed but not here), the endpoint’s FQDN, AD domain, MAC and OS build, the protection module that fired and the agent version, the firing alert’s id, the incident id as the correlation key both feeds join on, the activity kind, and the registry, DNS and user-agent fields the other alert kinds in this feed carry. action_country is dropped when it is the literal UNKNOWN, which XDR writes on alerts that have no network dimension at all: mapping it put a destination country on process-execution incidents that never had a destination. 1.4.0 — XQL. Four commands reach the data lake: cortex-xdr-xql-query runs a query and waits for its rows, cortex-xdr-xql-start-query and cortex-xdr-xql-get-query-results split that work in two when the query outlives a command, and cortex-xdr-xql-get-quota says what the day’s hunting has already cost — an XQL query is billed by the data it scans. Two things the API makes easy to get wrong are handled here. Past 1000 rows it stops answering with results and hands over a stream id instead: gzipped JSON lines, unpacked into rows rather than dropped on the playbook as an archive. And a query carrying no limit stage is served the tenant’s own default, so one is appended (100 rows, configurable) unless the query already has its own. Waiting is a budget, not a promise: a command is killed at 60 seconds, so a query still running comes back PENDING with its execution_id, which the results command collects afterwards — re-running the query would be charged against the quota a second time. 1.3.2 — The mappers fill the Actor group, as the HarfangLab and SentinelOne mappers already did. XDR names the process that acted actor_process_* and its account actor_effective_username, but both landed outside the actor: the process in process.*, the account in user.name — the OCSF slot for the user an event was done TO. So a Cortex tenant answered nothing to a correlation or pre-processing rule written on actor.user.name / actor.process.name, and any rule reading user.name as the victim read the attacker instead. actor_process_* now maps to actor.process.*, causality_actor_process_* to actor.process.parent_process.*, actor_effective_username to actor.user.name; process.* is left free for the process acted upon, which is what it holds in the SentinelOne (targetProcessInfo) and Carbon Black (childproc_*) mappers. On get_incidents_full the incident's impacted users[] and the first alert's user_name both wrote user.name, so the alert's actor overwrote the impacted user; they are now two fields. Rules and columns written against the old Cortex paths need repointing. 1.3.1 — get_incidents_full can watermark on modification_time (modified_after), which is what ingestion wants: an XDR incident keeps growing after creation, and a creation_time watermark fetches it once and never looks again, so every alert that joins it afterwards is lost. The full-incident fetch command is named cortex-xdr-get-incidents-full (id get_incidents_full), not cortex-xdr-fetch-incidents: sitting next to cortex-xdr-get-incidents in the command list, it now reads as what it is — the same call, everything included. A rule created against the old id must be pointed at the new one. 1.3.0 — Richer incident ingestion (get_incidents_full, on get_multiple_incidents_extra_data): incidents now arrive with their alerts in full and their file/network artifacts, where get_incidents only ever answered a 21-field summary carrying neither hosts, users, MITRE nor a single alert. Alert ingestion, and the alerts endpoint answers at last: get_alerts was sending the incidents dialect ({filters, search_from, search_to, sort}) to a grid endpoint that speaks request_data.filter_data (SEARCH_FIELD/SEARCH_TYPE/SEARCH_VALUE blocks, paging.from/to, sort as a list), and every call came back HTTP 500. Body rebuilt from the reference client, rows unwrapped out of their alert_fields envelope, severity code and the dotted status.progress key derived into readable fields. Alert ingestion: get_alerts is now a fetch command (results path reply.alerts, dedup on alert_id, incremental on source_insert_ts) with a bundled OCSF mapper and a Cortex XDR Alert incident type, so detections can be ingested alongside — or instead of — incidents. The incident mapper is fixed on the way past: its severity expression compared strings, which the mapping engine cannot do, so every ingested incident silently took the rule's default severity; it also now carries a detection anchor so MTTD is measurable. It pages past the API's 100-results-per-call ceiling, and sorts oldest-first on an incremental fetch so a truncated window drops the alerts the next poll can still see. 1.2.1 — Connection troubleshooting: the URL is normalised to the tenant host (a pasted /public_api/v1 or console path no longer breaks the call), a non-JSON reply reports the status, content type and body instead of a bare JSON parse error, missing key/key ID is caught up front, nonce and timestamp are sent in both auth modes as the reference client does, and test_connection now probes get_incidents. 1.2.0 — Incident write-back (update_incident: status/severity/assignment/resolve comment) and external alert push (insert_parsed_alerts, insert_cef_alerts). 1.1.0 — Full command coverage: added delete/alias/tag endpoints, abort scan, original alerts, script metadata/code/snippet/exec-status, file retrieval (+details), alert exclusions, device-control violations, audits, distribution url/status/create and RBAC (users, roles, groups, risk score, risky users/hosts). 1.0.0 — Initial release: incident ingestion (get_incidents) with OCSF mapper, endpoints, isolate/unisolate, scan, hash blocklist/allowlist, quarantine/restore, run script + results, alerts retrieval, distributions and action status. Standard or Advanced API authentication." category: endpoint # Per-instance configuration. The base URL is the tenant API root, e.g. diff --git a/integrations/cortex-xdr/mappers/get_incidents_full.yaml b/integrations/cortex-xdr/mappers/get_incidents_full.yaml index f6fa5c5..d937f33 100644 --- a/integrations/cortex-xdr/mappers/get_incidents_full.yaml +++ b/integrations/cortex-xdr/mappers/get_incidents_full.yaml @@ -1,14 +1,18 @@ 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" + # The title is the rule that fired, the description the sentence XDR writes + # about the incident. Both used to read `description`, so a ticket showed the + # same sentence twice and its title ran to the host and user it already names. + # incident_name wins when someone renamed the incident in the console; the + # 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" - # 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. + # incident_source rather than incident_sources[0], kept for the tenants whose + # mapper copy predates the jsonata: prefix; the script still lifts it out. 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 @@ -17,13 +21,17 @@ field_mappings: 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: "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: "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" } + # 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 # can still pivot to the single alert in the XDR console. - { 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: "aggregated_score", ocsf_field: "risk_score" } - { 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" } # What kind of activity the sensor saw ("Process Execution", "Network # 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].dns_query_name", ocsf_field: "dns_query.hostname" } - { 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_port", ocsf_field: "src_endpoint.port" } - { 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 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 # network dimension at all, and mapping it would put a destination country on # 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" } - # ── 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 # the alert only reports what one detection touched. - { 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_status", ocsf_field: "file.signature.value" } - { 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" }