From fe564cec2cdf5a717ba65825d5e721c87f0cbf6a Mon Sep 17 00:00:00 2001 From: Guillaume BOURGEOIS Date: Wed, 9 Sep 2026 12:03:53 +0000 Subject: [PATCH] feat(sentinelone): account_ids filter on get_unified_alerts (v1.12.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_threats and get_alerts have taken an accountIds filter since 1.4.0, so a multi-tenant console could be ingested one account at a time. The unified alerts could not, which left a tenant-per-rule setup unable to move off the REST commands. An alert carries its account twice — the scope it sits in now (realTime.scope.account.id) and the one it was detected in (detectionTime.scope.accountId). The first is what an operator means by "this account", so it is filtered on first, degrading to the second and then to a filter over the fetched alerts if the console rejects the fieldId, the same ladder the analytic category uses. pagination.accountFilter reports which of the three ran. Client-side, an alert matches on either of its two accounts. The query is unchanged for a rule that does not set the new input, verified against 1.11.0 on the inputs an existing alert rule sends. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EU1Rk9N6vNtp3q7YvLpjcc --- integrations/sentinelone/manifest.yaml | 5 +- .../sentinelone/scripts/get_unified_alerts.py | 56 +++++++++++++++---- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/integrations/sentinelone/manifest.yaml b/integrations/sentinelone/manifest.yaml index 0d667f4..298bb7a 100644 --- a/integrations/sentinelone/manifest.yaml +++ b/integrations/sentinelone/manifest.yaml @@ -1,8 +1,8 @@ id: sentinelone name: SentinelOne -version: 1.11.0 +version: 1.12.0 description: "SentinelOne Singularity (API v2.1) — endpoint detection & response: triage threats, enrich, isolate/reconnect hosts, mitigate, scan." -changelog: "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.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. @@ -1011,6 +1011,7 @@ commands: classification: { type: string, description: "Comma-separated classifications, e.g. MALWARE,RANSOMWARE,TROJAN." } attack_surface: { type: string, description: "Comma-separated attack surfaces: ENDPOINT,CLOUD,IDENTITY,NETWORK,EMAIL." } os_type: { type: string, description: "Comma-separated OS types of the affected asset: WINDOWS,LINUX,MACOS." } + account_ids: { type: string, description: "Optional comma-separated account IDs to scope the fetch to, for a multi-tenant console. Matched against the account the alert is scoped to now, or the one it was detected in." } asset_id: { type: string, description: "Only alerts on this asset (comma-separated for several)." } external_id: { type: string, description: "Filter on the source's own alert ID (comma-separated for several)." } search_text: { type: string, description: "Free-text match on the alert name." } diff --git a/integrations/sentinelone/scripts/get_unified_alerts.py b/integrations/sentinelone/scripts/get_unified_alerts.py index 1316735..ab51cfd 100644 --- a/integrations/sentinelone/scripts/get_unified_alerts.py +++ b/integrations/sentinelone/scripts/get_unified_alerts.py @@ -17,6 +17,12 @@ MAX_PAGES = 50 # of the category are tried before falling back to filtering what was fetched. CATEGORY_FIELD_IDS = ("analytics.category", "analyticsCategory") +# An alert carries its account twice: the scope it sits in now (realTime) and the +# one it was detected in (detectionTime). The first is what an operator means by +# "this account", so it is tried first, with the same degrade-or-filter-locally +# treatment as the category. +ACCOUNT_FIELD_IDS = ("realTime.scope.account.id", "detectionTime.scope.accountId") + # Same reason: the sort argument is spelled one way or the other depending on the # console's schema version, and a wrong guess fails the whole query rather than # degrading, so both are tried. @@ -130,6 +136,22 @@ def category_matches(node, categories): return norm(((node.get("analytics") or {}).get("category"))) in {norm(c) for c in categories} +def node_accounts(node): + """Both account IDs the node carries, current scope first.""" + out = [] + real = (((node.get("realTime") or {}).get("scope") or {}).get("account") or {}).get("id") + detected = (((node.get("detectionTime") or {}).get("scope") or {}).get("accountId")) + for v in (real, detected): + if v not in (None, ""): + out.append(str(v).strip()) + return out + + +def account_matches(node, accounts): + wanted = {str(a).strip() for a in accounts} + return any(a in wanted for a in node_accounts(node)) + + def string_filter(field, values): """Equality on one value, membership on several — an unfiltered field is the caller's job to skip. stringEqual is kept for the single-value case because @@ -149,9 +171,9 @@ def date_range_filter(field, start, end): return '{ fieldId: "%s" dateTimeRange: { %s } }' % (field, " ".join(parts)) -def build_filters(inputs, category_field): - """Assemble the filter list. category_field is the fieldId to filter the - analytic category on, or None to leave it to the client-side pass.""" +def build_filters(inputs, category_field, account_field): + """Assemble the filter list. category_field and account_field are the fieldIds + to filter on, or None to leave that one to the client-side pass.""" out = [] # ── Time windows ────────────────────────────────────────────────────── @@ -190,6 +212,12 @@ def build_filters(inputs, category_field): if values: out.append(string_filter(category_field, values)) + # ── Tenancy: the account the alert is scoped to ─────────────────────── + if account_field: + values = csv_list(inputs.get("account_ids")) + if values: + out.append(string_filter(account_field, values)) + # ── Free text and the two console toggles ───────────────────────────── search = str(inputs.get("search_text") or "").strip() if search: @@ -226,11 +254,14 @@ def main(): limit = int(inputs.get("limit") or 100) sort_by = "updatedAt" if str(inputs.get("updated_after") or "").strip() else "createdAt" categories = csv_list(inputs.get("analytics_category")) + accounts = csv_list(inputs.get("account_ids")) - # Query shapes to try, in order, until the console accepts one. Without a - # category asked for there is nothing to degrade, so only the sort varies. + # Query shapes to try, in order, until the console accepts one. A filter that + # was not asked for has nothing to degrade, so it contributes no variants and + # the usual run settles on the first shape. category_modes = list(CATEGORY_FIELD_IDS) + [None] if categories else [None] - attempts = [(s, c) for s in SORT_FORMS for c in category_modes] + account_modes = list(ACCOUNT_FIELD_IDS) + [None] if accounts else [None] + attempts = [(s, c, a) for s in SORT_FORMS for c in category_modes for a in account_modes] attempt = 0 settled = False @@ -239,14 +270,15 @@ def main(): pages = 0 stopped_mid_page = None while len(nodes) < limit and pages < MAX_PAGES: - sort_form, category_field = attempts[attempt] + sort_form, category_field, account_field = attempts[attempt] client_side_category = bool(categories) and category_field is None + client_side_accounts = bool(accounts) and account_field is None # Server-side, every alert on a page is one we keep, so ask for exactly # what is missing. Filtered client-side, most of a page is discarded, so # read full pages rather than walking one alert at a time. - page = 100 if client_side_category else min(limit - len(nodes), 100) + page = 100 if (client_side_category or client_side_accounts) else min(limit - len(nodes), 100) page_cursor = cursor - filters = build_filters(inputs, category_field) + filters = build_filters(inputs, category_field, account_field) filter_clause = "filters: [ %s ]" % " ".join(filters) if filters else "" query = """query Alerts { alerts( @@ -289,6 +321,8 @@ def main(): continue if client_side_category and not category_matches(node, categories): continue + if client_side_accounts and not account_matches(node, accounts): + continue nodes.append(node) # Client-side, a page holds more alerts than the limit leaves room # for. A cursor only addresses page boundaries, so stopping here @@ -308,7 +342,7 @@ def main(): if not edges or not page_info.get("hasNextPage") or not cursor: break - sort_form, category_field = attempts[attempt] + sort_form, category_field, account_field = attempts[attempt] # Flattened to {data: [...]} so the alert rule reads a plain array of alerts # (results_path: data) and dedups on a top-level id, matching the REST # commands in this package. The edges/node envelope carries no id of its own. @@ -326,6 +360,8 @@ def main(): # tenant: 'client' means the category was filtered after the fetch. "categoryFilter": (None if not categories else ("server:" + category_field if category_field else "client")), + "accountFilter": (None if not accounts + else ("server:" + account_field if account_field else "client")), "sortForm": sort_form, }, }))