diff --git a/integrations/sentinelone/incident-types/sentinelone-unified-alert.yaml b/integrations/sentinelone/incident-types/sentinelone-unified-alert.yaml new file mode 100644 index 0000000..8084931 --- /dev/null +++ b/integrations/sentinelone/incident-types/sentinelone-unified-alert.yaml @@ -0,0 +1,3 @@ +name: "SentinelOne Unified Alert" +color: "#f28c28" +icon: "shield-alert" diff --git a/integrations/sentinelone/manifest.yaml b/integrations/sentinelone/manifest.yaml index e80a0cb..33835cc 100644 --- a/integrations/sentinelone/manifest.yaml +++ b/integrations/sentinelone/manifest.yaml @@ -1,8 +1,8 @@ id: sentinelone name: SentinelOne -version: 1.7.1 +version: 1.8.0 description: "SentinelOne Singularity (API v2.1) — endpoint detection & response: triage threats, enrich, isolate/reconnect hosts, mitigate, scan." -changelog: "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.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. @@ -915,6 +915,32 @@ commands: outputs_schema: { properties: {} } # ── Unified (UAM) alerts ────────────────────────────────────────────────── + # Console-wide alerts, served by the unifiedalerts GraphQL endpoint rather than + # the REST v2.1 tree. A different object from the cloud-detection alert of + # get_alerts: one alert per detection across every surface SentinelOne watches. + - id: get_unified_alerts + name: sentinelone-get-unified-alerts + description: "Retrieve unified (UAM) alerts across endpoint, cloud, identity and third-party surfaces. Walks the cursor until the limit is reached." + risk: read + inputs_schema: + properties: + created_after: { type: string, description: "Return alerts created at or after this time (ISO8601 or epoch). The incremental field: leave empty and let the alert rule set it." } + created_until: { type: string, description: "Return alerts created at or before this time (ISO8601 or epoch)." } + updated_after: { type: string, description: "Return alerts updated at or after this time. Set, it sorts on updatedAt instead of createdAt — use it to pick up alerts retriaged in the console, not to create incidents." } + view_type: { type: string, description: "Surface to read: ALL (default) | CLOUD | CUSTOM_ALERTS | ENDPOINT | IDENTITY | STAR | THIRD_PARTY" } + status: { type: string, description: "Filter on alert status, e.g. NEW | IN_PROGRESS | RESOLVED." } + severity: { type: string, description: "Filter on severity: CRITICAL | HIGH | MEDIUM | LOW | INFO." } + analyst_verdict: { type: string, description: "Filter on analyst verdict, e.g. TRUE_POSITIVE_MALWARE, FALSE_POSITIVE_BENIGN, UNDEFINED." } + alert_id: { type: string, description: "Fetch one alert by its UUID." } + 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: [] + outputs_schema: { properties: {} } + ingest: + results_path: data + dedup_key: id + incremental_field: created_after + - 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/mappers/get_unified_alerts.yaml b/integrations/sentinelone/mappers/get_unified_alerts.yaml new file mode 100644 index 0000000..841db9c --- /dev/null +++ b/integrations/sentinelone/mappers/get_unified_alerts.yaml @@ -0,0 +1,105 @@ +name: "SentinelOne Unified Alerts → OCSF" +description: "Map of a SentinelOne unified (UAM) alert node (data[]) to OCSF. The console-wide alert model, distinct from the cloud-detection alert of get_alerts: one alert per detection across endpoint, cloud, identity and third-party surfaces. The detected process is the actor (actor.*); the file it ran is the target (file.*)." +field_mappings: + title: "name" + description: "description" + source: "detectionSource.product" + # The last branch yields severity itself, not a number: an alert with no + # severity (or one SentinelOne adds later) then falls through to the rule's + # default rather than being asserted Informational on data that never said so. + severity: "severity = 'CRITICAL' ? 5 : (severity = 'HIGH' ? 4 : (severity = 'MEDIUM' ? 3 : (severity = 'LOW' ? 2 : (severity = 'INFO' ? 1 : severity))))" +# source_path is JSONata over ONE alert node (alert rule results_path = data). +# Paths absent from a given alert are skipped at ingestion, so extra entries are safe. +# A UAM alert carries a few fields with no honest OCSF home — assignee, ticketId, +# noteExists, asset.policy, asset.connectivityToConsole, kubernetes controllers. +# They are fetched and stay in the raw payload for playbooks rather than being +# forced into an unrelated field. +ocsf: + # ── Alert identity ──────────────────────────────────────────────── + - { source_path: "id", ocsf_field: "finding_info.uid" } + - { source_path: "name", ocsf_field: "finding_info.title" } + - { source_path: "description", ocsf_field: "finding_info.desc" } + - { source_path: "externalId", ocsf_field: "finding_info.uid_alt" } + - { source_path: "classification", ocsf_field: "finding_info.types" } + - { source_path: "createdAt", ocsf_field: "finding_info.created_time" } + - { source_path: "updatedAt", ocsf_field: "finding_info.modified_time" } + - { source_path: "firstSeenAt", ocsf_field: "finding_info.first_seen_time" } + - { source_path: "lastSeenAt", ocsf_field: "finding_info.last_seen_time" } + - { source_path: "detectedAt", ocsf_field: "metadata.original_time" } + - { source_path: "storylineId", ocsf_field: "metadata.correlation_uid" } + - { source_path: "attackSurfaces", ocsf_field: "metadata.labels" } + - { source_path: "dataSources", ocsf_field: "metadata.log_source" } + # ── Triage state ────────────────────────────────────────────────── + - { source_path: "severity", ocsf_field: "severity" } + - { source_path: "status", ocsf_field: "status" } + - { source_path: "analystVerdict", ocsf_field: "status_detail" } + - { source_path: "result", ocsf_field: "action" } + - { source_path: "confidenceLevel", ocsf_field: "confidence" } + # ── Detecting analytic / rule ───────────────────────────────────── + - { source_path: "analytics.uid", ocsf_field: "finding_info.analytic.uid" } + - { source_path: "analytics.name", ocsf_field: "finding_info.analytic.name" } + - { source_path: "analytics.type", ocsf_field: "finding_info.analytic.type" } + - { source_path: "analytics.category", ocsf_field: "finding_info.analytic.category" } + - { source_path: "analytics.name", ocsf_field: "rule.name" } + - { source_path: "analytics.uid", ocsf_field: "rule.uid" } + - { source_path: "analytics.typeValue", ocsf_field: "rule.type" } + # ── Detecting product ───────────────────────────────────────────── + - { source_path: "detectionSource.product", ocsf_field: "metadata.product.name" } + - { source_path: "detectionSource.vendor", ocsf_field: "metadata.product.vendor_name" } + - { source_path: "detectionSource.engine", ocsf_field: "metadata.product.feature.name" } + - { source_path: "asset.agentVersion", ocsf_field: "metadata.product.version" } + # ── Asset (current state) ───────────────────────────────────────── + - { source_path: "asset.name", ocsf_field: "src_endpoint.hostname" } + - { source_path: "asset.agentUuid", ocsf_field: "device.uid" } + - { source_path: "asset.id", ocsf_field: "device.uid_alt" } + - { source_path: "asset.type", ocsf_field: "device.type" } + - { source_path: "asset.assetTypeClassifier", ocsf_field: "src_endpoint.type" } + - { source_path: "asset.osType", ocsf_field: "src_endpoint.os.type" } + - { source_path: "asset.osVersion", ocsf_field: "src_endpoint.os.version" } + - { source_path: "asset.lastLoggedInUser", ocsf_field: "device.owner.name" } + # ── Asset (state at detection) ──────────────────────────────────── + - { source_path: "detectionTime.asset.ipV4", ocsf_field: "src_endpoint.ip" } + - { source_path: "detectionTime.asset.ipV6", ocsf_field: "device.ip" } + - { source_path: "detectionTime.asset.domain", ocsf_field: "src_endpoint.domain" } + - { source_path: "detectionTime.asset.osName", ocsf_field: "src_endpoint.os.name" } + - { source_path: "detectionTime.asset.osRevision", ocsf_field: "device.os.build" } + - { source_path: "detectionTime.asset.subscriptionTime", ocsf_field: "device.first_seen_time" } + # ── Attacker (the other party) ──────────────────────────────────── + - { source_path: "detectionTime.attacker.host", ocsf_field: "dst_endpoint.hostname" } + - { source_path: "detectionTime.attacker.ip", ocsf_field: "dst_endpoint.ip" } + # ── Targeted user ───────────────────────────────────────────────── + - { source_path: "detectionTime.targetUser.name", ocsf_field: "user.name" } + - { source_path: "detectionTime.targetUser.emailAddress", ocsf_field: "user.email_addr" } + - { source_path: "detectionTime.targetUser.domain", ocsf_field: "user.domain" } + # ── Cloud workload ──────────────────────────────────────────────── + - { source_path: "detectionTime.cloud.accountId", ocsf_field: "cloud.account.uid" } + - { source_path: "detectionTime.cloud.cloudProvider", ocsf_field: "cloud.provider" } + - { source_path: "detectionTime.cloud.location", ocsf_field: "cloud.region" } + - { source_path: "detectionTime.cloud.instanceId", ocsf_field: "device.instance_uid" } + - { source_path: "detectionTime.cloud.network", ocsf_field: "device.vpc_uid" } + # ── SentinelOne tenancy (as scoped at detection) ────────────────── + - { source_path: "detectionTime.scope.accountName", ocsf_field: "cloud.org.name" } + - { source_path: "detectionTime.scope.accountId", ocsf_field: "cloud.org.uid" } + - { source_path: "detectionTime.scope.siteName", ocsf_field: "device.zone" } + - { source_path: "realTime.scope.site.id", ocsf_field: "metadata.tenant_uid" } + # ── Container ───────────────────────────────────────────────────── + - { source_path: "detectionTime.kubernetes.containerName", ocsf_field: "container.name" } + - { source_path: "detectionTime.kubernetes.containerId", ocsf_field: "container.uid" } + - { source_path: "detectionTime.kubernetes.containerImageName", ocsf_field: "container.image.name" } + - { source_path: "detectionTime.kubernetes.podName", ocsf_field: "container.pod_uuid" } + # ── Detected process — the actor ────────────────────────────────── + - { source_path: "process.cmdLine", ocsf_field: "actor.process.cmd_line" } + - { source_path: "process.parentName", ocsf_field: "actor.process.parent_process.name" } + - { source_path: "process.file.name", ocsf_field: "actor.process.file.name" } + - { source_path: "process.file.path", ocsf_field: "actor.process.file.path" } + - { source_path: "process.file.sha256", ocsf_field: "actor.process.file.hashes.sha256" } + # ── The file it ran — the target ────────────────────────────────── + - { source_path: "process.file.name", ocsf_field: "file.name" } + - { source_path: "process.file.path", ocsf_field: "file.path" } + - { source_path: "process.file.sha256", ocsf_field: "file.hashes.sha256" } + - { source_path: "process.file.sha1", ocsf_field: "file.hashes.sha1" } + - { source_path: "process.file.md5", ocsf_field: "file.hashes.md5" } + - { source_path: "process.file.certSubject", ocsf_field: "file.signature.value" } + # ── The alert's own file summary, hash type unstated ────────────── + - { source_path: "fileName", ocsf_field: "observables.name" } + - { source_path: "fileHash", ocsf_field: "observables.value" } diff --git a/integrations/sentinelone/scripts/get_unified_alerts.py b/integrations/sentinelone/scripts/get_unified_alerts.py new file mode 100644 index 0000000..e6b6250 --- /dev/null +++ b/integrations/sentinelone/scripts/get_unified_alerts.py @@ -0,0 +1,184 @@ +import json, os, sys, urllib.request, urllib.parse, urllib.error + +# Unified (UAM) alerts live behind a GraphQL endpoint, not the REST v2.1 tree the +# rest of this package uses. The console-wide alert model is a different object +# from /cloud-detection/alerts (see get_alerts): one alert per detection across +# endpoint, cloud, identity and third-party surfaces. +VIEW_TYPES = ("ALL", "CLOUD", "CUSTOM_ALERTS", "ENDPOINT", "IDENTITY", "STAR", "THIRD_PARTY") + +# Fields requested per alert node. Kept in one place so the mapper and this +# query cannot drift apart. +NODE_FIELDS = """ + id name description severity status result analystVerdict attackSurfaces + classification confidenceLevel externalId createdAt updatedAt firstSeenAt + lastSeenAt detectedAt noteExists dataSources storylineId ticketId + fileName fileHash + analytics { uid name type typeValue category } + assignee { userId fullName email } + detectionSource { product vendor engine } + asset { + id name agentUuid agentVersion assetTypeClassifier category subcategory type + connectivityToConsole osType osVersion pendingReboot lastLoggedInUser policy + } + detectionTime { + asset { + agentVersion consoleIpAddress domain ipV4 ipV6 lastLoggedInUser + osName osRevision osType policy subscriptionTime + } + attacker { host ip } + cloud { accountId cloudProvider image instanceId instanceSize location network tags } + kubernetes { + clusterName namespaceName nodeName podName containerId containerImageName + containerName containerNetworkStatus controllerName controllerType + } + scope { accountId accountName groupName siteName } + targetUser { name emailAddress domain } + } + process { cmdLine parentName file { certSubject md5 name path sha1 sha256 } } + realTime { scope { account { id name } group { id name } site { id name } } } +""" + + +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 epoch_ms(v): + """Normalise a watermark to epoch milliseconds. + + The alert-rule scheduler injects the watermark raw, in whichever format the + operator picked for the rule (iso8601 | unix | unix_ms), so accept all three + rather than forcing one and failing silently on the others. + """ + s = str(v or "").strip() + if not s: + return None + if s.isdigit(): + n = int(s) + return n * 1000 if n < 100000000000 else n # seconds vs milliseconds + import datetime + t = s.replace("Z", "+00:00") + try: + dt = datetime.datetime.fromisoformat(t) + except ValueError: + raise ValueError("cannot read timestamp %r: expected ISO8601 or an epoch" % s) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=datetime.timezone.utc) + return int(dt.timestamp() * 1000) + + +def gql_string(v): + """Quote a value for inline use in the query (json.dumps escapes to GraphQL's rules).""" + return json.dumps(str(v)) + + +def build_filters(inputs): + out = [] + start = epoch_ms(inputs.get("created_after")) + end = epoch_ms(inputs.get("created_until")) + if start is not None or end is not None: + parts = [] + if start is not None: + parts.append("start: %d" % start) + if end is not None: + parts.append("end: %d" % end) + out.append('{ fieldId: "createdAt" dateTimeRange: { %s } }' % " ".join(parts)) + updated = epoch_ms(inputs.get("updated_after")) + if updated is not None: + out.append('{ fieldId: "updatedAt" dateTimeRange: { start: %d } }' % updated) + for field, key in (("status", "status"), ("severity", "severity"), + ("analystVerdict", "analyst_verdict"), ("id", "alert_id")): + v = str(inputs.get(key) or "").strip() + if v: + out.append('{ fieldId: "%s" stringEqual: { value: %s } }' % (field, gql_string(v))) + 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" + headers = { + "Authorization": "ApiToken " + secrets.get("api_token", ""), + "Accept": "application/json", + "Content-Type": "application/json", + } + + view_type = str(inputs.get("view_type") or "ALL").strip().upper() + if view_type not in VIEW_TYPES: + raise ValueError("view_type must be one of %s" % ", ".join(VIEW_TYPES)) + + limit = int(inputs.get("limit") or 100) + sort_by = "updatedAt" if str(inputs.get("updated_after") or "").strip() else "createdAt" + filters = build_filters(inputs) + filter_clause = "filters: [ %s ]" % " ".join(filters) if filters else "" + + # Ascending, so a truncated run leaves the watermark on the oldest unseen + # alert rather than skipping everything between it and the newest. + nodes, cursor, page_info = [], str(inputs.get("cursor") or "").strip(), {} + total = None + while len(nodes) < limit: + page = min(limit - len(nodes), 100) + query = """query Alerts { + alerts( + first: %d + viewType: %s + after: %s + sort: { by: "%s", order: ASC } + %s + ) { + totalCount + edges { node { %s } } + pageInfo { hasNextPage hasPreviousPage startCursor endCursor } + } +}""" % (page, view_type, gql_string(cursor) if cursor else "null", sort_by, filter_clause, NODE_FIELDS) + + resp = request("POST", base + "/unifiedalerts/graphql", headers, {"query": query}) + # GraphQL answers 200 with an errors array; without this an unusable + # query would look like an endless run of empty, successful fetches. + if resp.get("errors"): + print(json.dumps({"error": "GraphQL error", "detail": resp["errors"]})) + sys.exit(1) + + alerts = (resp.get("data") or {}).get("alerts") or {} + if total is None: + total = alerts.get("totalCount") + edges = alerts.get("edges") or [] + for edge in edges: + node = edge.get("node") + if node: + nodes.append(node) + page_info = alerts.get("pageInfo") or {} + cursor = page_info.get("endCursor") + # An empty page ends the walk even when the console still claims a next + # one: without this, a console answering hasNextPage on no edges would + # keep the fetch spinning until the run is killed. + if not edges or not page_info.get("hasNextPage") or not cursor: + break + + # 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. + print(json.dumps({ + "data": nodes, + "pagination": { + "totalCount": total, + "returned": len(nodes), + "hasNextPage": bool(page_info.get("hasNextPage")), + "endCursor": page_info.get("endCursor"), + }, + })) + + +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)