From 232fa2f0a6e9461142dbec7aff6383248d06c0f9 Mon Sep 17 00:00:00 2001 From: Guillaume Bourgeois Date: Mon, 28 Sep 2026 14:37:00 +0000 Subject: [PATCH] fix(sentinelone): type the assignee mutation as the console does (1.17.1) update_uam_alert_assignee declared the action id as String! and the user as ID!; the console types them ID! and Long and refused the mutation at validation (VariableTypeMismatch), so no alert was ever assigned. The user id now goes out as a JSON number, and a value that is not the numeric console id is refused with an error naming assignee_user_id. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013u8PFdkYuuPiC6JdZBV5x3 --- integrations/sentinelone/manifest.yaml | 6 +++--- .../scripts/update_uam_alert_assignee.py | 18 ++++++++++++++---- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/integrations/sentinelone/manifest.yaml b/integrations/sentinelone/manifest.yaml index 8543c3d..dc5b1dd 100644 --- a/integrations/sentinelone/manifest.yaml +++ b/integrations/sentinelone/manifest.yaml @@ -1,8 +1,8 @@ id: sentinelone name: SentinelOne -version: 1.17.0 +version: 1.17.1 description: "SentinelOne Singularity (API v2.1) — endpoint detection & response: triage threats, enrich, isolate/reconnect hosts, mitigate, scan." -changelog: "1.17.0 — The unified-alert OCSF mapper, corrected and completed against a real SentinelOne Identity alert. It read asset.type for the device type, and asset.type is UNKNOWN on every alert whose surface is not the endpoint: a domain controller the same alert called a Server was ingested as an unknown device. asset.category is read first now, with a numeric device.type_id beside it. device.ip took the IPv6 address ahead of the IPv4, so every Windows host carried a link-local fe80:: address nothing can pivot on — and which the indicator extractor mined as an IOC all the same; the v4 address wins and the v6 is kept beside it. The alert's flat file summary was written straight into observables, and an alert with no file — identity, cloud — ended up with one observable whose name and value were both the literal string N/A: the summary now fills file.name, and its hash the file.hashes entry its digest length names, only when the process block did not already describe the file. src_endpoint and dst_endpoint are oriented by the detection instead of by habit: SentinelOne fills detectionTime.attacker only when a party distinct from the asset is involved, so its presence now decides which way the pair points — attacker as the source and the asset as the destination when it is filled, the asset as the source and no destination when it is not. Mapping the attacker to dst_endpoint unconditionally announced a domain controller as the source of the Active Directory enumeration it had in fact suffered. device.* is the invariant across both branches, and the asset's full OS version moved onto it, its cloud classifier under unmapped, so the flip costs nothing. finding_info.types is a list, as OCSF and the layout generator both expect. The base event had no clock of its own — time, start_time and end_time are set from detectedAt, firstSeenAt and lastSeenAt, so a timeline no longer falls back to the moment Riposte polled. 47 entries added, 97 to 144, covering every one of the 120 fields the query fetches: severity_id and status_id, the console scope's group, the alert as its own metadata.uid, finding_info.product, rule.category, container.orchestrator, the assignee's id, and the detection-time OS name, domain and logged-in user. 23 of them sit under unmapped.* — the Azure image, instance size and resource tags, the console-side IP, the Kubernetes cluster, namespace, node and controller, the agent's connectivity, pending reboot and policy, the ticket and whether a note exists — surfaced for the page instead of being left in the raw payload. Checked by running the platform's own jsonata-go mapping engine over the real Identity alert, over a STAR alert fetched with_details, over an alert naming only the attacker's address, and over a bare alert: no expression errors on any of them, and the process chain, evidences, attacks and observables come out unchanged. 1.16.0 — count_agents takes the three version comparators missing beside the agentVersion__gt it already had: agentVersion__gte, agentVersion__lt and agentVersion__lte. Sent together they bound a range, so a fleet can be counted on a band of builds rather than only above one; alone, __lte or __lt answers what is still to be upgraded to a target build, which __gt could not ask. Each is left out of the query when empty. 1.15.0 — The incident's process-tree block now draws itself for a unified alert. It rendered the chain already, reading actor.process.file.name, but it places itself automatically only on a mapper declaring actor.process.name next to the parent's, which this one did not — so a generated layout left the block out and the chain went unseen. Mapping the process name to both, plus certSubject to the node's signer badge. Checked by running the block's own reader over the mapper's output on a real alert: explorer.exe to powershell.exe, with the host, the user, and paths and hashes on both nodes when the fetch ran with_details. 1.14.0 — OCSF mapper corrected against a real STAR alert. Its alert-level observables list was empty while every observable sat under the indicators, so the two are now appended instead of reading the alert level alone — 12 observables where the mapper saw none. The same alert had process.username null while its indicators named the user, and named its parent process without hashing it anywhere else, so actor.user.name, actor.process.parent_process.file.path and its three hashes are read from the indicator observables, with the alert's own field winning when filled in. attacks and relatedAlerts are guarded on $count rather than $exists: that alert carries attacks: null, which exists in JSONata and would have written an empty array. Verified end to end through the scheduler's own jsonata-go build, with and without with_details. 1.13.0 — get_unified_alerts takes with_details, which enriches every alert it returns with what only the per-alert view carries: the indicators behind the detection, their MITRE ATT&CK tactic and technique, the observables, the related alerts and the process user. The list nodes alone were too thin to triage on. Read in aliased batches of ten, so a default-limit fetch costs ten extra requests rather than a hundred, and merged under the list node so the richer list fields win on the fields both carry. Best effort: an alert the console will not detail keeps its list fields and the shortfall is reported in pagination.details, rather than failing a fetch that already succeeded. Off by default — a rule that does not set it sends the same queries as before. The OCSF mapper maps the new fields: evidences, finding_info.attacks, finding_info.related_events, the full observables list and the process user. 1.12.0 — get_unified_alerts takes an account_ids filter, so a multi-tenant console can be ingested one account at a time, as get_threats and get_alerts already allowed. Filtered on the scope the alert sits in now (realTime.scope.account.id), falling back to the scope it was detected in and then to a filter over the fetched alerts, the same ladder as the category; pagination.accountFilter reports which ran. 1.11.0 — Absorbs the SentinelOne SDL package, which covered the same unified (UAM) alerts from a second integration: one console, one instance, one set of credentials. get_unified_alerts gains the SDL filters — classification, attack_surface, os_type, asset_id, external_id, search_text, unassigned_only, unmitigated_only and a detectedAt window — status, severity, analyst_verdict and analytics_category take comma-separated lists, and every time input also reads a relative window ('24 hours'). Three capabilities the SDL package had and this one did not: get_unified_alert_details (indicators, their MITRE mapping, observables, related alerts), add_uam_alert_note and trigger_uam_alert_action (any console action by ID). update_uam_alert_assignee completes the status/verdict pair. update_uam_alert_status and _verdict now report a GraphQL error instead of printing it as a successful update. analytics_category filters the fetch to one analytic category, e.g. only the platform-rule alerts (analyticsCategory=Platform Rule, the console's own filter): sent as a server-side filter under either fieldId spelling, and applied to the fetched alerts if the console rejects both, rather than failing the fetch — pagination.categoryFilter reports which ran. The sort argument is likewise tried in both spellings. 1.9.0 — Every command now declares its risk tier, so the agent approval gate can tell a lookup from an action. Only test_connection and get_unified_alerts did; the other 83 were unset, and an unset risk is treated as destructive, which put get-threats and get-alerts behind the same gate as isolate-agent and run-remote-script. 39 read, 22 safe_write, 24 destructive. Destructive covers what removes detection or protection (exclusions, disabling or editing a STAR rule), what adds blocking (blocklist, firewall rule), what changes containment in either direction (isolate and reconnect both), what reboots or shuts a host down, and every delete. 1.8.0 — get_unified_alerts fetches the unified (UAM) alerts: the console-wide alert model served by the unifiedalerts GraphQL endpoint, which get_alerts (cloud-detection) does not cover. Walks the cursor to the requested limit, sorts ascending so a truncated run leaves the watermark on the oldest unseen alert, and flattens edges/node to a plain data[] so the alert rule dedups on a top-level id. Ships with an OCSF mapper (74 fields) and a 'SentinelOne Unified Alert' incident type. 1.7.1 — count_agents' isActive filter is a text field again: declared as a boolean it rendered as a checkbox, which has no empty state, so the count could never be asked for both the active and the inactive agents. Left empty the filter is not sent. 1.7.0 — count_agents takes an accountIds filter, so a multi-tenant console can be counted one account at a time. 1.6.0 — count_agents takes an isActive filter, to count the active agents apart from the inactive ones; left unset, the count still covers both. 1.5.0 — count_agents takes four more filters: agentVersion__gt to count the fleet still below a target build, networkStatuses to count only the endpoints in a given connection state, operationalStatesNin to leave states out of the count, and machineTypes to count servers apart from laptops. 1.4.0 — get_threats and get_alerts accept an optional account_ids filter (accountIds), so a multi-tenant console can be scoped to one or more accounts at fetch time. 1.3.0 — Exhaustive OCSF mappers: get_threats (38 fields) and get_alerts (58 fields) now cover device, finding, malware, actor/target process, file, registry, network, indicators and container. 1.2.4 — Re-modelled the OCSF mappers to OCSF actor/target semantics: the initiating process maps to actor.* (actor.process, actor.user), and the process/file acted upon maps to the target (process.*, file.*, user.*). 1.2.3 — Expanded the OCSF mappers (get_threats, get_alerts) with more fields: file hashes (sha1/md5), file ext/size, finding/rule ids, confidence, status, parent process and device/user identity. 1.2.2 — Added pre-built OCSF mappers (get_threats, get_alerts) for the mapper library. 1.2.1 — Added test_connection for the instance Test button. 1.2.0 — Added 13 commands: threat-analysis, threat-download-from-cloud, abort-endpoint-scan, endpoint-fetch-logs, fetch-file, get-remote-script-task-status/results, get-service-users, list-installed-singularity-marketplace-applications, update-uam-alert-status/verdict, run-powerquery and create-tag-rule (83 commands total). 1.1.0 — Command names prefixed with 'sentinelone-' (e.g. sentinelone-isolate-agent) for easier toolbox search; command IDs unchanged. 1.0.0 — Initial release: 70 commands covering agents, threats, alerts, blocklist/exclusions, IOCs, STAR rules, Deep Visibility, remote scripts, tags, firewall and network discovery based on the SentinelOne API v2.1." +changelog: "1.17.1 — update_uam_alert_assignee never assigned anything: its mutation declared the action id as String! and the user as ID!, where the console types them ID! and Long, and the console refused it at validation (VariableTypeMismatch) before touching the alert. Both now carry the console's types. The user id goes out as a number, and a value that is not the numeric console id — an email, a name — is refused with an error naming assignee_user_id instead of a GraphQL type mismatch. 1.17.0 — The unified-alert OCSF mapper, corrected and completed against a real SentinelOne Identity alert. It read asset.type for the device type, and asset.type is UNKNOWN on every alert whose surface is not the endpoint: a domain controller the same alert called a Server was ingested as an unknown device. asset.category is read first now, with a numeric device.type_id beside it. device.ip took the IPv6 address ahead of the IPv4, so every Windows host carried a link-local fe80:: address nothing can pivot on — and which the indicator extractor mined as an IOC all the same; the v4 address wins and the v6 is kept beside it. The alert's flat file summary was written straight into observables, and an alert with no file — identity, cloud — ended up with one observable whose name and value were both the literal string N/A: the summary now fills file.name, and its hash the file.hashes entry its digest length names, only when the process block did not already describe the file. src_endpoint and dst_endpoint are oriented by the detection instead of by habit: SentinelOne fills detectionTime.attacker only when a party distinct from the asset is involved, so its presence now decides which way the pair points — attacker as the source and the asset as the destination when it is filled, the asset as the source and no destination when it is not. Mapping the attacker to dst_endpoint unconditionally announced a domain controller as the source of the Active Directory enumeration it had in fact suffered. device.* is the invariant across both branches, and the asset's full OS version moved onto it, its cloud classifier under unmapped, so the flip costs nothing. finding_info.types is a list, as OCSF and the layout generator both expect. The base event had no clock of its own — time, start_time and end_time are set from detectedAt, firstSeenAt and lastSeenAt, so a timeline no longer falls back to the moment Riposte polled. 47 entries added, 97 to 144, covering every one of the 120 fields the query fetches: severity_id and status_id, the console scope's group, the alert as its own metadata.uid, finding_info.product, rule.category, container.orchestrator, the assignee's id, and the detection-time OS name, domain and logged-in user. 23 of them sit under unmapped.* — the Azure image, instance size and resource tags, the console-side IP, the Kubernetes cluster, namespace, node and controller, the agent's connectivity, pending reboot and policy, the ticket and whether a note exists — surfaced for the page instead of being left in the raw payload. Checked by running the platform's own jsonata-go mapping engine over the real Identity alert, over a STAR alert fetched with_details, over an alert naming only the attacker's address, and over a bare alert: no expression errors on any of them, and the process chain, evidences, attacks and observables come out unchanged. 1.16.0 — count_agents takes the three version comparators missing beside the agentVersion__gt it already had: agentVersion__gte, agentVersion__lt and agentVersion__lte. Sent together they bound a range, so a fleet can be counted on a band of builds rather than only above one; alone, __lte or __lt answers what is still to be upgraded to a target build, which __gt could not ask. Each is left out of the query when empty. 1.15.0 — The incident's process-tree block now draws itself for a unified alert. It rendered the chain already, reading actor.process.file.name, but it places itself automatically only on a mapper declaring actor.process.name next to the parent's, which this one did not — so a generated layout left the block out and the chain went unseen. Mapping the process name to both, plus certSubject to the node's signer badge. Checked by running the block's own reader over the mapper's output on a real alert: explorer.exe to powershell.exe, with the host, the user, and paths and hashes on both nodes when the fetch ran with_details. 1.14.0 — OCSF mapper corrected against a real STAR alert. Its alert-level observables list was empty while every observable sat under the indicators, so the two are now appended instead of reading the alert level alone — 12 observables where the mapper saw none. The same alert had process.username null while its indicators named the user, and named its parent process without hashing it anywhere else, so actor.user.name, actor.process.parent_process.file.path and its three hashes are read from the indicator observables, with the alert's own field winning when filled in. attacks and relatedAlerts are guarded on $count rather than $exists: that alert carries attacks: null, which exists in JSONata and would have written an empty array. Verified end to end through the scheduler's own jsonata-go build, with and without with_details. 1.13.0 — get_unified_alerts takes with_details, which enriches every alert it returns with what only the per-alert view carries: the indicators behind the detection, their MITRE ATT&CK tactic and technique, the observables, the related alerts and the process user. The list nodes alone were too thin to triage on. Read in aliased batches of ten, so a default-limit fetch costs ten extra requests rather than a hundred, and merged under the list node so the richer list fields win on the fields both carry. Best effort: an alert the console will not detail keeps its list fields and the shortfall is reported in pagination.details, rather than failing a fetch that already succeeded. Off by default — a rule that does not set it sends the same queries as before. The OCSF mapper maps the new fields: evidences, finding_info.attacks, finding_info.related_events, the full observables list and the process user. 1.12.0 — get_unified_alerts takes an account_ids filter, so a multi-tenant console can be ingested one account at a time, as get_threats and get_alerts already allowed. Filtered on the scope the alert sits in now (realTime.scope.account.id), falling back to the scope it was detected in and then to a filter over the fetched alerts, the same ladder as the category; pagination.accountFilter reports which ran. 1.11.0 — Absorbs the SentinelOne SDL package, which covered the same unified (UAM) alerts from a second integration: one console, one instance, one set of credentials. get_unified_alerts gains the SDL filters — classification, attack_surface, os_type, asset_id, external_id, search_text, unassigned_only, unmitigated_only and a detectedAt window — status, severity, analyst_verdict and analytics_category take comma-separated lists, and every time input also reads a relative window ('24 hours'). Three capabilities the SDL package had and this one did not: get_unified_alert_details (indicators, their MITRE mapping, observables, related alerts), add_uam_alert_note and trigger_uam_alert_action (any console action by ID). update_uam_alert_assignee completes the status/verdict pair. update_uam_alert_status and _verdict now report a GraphQL error instead of printing it as a successful update. analytics_category filters the fetch to one analytic category, e.g. only the platform-rule alerts (analyticsCategory=Platform Rule, the console's own filter): sent as a server-side filter under either fieldId spelling, and applied to the fetched alerts if the console rejects both, rather than failing the fetch — pagination.categoryFilter reports which ran. The sort argument is likewise tried in both spellings. 1.9.0 — Every command now declares its risk tier, so the agent approval gate can tell a lookup from an action. Only test_connection and get_unified_alerts did; the other 83 were unset, and an unset risk is treated as destructive, which put get-threats and get-alerts behind the same gate as isolate-agent and run-remote-script. 39 read, 22 safe_write, 24 destructive. Destructive covers what removes detection or protection (exclusions, disabling or editing a STAR rule), what adds blocking (blocklist, firewall rule), what changes containment in either direction (isolate and reconnect both), what reboots or shuts a host down, and every delete. 1.8.0 — get_unified_alerts fetches the unified (UAM) alerts: the console-wide alert model served by the unifiedalerts GraphQL endpoint, which get_alerts (cloud-detection) does not cover. Walks the cursor to the requested limit, sorts ascending so a truncated run leaves the watermark on the oldest unseen alert, and flattens edges/node to a plain data[] so the alert rule dedups on a top-level id. Ships with an OCSF mapper (74 fields) and a 'SentinelOne Unified Alert' incident type. 1.7.1 — count_agents' isActive filter is a text field again: declared as a boolean it rendered as a checkbox, which has no empty state, so the count could never be asked for both the active and the inactive agents. Left empty the filter is not sent. 1.7.0 — count_agents takes an accountIds filter, so a multi-tenant console can be counted one account at a time. 1.6.0 — count_agents takes an isActive filter, to count the active agents apart from the inactive ones; left unset, the count still covers both. 1.5.0 — count_agents takes four more filters: agentVersion__gt to count the fleet still below a target build, networkStatuses to count only the endpoints in a given connection state, operationalStatesNin to leave states out of the count, and machineTypes to count servers apart from laptops. 1.4.0 — get_threats and get_alerts accept an optional account_ids filter (accountIds), so a multi-tenant console can be scoped to one or more accounts at fetch time. 1.3.0 — Exhaustive OCSF mappers: get_threats (38 fields) and get_alerts (58 fields) now cover device, finding, malware, actor/target process, file, registry, network, indicators and container. 1.2.4 — Re-modelled the OCSF mappers to OCSF actor/target semantics: the initiating process maps to actor.* (actor.process, actor.user), and the process/file acted upon maps to the target (process.*, file.*, user.*). 1.2.3 — Expanded the OCSF mappers (get_threats, get_alerts) with more fields: file hashes (sha1/md5), file ext/size, finding/rule ids, confidence, status, parent process and device/user identity. 1.2.2 — Added pre-built OCSF mappers (get_threats, get_alerts) for the mapper library. 1.2.1 — Added test_connection for the instance Test button. 1.2.0 — Added 13 commands: threat-analysis, threat-download-from-cloud, abort-endpoint-scan, endpoint-fetch-logs, fetch-file, get-remote-script-task-status/results, get-service-users, list-installed-singularity-marketplace-applications, update-uam-alert-status/verdict, run-powerquery and create-tag-rule (83 commands total). 1.1.0 — Command names prefixed with 'sentinelone-' (e.g. sentinelone-isolate-agent) for easier toolbox search; command IDs unchanged. 1.0.0 — Initial release: 70 commands covering agents, threats, alerts, blocklist/exclusions, IOCs, STAR rules, Deep Visibility, remote scripts, tags, firewall and network discovery based on the SentinelOne API v2.1." category: endpoint # Per-instance configuration. The scripts build the API base as /web/api/v2.1. @@ -1075,7 +1075,7 @@ commands: inputs_schema: properties: alert_id: { type: string, description: "UAM alert ID (UUID)." } - assignee_user_id: { type: string, description: "Console user ID to assign the alert to (the userId returned in assignee.userId)." } + assignee_user_id: { type: string, description: "Numeric console user ID to assign the alert to (the userId returned in assignee.userId, or a user's id from the users list)." } action_id: { type: string, description: "Console action ID, only if this tenant names it something other than S1/alert/assignUser." } required: [alert_id, assignee_user_id] outputs_schema: { properties: {} } diff --git a/integrations/sentinelone/scripts/update_uam_alert_assignee.py b/integrations/sentinelone/scripts/update_uam_alert_assignee.py index 91f2bb7..cecb3c3 100644 --- a/integrations/sentinelone/scripts/update_uam_alert_assignee.py +++ b/integrations/sentinelone/scripts/update_uam_alert_assignee.py @@ -3,9 +3,13 @@ import json, os, sys, urllib.request, urllib.parse, urllib.error # Assignment rides the same alertTriggerActions mutation as the status and # verdict updates, with the assignUser payload. The action id is the console's, # and can be overridden for a tenant that names it differently. +# +# The variable types are the console's, not a guess: the action id is an ID! +# and assignUser.value a Long. Declared String! and ID!, the console rejected +# the mutation at validation (VariableTypeMismatch) before assigning anything. DEFAULT_ACTION_ID = "S1/alert/assignUser" -MUTATION = """mutation AlertTriggerActions($id: String!, $actionId: String!, $user: ID!) { +MUTATION = """mutation AlertTriggerActions($id: String!, $actionId: ID!, $user: Long) { alertTriggerActions( filter: { or: [ { and: [ { fieldId: "id", stringEqual: { value: $id } } ] } ] }, actions: [ { id: $actionId, payload: { assignUser: { value: $user } } } ] @@ -43,12 +47,18 @@ def main(): if not alert_id or not user_id: raise ValueError("alert_id and assignee_user_id are required") + # A Long is a number: an email or a display name would only be refused by + # the console with a type error that does not say which input was wrong. + if not user_id.isdigit(): + raise ValueError("assignee_user_id must be the console's numeric user id " + "(assignee.userId, or the id from list users), got " + repr(user_id)) + variables = { "id": alert_id, "actionId": str(inputs.get("action_id") or "").strip() or DEFAULT_ACTION_ID, - # The console types the user id as an ID, which accepts the number it - # returns in assignee.userId as well as its string form. - "user": int(user_id) if user_id.isdigit() else user_id, + # Sent as a JSON number. Python's int keeps the 19 digits of a console + # id exactly; it fits the Long the console declares. + "user": int(user_id), } resp = request("POST", base + "/unifiedalerts/graphql", headers, {"query": MUTATION, "variables": variables}) if resp.get("errors"):