diff --git a/integrations/sentinelone/manifest.yaml b/integrations/sentinelone/manifest.yaml index 298bb7a..e24a8e0 100644 --- a/integrations/sentinelone/manifest.yaml +++ b/integrations/sentinelone/manifest.yaml @@ -1,8 +1,8 @@ id: sentinelone name: SentinelOne -version: 1.12.0 +version: 1.13.0 description: "SentinelOne Singularity (API v2.1) — endpoint detection & response: triage threats, enrich, isolate/reconnect hosts, mitigate, scan." -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." +changelog: "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. @@ -1023,6 +1023,7 @@ commands: severity: { type: string, description: "Filter on severity: CRITICAL | HIGH | MEDIUM | LOW | INFO. Comma-separated for several." } analyst_verdict: { type: string, description: "Filter on analyst verdict, e.g. TRUE_POSITIVE_MALWARE, FALSE_POSITIVE_BENIGN, UNDEFINED. Comma-separated for several." } alert_id: { type: string, description: "Fetch one alert by its UUID." } + with_details: { type: boolean, description: "Also read each alert's detail (indicators and their MITRE ATT&CK mapping, observables, related alerts, process user) and merge it into the alert. Costs one extra request per 10 alerts; leave off for a plain list fetch." } limit: { type: number, description: "Max alerts to return across pages (default 100)." } cursor: { type: string, description: "Resume from a previous run's pagination.endCursor." } required: [] diff --git a/integrations/sentinelone/mappers/get_unified_alerts.yaml b/integrations/sentinelone/mappers/get_unified_alerts.yaml index a366f64..47abfae 100644 --- a/integrations/sentinelone/mappers/get_unified_alerts.yaml +++ b/integrations/sentinelone/mappers/get_unified_alerts.yaml @@ -113,3 +113,17 @@ ocsf: # ── The alert's own file summary, hash type unstated ────────────── - { source_path: "fileName", ocsf_field: "observables.name" } - { source_path: "fileHash", ocsf_field: "observables.value" } + # ── Only present when the fetch ran with_details ────────────────── + # Absent paths are skipped at ingestion, so these stay inert on a plain + # list fetch. observables comes last on purpose: with the detail read, the + # full list replaces the single file summary set just above. + - { source_path: "process.username", ocsf_field: "actor.user.name" } + - { source_path: "process.file.size", ocsf_field: "actor.process.file.size" } + - { source_path: "process.file.size", ocsf_field: "file.size" } + # [ ] forces a list of one to stay a list; the $exists guard keeps an empty + # or absent value undefined, so a plain list fetch skips these entries + # instead of writing an empty array over the file summary above. + - { source_path: '$exists(indicators) ? [indicators]', ocsf_field: "evidences" } + - { source_path: '$exists(indicators.attacks) ? [indicators.attacks.{"tactic": {"name": tactic.name, "uid": tactic.uid}, "technique": {"name": technique.name, "uid": technique.uid}}]', ocsf_field: "finding_info.attacks" } + - { source_path: '$exists(relatedAlerts) ? [relatedAlerts.{"uid": id, "title": name}]', ocsf_field: "finding_info.related_events" } + - { source_path: '$exists(observables) ? [observables.{"name": name, "value": value, "type": type}]', ocsf_field: "observables" } diff --git a/integrations/sentinelone/scripts/get_unified_alerts.py b/integrations/sentinelone/scripts/get_unified_alerts.py index ab51cfd..c2ed19c 100644 --- a/integrations/sentinelone/scripts/get_unified_alerts.py +++ b/integrations/sentinelone/scripts/get_unified_alerts.py @@ -60,6 +60,21 @@ NODE_FIELDS = """ realTime { scope { account { id name } group { id name } site { id name } } } """ +# Only what the list nodes do not already carry, so merging a detail onto its +# node cannot overwrite the richer list fields with the detail's shorter ones. +DETAIL_FIELDS = """ + indicators { uid type message severity eventTime + attacks { tactic { name uid } technique { name uid } } + observables { name value type typeName } } + observables { name value type typeName } + relatedAlerts { id name } + process { username file { size } } +""" + +# Details are read in aliased batches: one alert per request would cost 100 +# round trips on a default-limit fetch. +DETAIL_BATCH = 10 + _UNITS = {"second": 1, "minute": 60, "hour": 3600, "day": 86400, "week": 604800, "month": 2592000, "year": 31536000} @@ -126,6 +141,52 @@ def csv_list(v, upper=False): return [x.upper() for x in out] if upper else out +def truthy(v): + return str(v).strip().lower() in ("1", "true", "yes", "on") + + +def deep_merge(base, extra): + """Fold extra into base without overwriting what base already says: the list + node is the authority on the fields both carry, the detail only fills in.""" + for k, v in (extra or {}).items(): + cur = base.get(k) + if isinstance(cur, dict) and isinstance(v, dict): + deep_merge(cur, v) + elif cur in (None, "", [], {}): + base[k] = v + return base + + +def fetch_details(base, headers, ids): + """Read the per-alert detail for ids, as {id: detail}. Best effort: an alert + the console will not detail is left with its list fields rather than failing + the fetch that already succeeded.""" + out, errors = {}, [] + for start in range(0, len(ids), DETAIL_BATCH): + chunk = ids[start:start + DETAIL_BATCH] + aliases = {"d%d" % i: aid for i, aid in enumerate(chunk)} + query = "query AlertDetails {\n%s\n}" % "\n".join( + " %s: alert(id: %s) { %s }" % (alias, gql_string(aid), DETAIL_FIELDS) + for alias, aid in aliases.items()) + resp = request("POST", base + "/unifiedalerts/graphql", headers, {"query": query}) + for alias, value in ((resp.get("data") or {}) or {}).items(): + if value and alias in aliases: + out[aliases[alias]] = value + if resp.get("errors"): + errors.extend(resp["errors"]) + # A batch answers partially: one unreadable alert nulls its alias and + # leaves the rest. Retry only what is still missing, one at a time, + # so a single bad id does not cost the whole batch. + for aid in [a for a in chunk if a not in out]: + single = request("POST", base + "/unifiedalerts/graphql", headers, + {"query": "query AlertDetail { alert(id: %s) { %s } }" + % (gql_string(aid), DETAIL_FIELDS)}) + value = (single.get("data") or {}).get("alert") + if value: + out[aid] = value + return out, errors + + def norm(v): """Fold a category to letters/digits, so "Platform Rule", "platform rule" and PLATFORM_RULE all compare equal (used only by the client-side fallback).""" @@ -222,9 +283,9 @@ def build_filters(inputs, category_field, account_field): search = str(inputs.get("search_text") or "").strip() if search: out.append('{ fieldId: "name" match: { values: [%s] } }' % gql_string(search)) - if str(inputs.get("unassigned_only") or "").strip().lower() in ("1", "true", "yes"): + if truthy(inputs.get("unassigned_only")): out.append('{ fieldId: "assignee.userId" isNegated: true stringEqual: { value: null } }') - if str(inputs.get("unmitigated_only") or "").strip().lower() in ("1", "true", "yes"): + if truthy(inputs.get("unmitigated_only")): out.append('{ fieldId: "result" stringEqual: { value: "UNMITIGATED" } }') return out @@ -342,6 +403,22 @@ def main(): if not edges or not page_info.get("hasNextPage") or not cursor: break + # Enrichment runs on the alerts actually returned, once the walk is over, so + # the alerts dropped by a client-side filter cost nothing. + details = {"requested": 0, "fetched": 0} + if truthy(inputs.get("with_details")) and nodes: + ids = [n.get("id") for n in nodes if n.get("id")] + details["requested"] = len(ids) + fetched, detail_errors = fetch_details(base, headers, ids) + details["fetched"] = len(fetched) + for node in nodes: + deep_merge(node, fetched.get(node.get("id")) or {}) + # Alerts without their detail are still returned — an ingestion that got + # the alerts but not their indicators beats one that got nothing — so the + # shortfall is reported rather than raised. + if detail_errors and len(fetched) < len(ids): + details["error"] = detail_errors[0].get("message", str(detail_errors[0])) + 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 @@ -363,6 +440,7 @@ def main(): "accountFilter": (None if not accounts else ("server:" + account_field if account_field else "client")), "sortForm": sort_form, + "details": details, }, }))