From 62157e5deee827f5e45b8edd6720b947ef240d98 Mon Sep 17 00:00:00 2001 From: Guillaume BOURGEOIS Date: Tue, 13 Oct 2026 08:43:18 +0000 Subject: [PATCH] feat(cortex-xdr): endpoint_count counts by operational status (1.10.0) The endpoints API has no filter on operational_status, so the endpoints the other filters match are read 100 at a time and counted by the script. Lets an agent reporting playbook count the agents in error, as SentinelOne's operationalStatesNin does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LaiNEwG2W5Y9fMo4qXcesr --- integrations/cortex-xdr/manifest.yaml | 5 ++-- .../cortex-xdr/scripts/endpoint_count.py | 24 +++++++++++++++++-- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/integrations/cortex-xdr/manifest.yaml b/integrations/cortex-xdr/manifest.yaml index 6d7cdcf..4b1f5e7 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.9.1 +version: 1.10.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.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." +changelog: "1.10.0 — endpoint_count takes an operational_status filter (protected, partially_protected, unprotected), so an agent reporting playbook can count the agents in error the way SentinelOne's operationalStatesNin does. The endpoints API has no filter on that field, so the endpoints the other filters match are read 100 at a time and counted by the script: one call per hundred endpoints, where the other filters cost one call. | 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. @@ -182,6 +182,7 @@ commands: isolate: { type: string, description: "Isolation state: isolated or unisolated" } scan_status: { type: string, description: "Comma-separated scan statuses (none, pending, in_progress, canceled, aborted, pending_cancellation, success, error)" } username: { type: string, description: "Comma-separated usernames logged on the endpoint" } + operational_status: { type: string, description: "Comma-separated operational statuses (protected, partially_protected, unprotected), e.g. partially_protected,unprotected for the agents in error. The API does not filter on it: the endpoints the other filters match are read 100 at a time and counted here" } first_seen_after: { type: string, description: "Lower bound on first_seen, ISO8601 or epoch ms — endpoints enrolled since that instant" } first_seen_before: { type: string, description: "Upper bound on first_seen, ISO8601 or epoch ms" } last_seen_after: { type: string, description: "Lower bound on last_seen, ISO8601 or epoch ms — endpoints seen since that instant" } diff --git a/integrations/cortex-xdr/scripts/endpoint_count.py b/integrations/cortex-xdr/scripts/endpoint_count.py index 6386c97..dd0d5af 100644 --- a/integrations/cortex-xdr/scripts/endpoint_count.py +++ b/integrations/cortex-xdr/scripts/endpoint_count.py @@ -135,14 +135,34 @@ def count(filters): return int(reply.get("total_count") or 0) +def count_operational(filters, wanted): + # The endpoints grid does not filter on operational_status, so the endpoints + # the other filters match are read 100 at a time — the API's page ceiling — + # and counted here. Costs one call per hundred endpoints. + n, start = 0, 0 + while True: + request_data = {"search_from": start, "search_to": start + 100, "filters": filters} + reply = post("/endpoints/get_endpoint/", request_data).get("reply") or {} + page = reply.get("endpoints") or [] + n += sum(1 for e in page if str(e.get("operational_status") or "").upper() in wanted) + start += len(page) + if not page or start >= int(reply.get("total_count") or 0): + return n + + def main(): inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) filters = build_filters(inputs) - result = {"count": count(filters), "filters": filters} + wanted = {x.upper() for x in csv(inputs.get("operational_status"))} + if wanted: + result = {"count": count_operational(filters, wanted), "filters": filters, + "operational_status": sorted(wanted)} + else: + result = {"count": count(filters), "filters": filters} # The fleet total is the denominator the filtered count is read against: it # turns a bare number into a proportion, and makes a filter that matched the # whole estate — because it was too broad — visible instead of plausible. - result["total"] = count([]) if filters else result["count"] + result["total"] = count([]) if filters or wanted else result["count"] print(json.dumps(result))