From 6f51520fb5e9675c90bd9dd6cae789afb28cb990 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 11:27:36 +0000 Subject: [PATCH] feat(sentinelone): investigate_alert, one call for an agent (1.19.0) Wraith spent ten calls on a real unified alert and still lost the host: it took the UAM asset id for an agent id and the raw agent list ran past what a tool result carries. investigate_alert reads the alert, its detail, the host's agent (by id or UUID, then name), the other alerts and threats on that host around the detection, and the file SHA1's reputation, blocklist and fleet-wide threats, summarised in one answer. The operator sheet now starts with it (budget 8). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015RhL2gy6Sx6Y3FEsyBE3mZ --- integrations/sentinelone/agents/operator.yaml | 69 ++- integrations/sentinelone/manifest.yaml | 16 +- .../sentinelone/scripts/investigate_alert.py | 409 ++++++++++++++++++ 3 files changed, 455 insertions(+), 39 deletions(-) create mode 100644 integrations/sentinelone/scripts/investigate_alert.py diff --git a/integrations/sentinelone/agents/operator.yaml b/integrations/sentinelone/agents/operator.yaml index 037fdee..7f0e247 100644 --- a/integrations/sentinelone/agents/operator.yaml +++ b/integrations/sentinelone/agents/operator.yaml @@ -6,7 +6,7 @@ handle: Wraith title: SentinelOne Singularity operator role: analyst_l2 # needs integrations.execute — viewer would hide every command approval_threshold: safe_write -max_tool_calls: 12 +max_tool_calls: 8 when_to_delegate: | Anything SentinelOne can tell about an endpoint: the threat or alert behind @@ -28,46 +28,41 @@ returns: | No verdict. instructions: | - Your budget is 12 calls. Spend it in this order: - 1. get_incident_context (1 call). Its raw fields tell what the alert is: - - a threat (threatInfo.threatId, agentRealtimeInfo.agentComputerName, - threatInfo.sha1): go to 2. - - a unified (UAM) alert (id is a UUID, asset.id, asset.name): call - get_unified_alert_details(alert_id=) instead of 2-3. - 2. threat_analysis(threat_id) (1 call): detection, file, process, agent. - 3. get_threat_notes(threat_id) (1 call): what analysts already wrote. - 4. get_activities(threats_ids=) (1 call): mitigation and - console timeline. agent_ids works the same for the host's timeline. - 5. Host: list_agents(computer_name=) (1 call) — the name, not an id. - Then get_agent_mac(agent_id) only if IPs/MACs are asked. - 6. Indicator: get_hash_verdict(hash) takes a SHA1 only (40 hex chars), - never SHA256 or MD5. get_blocklist(hash=) says if it is blocked. - 7. ALWAYS keep 1-2 calls for "elsewhere": - - get_threats(query=) — other threats with the - same hash (other hosts) or on the same host. Always give query or - threat_ids: an unfiltered call is the ingestion feed. - - for a UAM alert: get_unified_alerts(asset_id=, - detected_after="7 days", limit=50). It sorts OLDEST first, so say if - the limit was hit. Its search_text matches the alert name only. + RULE ONE. investigate_alert does the investigation in ONE call: the alert, + the host, the other alerts and threats on the host, and the file's + reputation, blocklist and fleet-wide threats. Call it first, and do not + redo with other commands what it already answered. - Ids: threat ids are long numbers (about 19 digits) and agent ids too. Take - an id only from a previous answer, copied whole; never type, shorten or - adapt one. When a command accepts a name (computer_name, query), prefer the - name. - Time: pass timestamps exactly as the data gives them (ISO8601). Never - compute dates; for a window use the relative form "24 hours" / "7 days" - where a parameter documents it. - Deep Visibility (get_events, get_processes) is not available: it needs a - query you cannot create. Do not ask for it. - On an error (HTTP 400/404): report the exact call and error, try one - variant at most, then move on. + 1. get_incident_context (1 call). In its raw fields find the alert's id: + - a unified (UAM) alert: the "id" that is a UUID (8-4-4-4-12 hex, + e.g. 01a0f703-e969-7e53-80ba-870646032581). asset.id (a 26-letter + token) and agentUuid are NOT alert ids. + - a threat: threatInfo.threatId (a number of about 19 digits). + 2. investigate_alert(alert_id=) or investigate_alert( + threat_id=) (1 call). Copy the id whole, character for + character. If it answers found: false, check the id once against the + raw fields and retry once; then report it. + 3. Only for a question the answer leaves open (at most 4 calls): + - more of a hash: get_threats(query=); + - the host's console timeline: get_activities(agent_ids=); + - a UAM alert's full indicator list: get_unified_alert_details. + get_hash_verdict takes a SHA1 only (40 hex). Never a SHA256 or MD5. - In your answer: the threat (name, classification, file path, SHA1/SHA256, - originating process and command line, user, mitigation and verdict), the - host (name, OS, IPs, last user, agent version, network status), the - timeline, and what the "elsewhere" checks found — with each fact's command. + Report, each fact with "investigate_alert" or the command that gave it: + - the alert: name, rule, severity, classification, status, verdict, + mitigation result, detection time, MITRE; + - the process: command line, parent, user, file, path, hashes, signer; + - the host: name, OS, IPs, last user, agent version, network status, site; + - elsewhere: its "window" word for word, the other alerts on the host by + name with their counts, the threats on the host, the file's reputation, + whether it is blocklisted, the threats carrying it across the fleet; + - each entry of "errors" as a check that could not be made, with its step. + Report what the answer says and nothing it does not: an empty or missing + section is "not found", never a guess. No verdict. commands: + - investigate_alert - threat_analysis - get_threat_notes - get_activities diff --git a/integrations/sentinelone/manifest.yaml b/integrations/sentinelone/manifest.yaml index 9408bb2..d4fac3d 100644 --- a/integrations/sentinelone/manifest.yaml +++ b/integrations/sentinelone/manifest.yaml @@ -1,8 +1,8 @@ id: sentinelone name: SentinelOne -version: 1.18.1 +version: 1.19.0 description: "SentinelOne Singularity (API v2.1) — endpoint detection & response: triage threats, enrich, isolate/reconnect hosts, mitigate, scan." -changelog: "1.18.0 — update_uam_alert_verdict and update_uam_alert_status declare their values as enums: the console's 20 AnalystVerdict values, and NEW, IN_PROGRESS and RESOLVED. The playbook editor offers them in a drop-down instead of a free text field where a misspelt value only failed at run time, and the command bar refuses one. 1.17.3 — A UAM update the console skips is no longer failed outright. The console skips an alert already in the requested state — a playbook assigning an alert to the analyst who already holds it, a second run of the same closure — and 1.17.2 reported that as a failure, so re-running a playbook broke on a block that had nothing left to do. The skipped alert is now read back: when it is already assigned to that user, or already carries that status or verdict, the block succeeds with already_assigned / already_set; otherwise it fails and names the state the console holds. 1.17.2 — update_uam_alert_assignee reads the alert back after assigning it. A real run came back with the alert under success while the console still showed its previous assignee, and the block passed: the mutation only says the console accepted the action. The script now reads the alert's assignee for up to seven seconds and fails when it is not the requested user, printing who the console holds instead — an id that is not the console user's shows up there rather than as a green block. The three UAM updates (assignee, status, verdict) also fail when the console lists the alert under failure or skip, or under neither: it answers those with a 200 and no errors, so they read as done updates. 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." +changelog: "1.19.0 — investigate_alert: one read-only call that investigates a SentinelOne alert for an agent. Given a unified alert's UUID or a threat id, it returns the alert summarised (rule, severity, MITRE, process, command line, file and hashes, observables, related alerts), the host's agent found from the alert itself (by agent id or UUID, then by name), the other alerts and threats on that host from 7 days before the detection to 1 day after, and the file SHA1's reputation, blocklist entries and fleet-wide threats; a threat adds its notes and console timeline. Each step is best effort and a failed one is named under errors. An operator agent had spent ten calls rebuilding this on a real alert and still lost the host: it took the UAM asset id for an agent id, and the raw agent list it fell back to ran past what a tool result carries. The Wraith operator sheet now starts with it. 1.18.0 — update_uam_alert_verdict and update_uam_alert_status declare their values as enums: the console's 20 AnalystVerdict values, and NEW, IN_PROGRESS and RESOLVED. The playbook editor offers them in a drop-down instead of a free text field where a misspelt value only failed at run time, and the command bar refuses one. 1.17.3 — A UAM update the console skips is no longer failed outright. The console skips an alert already in the requested state — a playbook assigning an alert to the analyst who already holds it, a second run of the same closure — and 1.17.2 reported that as a failure, so re-running a playbook broke on a block that had nothing left to do. The skipped alert is now read back: when it is already assigned to that user, or already carries that status or verdict, the block succeeds with already_assigned / already_set; otherwise it fails and names the state the console holds. 1.17.2 — update_uam_alert_assignee reads the alert back after assigning it. A real run came back with the alert under success while the console still showed its previous assignee, and the block passed: the mutation only says the console accepted the action. The script now reads the alert's assignee for up to seven seconds and fails when it is not the requested user, printing who the console holds instead — an id that is not the console user's shows up there rather than as a green block. The three UAM updates (assignee, status, verdict) also fail when the console lists the alert under failure or skip, or under neither: it answers those with a 200 and no errors, so they read as done updates. 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. @@ -1046,6 +1046,18 @@ commands: required: [alert_id] outputs_schema: { properties: {} } + - id: investigate_alert + name: sentinelone-investigate-alert + description: "One call to investigate a SentinelOne alert, for an agent: the alert (rule, severity, MITRE, process and command line, file and hashes, observables, related alerts), the host's agent found from the alert (OS, IPs, last user, agent version, network status, site), the other alerts and threats on that host from 7 days before the detection to 1 day after, and for the file's SHA1 its reputation, whether it is blocklisted and the threats carrying it across the fleet. A threat also gets its notes and console timeline. Summarised, no id to look up in between, no date arithmetic." + risk: read + inputs_schema: + properties: + alert_id: { type: string, description: "Unified (UAM) alert id: the UUID, copied whole from the alert's raw id. Not asset.id, not agentUuid." } + threat_id: { type: string, description: "Threat id (threatInfo.threatId, a number of about 19 digits) for a threat instead of a unified alert." } + days_before: { type: number, description: "Days before the detection the host and fleet checks cover (default 7, max 30)." } + required: [] + outputs_schema: { properties: {} } + - id: update_uam_alert_status name: sentinelone-update-uam-alert-status description: "Update the status of a unified (UAM) alert." diff --git a/integrations/sentinelone/scripts/investigate_alert.py b/integrations/sentinelone/scripts/investigate_alert.py new file mode 100644 index 0000000..f22c20a --- /dev/null +++ b/integrations/sentinelone/scripts/investigate_alert.py @@ -0,0 +1,409 @@ +import datetime, json, os, re, sys, urllib.request, urllib.parse, urllib.error + +# One call for an agent investigating a SentinelOne alert: what the alert is, +# the host behind it, and what else the console holds around it. An operator +# model spent ten calls rebuilding this and still lost the host — it took the +# UAM asset id (a 26-character token) for an agent id, and the raw agent list +# it fell back to ran past what a tool result can carry. Every step here is +# best effort and summarised: a step that fails is named in "errors" and the +# rest of the answer still comes back. + +UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") +SHA1_RE = re.compile(r"^[0-9a-fA-F]{40}$") + +# List-shape fields (the ones get_unified_alerts reads, known to every console) +# for the alert itself, fetched by an id filter. +ALERT_FIELDS = """ + id name description severity status result analystVerdict classification + confidenceLevel detectedAt firstSeenAt lastSeenAt storylineId fileName fileHash + analytics { name category } + detectionSource { product vendor engine } + asset { id name agentUuid agentVersion osType osVersion lastLoggedInUser connectivityToConsole } + detectionTime { + asset { domain ipV4 lastLoggedInUser osName } + attacker { host ip } + scope { accountName groupName siteName } + targetUser { name domain } + } + process { cmdLine parentName file { certSubject md5 name path sha1 sha256 } } +""" + +# What only the per-alert view carries. +DETAIL_FIELDS = """ + indicators { message attacks { tactic { name } technique { name uid } } } + observables { name value typeName } + relatedAlerts { id name } + process { username } +""" + +SIBLING_FIELDS = "id name severity status detectedAt classification" + + +def request(method, url, headers, body=None): + data = json.dumps(body).encode("utf-8") if body is not None else None + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=60) as resp: + raw = resp.read() + return json.loads(raw) if raw else {} + + +def trim(v, n): + s = "" if v is None else str(v) + return s if len(s) <= n else s[:n] + "…" + + +def compact(d): + """Drop the empty values, so the answer says only what the console said.""" + if isinstance(d, dict): + out = {k: compact(v) for k, v in d.items()} + return {k: v for k, v in out.items() if v not in (None, "", [], {})} + if isinstance(d, list): + return [compact(x) for x in d if x not in (None, "", [], {})] + return d + + +def parse_time(s): + try: + dt = datetime.datetime.fromisoformat(str(s).replace("Z", "+00:00")) + except (TypeError, ValueError): + return None + return dt if dt.tzinfo else dt.replace(tzinfo=datetime.timezone.utc) + + +def iso(dt): + return dt.astimezone(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z") + + +def group_count(items, key, extra=None, top=10): + """Count items per key value, most frequent first, with the latest time seen.""" + groups = {} + for it in items: + k = key(it) or "?" + g = groups.setdefault(k, {"value": k, "count": 0, "last": ""}) + g["count"] += 1 + t = extra(it) if extra else "" + if t and t > g["last"]: + g["last"] = t + out = sorted(groups.values(), key=lambda g: -g["count"])[:top] + if not extra: + for g in out: + g.pop("last") + return out + + +class Console: + def __init__(self, base, headers): + self.base, self.headers, self.errors = base, headers, [] + + def get(self, step, path, qs=None, missing=None): + """missing, when given, is what a 404 means for this step: an answer, + not a failed check.""" + url = self.base + path + ("?" + urllib.parse.urlencode({k: v for k, v in (qs or {}).items() if v not in (None, "")}) if qs else "") + try: + return request("GET", url, self.headers) + except urllib.error.HTTPError as e: + if e.code == 404 and missing is not None: + return missing + self.errors.append({"step": step, "error": "HTTP %d" % e.code, + "detail": trim(e.read().decode("utf-8", "replace"), 300)}) + except Exception as e: + self.errors.append({"step": step, "error": trim(e, 300)}) + return None + + def gql(self, step, query): + try: + resp = request("POST", self.base + "/unifiedalerts/graphql", self.headers, {"query": query}) + except urllib.error.HTTPError as e: + self.errors.append({"step": step, "error": "HTTP %d" % e.code, + "detail": trim(e.read().decode("utf-8", "replace"), 300)}) + return None + except Exception as e: + self.errors.append({"step": step, "error": trim(e, 300)}) + return None + if resp.get("errors"): + self.errors.append({"step": step, "error": "GraphQL error", + "detail": trim(json.dumps(resp["errors"]), 300)}) + return resp.get("data") or {} + + +def alerts_query(filters, fields, first): + # Both spellings of the sort argument exist across console versions; none + # is needed here, so the query carries no sort at all. + return """query Alerts { alerts(first: %d viewType: ALL filters: [ %s ]) { + totalCount edges { node { %s } } } }""" % (first, " ".join(filters), fields) + + +def summarize_agent(a): + ips = [] + for nic in a.get("networkInterfaces") or []: + for ip in nic.get("inet") or []: + if ip and ip not in ips: + ips.append(ip) + return compact({ + "computer_name": a.get("computerName"), + "agent_id": a.get("id"), + "os": a.get("osName"), + "os_revision": a.get("osRevision"), + "machine_type": a.get("machineType"), + "domain": a.get("domain"), + "ips": ips[:6], + "external_ip": a.get("externalIp"), + "last_logged_in_user": a.get("lastLoggedInUserName"), + "agent_version": a.get("agentVersion"), + "is_active": a.get("isActive"), + "network_status": a.get("networkStatus"), + "infected": a.get("infected"), + "active_threats": a.get("activeThreats"), + "last_active": a.get("lastActiveDate"), + "site": a.get("siteName"), + "group": a.get("groupName"), + "account": a.get("accountName"), + }) + + +def find_agent(con, agent_uuid=None, agent_id=None, name=None): + """The host's agent, by the most precise key the alert gave.""" + for step, qs in (("agent_by_id", {"ids": agent_id}), + ("agent_by_uuid", {"uuids": agent_uuid}), + ("agent_by_name", {"computerName": name})): + if not list(qs.values())[0]: + continue + resp = con.get(step, "/agents", dict(qs, limit=5)) + data = (resp or {}).get("data") or [] + if data: + return data[0], step + return None, None + + +def threats_summary(con, step, qs): + resp = con.get(step, "/threats", dict(qs, limit=100, sortBy="createdAt", sortOrder="desc")) + if resp is None: + return None + data = resp.get("data") or [] + total = ((resp.get("pagination") or {}).get("totalItems")) + return compact({ + "total": total if total is not None else len(data), + "by_name": group_count(data, lambda t: (t.get("threatInfo") or {}).get("threatName"), + lambda t: (t.get("threatInfo") or {}).get("createdAt") or ""), + "by_host": group_count(data, lambda t: (t.get("agentRealtimeInfo") or {}).get("agentComputerName")), + }) + + +def hash_checks(con, sha1, site_id): + out = {"sha1": sha1} + verdict = con.get("hash_verdict", "/hashes/%s/verdict" % sha1, + missing={"data": {"verdict": "unknown to SentinelOne (404)"}}) + if verdict is not None: + out["reputation"] = ((verdict.get("data") or {}).get("verdict")) or "unknown" + blocked = [] + for scope_qs in ({"tenant": "true"}, {"siteIds": site_id} if site_id else None): + if not scope_qs: + continue + resp = con.get("blocklist", "/restrictions", dict(scope_qs, type="black_hash", value__contains=sha1, limit=10)) + for r in (resp or {}).get("data") or []: + blocked.append(compact({"scope": r.get("scopeName") or r.get("scope"), "description": trim(r.get("description"), 120)})) + out["blocklisted"] = bool(blocked) + if blocked: + out["blocklist_entries"] = blocked[:5] + out["threats_fleet_wide"] = threats_summary(con, "threats_by_hash", {"contentHashes": sha1}) + return compact(out) + + +def investigate_uam(con, alert_id, days): + data = con.gql("alert", alerts_query(['{ fieldId: "id" stringEqual: { value: %s } }' % json.dumps(alert_id)], + ALERT_FIELDS, 1)) + if data is None: + # The console could not be asked: that is a failed call, not an alert + # that does not exist. + raise RuntimeError("the alert could not be read: %s" % json.dumps(con.errors[-1])) + edges = (data.get("alerts") or {}).get("edges") or [] + if not edges: + return {"found": False, "alert_id": alert_id, + "hint": "No unified alert has this id. Copy the id whole from get_incident_context (raw id), or pass a threat_id for a threat."} + a = edges[0]["node"] + det = (con.gql("alert_detail", "query D { alert(id: %s) { %s } }" % (json.dumps(alert_id), DETAIL_FIELDS)) or {}).get("alert") or {} + + proc = a.get("process") or {} + f = proc.get("file") or {} + dt_asset = (a.get("detectionTime") or {}).get("asset") or {} + scope = (a.get("detectionTime") or {}).get("scope") or {} + indicators = [] + for ind in (det.get("indicators") or [])[:10]: + attacks = ["%s / %s (%s)" % ((x.get("tactic") or {}).get("name"), (x.get("technique") or {}).get("name"), + (x.get("technique") or {}).get("uid")) for x in ind.get("attacks") or []] + indicators.append(compact({"message": trim(ind.get("message"), 220), "mitre": attacks[:3]})) + observables, seen = [], set() + for ob in (det.get("observables") or []): + key = (ob.get("typeName") or ob.get("name"), ob.get("value")) + if key in seen or not ob.get("value"): + continue + seen.add(key) + observables.append("%s: %s" % (key[0], trim(key[1], 160))) + out = { + "kind": "unified alert", + "alert": compact({ + "id": a.get("id"), "name": a.get("name"), "description": trim(a.get("description"), 400), + "severity": a.get("severity"), "classification": a.get("classification"), + "confidence": a.get("confidenceLevel"), "status": a.get("status"), + "analyst_verdict": a.get("analystVerdict"), "mitigation_result": a.get("result"), + "detected_at": a.get("detectedAt"), "first_seen": a.get("firstSeenAt"), "last_seen": a.get("lastSeenAt"), + "rule": (a.get("analytics") or {}).get("name"), "rule_category": (a.get("analytics") or {}).get("category"), + "detected_by": " ".join(x for x in ((a.get("detectionSource") or {}).get("product"), + (a.get("detectionSource") or {}).get("engine")) if x), + "storyline_id": a.get("storylineId"), + "attacker": (a.get("detectionTime") or {}).get("attacker"), + "target_user": (a.get("detectionTime") or {}).get("targetUser"), + "scope": " / ".join(x for x in (scope.get("accountName"), scope.get("siteName"), scope.get("groupName")) if x), + }), + "process": compact({ + "command_line": trim(proc.get("cmdLine"), 600), "parent": proc.get("parentName"), + "user": det.get("process", {}).get("username") if det.get("process") else None, + "file": f.get("name") or a.get("fileName"), "path": f.get("path"), + "sha1": f.get("sha1"), "sha256": f.get("sha256"), "md5": f.get("md5"), + "signer": f.get("certSubject"), + }), + "indicators": indicators, + "observables": observables[:25], + "related_alerts": [trim(r.get("name"), 120) for r in (det.get("relatedAlerts") or [])[:10]], + } + + asset = a.get("asset") or {} + agent, how = find_agent(con, agent_uuid=asset.get("agentUuid"), name=asset.get("name")) + out["host"] = summarize_agent(agent) if agent else compact({ + "computer_name": asset.get("name"), "os": dt_asset.get("osName") or asset.get("osVersion"), + "ip_at_detection": dt_asset.get("ipV4"), "domain": dt_asset.get("domain"), + "last_logged_in_user": dt_asset.get("lastLoggedInUser") or asset.get("lastLoggedInUser"), + "agent_version": asset.get("agentVersion"), "connectivity": asset.get("connectivityToConsole"), + "note": "no SentinelOne agent found for this asset (not an endpoint, or uninstalled): fields from the alert", + }) + if agent: + out["host"]["found_by"] = how + if dt_asset.get("ipV4"): + out["host"]["ip_at_detection"] = dt_asset.get("ipV4") + + anchor = parse_time(a.get("detectedAt")) or datetime.datetime.now(datetime.timezone.utc) + start, end = anchor - datetime.timedelta(days=days), anchor + datetime.timedelta(days=1) + window = "%s to %s (%d days before the alert's detection to 1 day after)" % (iso(start), iso(end), days) + elsewhere = {"window": window} + if asset.get("id"): + sib = con.gql("same_host_alerts", alerts_query([ + '{ fieldId: "asset.id" stringEqual: { value: %s } }' % json.dumps(asset["id"]), + '{ fieldId: "detectedAt" dateTimeRange: { start: %d end: %d } }' % (start.timestamp() * 1000, end.timestamp() * 1000), + ], SIBLING_FIELDS, 100)) + alerts = (sib or {}).get("alerts") or {} + nodes = [e["node"] for e in alerts.get("edges") or [] if e.get("node") and e["node"].get("id") != alert_id] + if sib is not None: + elsewhere["same_host_other_alerts"] = compact({ + "total": max((alerts.get("totalCount") or 0) - 1, len(nodes)), + "read": len(nodes), + "by_name": group_count(nodes, lambda n: n.get("name"), lambda n: n.get("detectedAt") or ""), + }) + if agent: + elsewhere["same_host_threats"] = threats_summary(con, "threats_on_host", { + "agentIds": agent.get("id"), "createdAt__gt": iso(start), "createdAt__lt": iso(end)}) + sha1 = f.get("sha1") or (a.get("fileHash") if SHA1_RE.match(str(a.get("fileHash") or "")) else None) + if sha1: + out["file_checks"] = hash_checks(con, sha1, agent.get("siteId") if agent else None) + else: + out["file_checks"] = "no SHA1 on this alert: reputation, blocklist and fleet-wide checks skipped" + out["elsewhere"] = compact(elsewhere) + return out + + +def investigate_threat(con, threat_id, days): + resp = con.get("threat", "/threats", {"ids": threat_id}) + if resp is None: + raise RuntimeError("the threat could not be read: %s" % json.dumps(con.errors[-1])) + data = (resp or {}).get("data") or [] + if not data: + return {"found": False, "threat_id": threat_id, + "hint": "No threat has this id. Copy the id whole from get_incident_context (threatInfo.threatId), or pass alert_id for a unified alert."} + t = data[0] + ti, rt, di = t.get("threatInfo") or {}, t.get("agentRealtimeInfo") or {}, t.get("agentDetectionInfo") or {} + out = { + "kind": "threat", + "threat": compact({ + "id": ti.get("threatId"), "name": ti.get("threatName"), "classification": ti.get("classification"), + "confidence": ti.get("confidenceLevel"), "analyst_verdict": ti.get("analystVerdict"), + "incident_status": ti.get("incidentStatus"), "mitigation_status": ti.get("mitigationStatus"), + "detection_type": ti.get("detectionType"), "engines": ti.get("engines"), + "created_at": ti.get("createdAt"), "identified_at": ti.get("identifiedAt"), + "storyline": ti.get("storyline"), + }), + "process": compact({ + "file_path": ti.get("filePath"), "sha1": ti.get("sha1"), "sha256": ti.get("sha256"), "md5": ti.get("md5"), + "signer": ti.get("publisherName"), "verified": ti.get("fileVerificationType"), + "originating_process": ti.get("originatorProcess"), "process_user": ti.get("processUser"), + "command_line": trim(ti.get("maliciousProcessArguments"), 600), + "initiated_by": ti.get("initiatedBy"), + }), + } + notes = con.get("threat_notes", "/threats/%s/notes" % threat_id, {"limit": 10}) + out["notes"] = [compact({"by": n.get("creator"), "at": n.get("createdAt"), "text": trim(n.get("text"), 300)}) + for n in (notes or {}).get("data") or []] + acts = con.get("threat_timeline", "/activities", {"threatIds": threat_id, "limit": 30, "sortBy": "createdAt", "sortOrder": "asc"}) + out["timeline"] = ["%s %s" % (x.get("createdAt"), trim(x.get("primaryDescription"), 200)) + for x in (acts or {}).get("data") or []][:20] + + agent, how = find_agent(con, agent_id=rt.get("agentId"), name=rt.get("agentComputerName")) + out["host"] = summarize_agent(agent) if agent else compact({ + "computer_name": rt.get("agentComputerName"), "os": rt.get("agentOsName"), + "agent_version": rt.get("agentVersion"), "network_status": rt.get("networkStatus"), + "note": "agent not found in the console: fields from the threat", + }) + if agent: + out["host"]["found_by"] = how + out["host"]["user_at_detection"] = di.get("agentLastLoggedInUserName") + out["host"]["ip_at_detection"] = di.get("agentIpV4") + out["host"] = compact(out["host"]) + + anchor = parse_time(ti.get("createdAt")) or datetime.datetime.now(datetime.timezone.utc) + start, end = anchor - datetime.timedelta(days=days), anchor + datetime.timedelta(days=1) + elsewhere = {"window": "%s to %s (%d days before the threat to 1 day after)" % (iso(start), iso(end), days)} + if agent: + elsewhere["same_host_threats"] = threats_summary(con, "threats_on_host", { + "agentIds": agent.get("id"), "createdAt__gt": iso(start), "createdAt__lt": iso(end)}) + out["elsewhere"] = compact(elsewhere) + if ti.get("sha1"): + out["file_checks"] = hash_checks(con, ti["sha1"], agent.get("siteId") if agent else None) + return out + + +def main(): + secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + base = secrets.get("url", "").rstrip("/") + "/web/api/v2.1" + con = Console(base, { + "Authorization": "ApiToken " + secrets.get("api_token", ""), + "Accept": "application/json", + "Content-Type": "application/json", + }) + alert_id = str(inputs.get("alert_id") or "").strip() + threat_id = str(inputs.get("threat_id") or "").strip() + days = max(1, min(int(inputs.get("days_before") or 7), 30)) + # A long number in alert_id is a threat id given in the wrong field. + if alert_id.isdigit() and not threat_id: + threat_id, alert_id = alert_id, "" + if threat_id: + if not threat_id.isdigit(): + raise ValueError("threat_id is a long number (about 19 digits); for a UUID use alert_id") + out = investigate_threat(con, threat_id, days) + elif alert_id: + if not UUID_RE.match(alert_id): + raise ValueError("alert_id is the unified alert's UUID (8-4-4-4-12 hex), copied whole; got %r (asset.id and agentUuid are not alert ids)" % alert_id) + out = investigate_uam(con, alert_id, days) + else: + raise ValueError("give alert_id (unified alert UUID) or threat_id (threat number)") + if con.errors: + out["errors"] = con.errors[:8] + print(json.dumps(out, ensure_ascii=False, separators=(",", ":"))) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1)