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, }, }))