Files
riposte-marketplace/integrations/sentinelone/scripts/get_unified_alerts.py
T
f3nris fe564cec2c feat(sentinelone): account_ids filter on get_unified_alerts (v1.12.0)
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Rk9N6vNtp3q7YvLpjcc
2026-09-09 12:03:53 +00:00

378 lines
17 KiB
Python

import json, os, sys, time, 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")
# Walking the cursor is bounded: filtering client-side (see the attempt ladder) a
# page can be discarded almost whole, and without a cap a narrow filter on a busy
# console would page until the run is killed.
MAX_PAGES = 50
# The console narrows to platform-rule alerts with analyticsCategory=Platform
# Rule, but the fieldIds this endpoint takes are a mix of flat names (createdAt,
# externalId) and dotted paths (asset.osType, assignee.userId), so both spellings
# 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.
SORT_FORMS = ("sort", "sorts")
# 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 } } }
"""
_UNITS = {"second": 1, "minute": 60, "hour": 3600, "day": 86400,
"week": 604800, "month": 2592000, "year": 31536000}
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 rel_seconds(text):
"""Read a relative window like '24 hours' or 'last 7 days' into seconds."""
num = unit = None
for t in str(text).lower().replace("last", "").split():
if t.isdigit():
num = int(t)
elif t.rstrip("s") in _UNITS:
unit = t.rstrip("s")
return num * _UNITS[unit] if (num is not None and unit) else None
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. A relative window
('24 hours') is accepted too, for a rule that reads a rolling window instead
of a watermark.
"""
s = str(v or "").strip()
if not s:
return None
secs = rel_seconds(s)
if secs is not None:
return int((time.time() - secs) * 1000)
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, an epoch or a window like '24 hours'" % 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 csv_list(v, upper=False):
"""Split a comma-separated input into values, dropping the empties."""
if v in (None, ""):
return []
items = v if isinstance(v, list) else str(v).split(",")
out = [str(x).strip() for x in items if str(x).strip()]
return [x.upper() for x in out] if upper else out
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)."""
return "".join(ch for ch in str(v or "").lower() if ch.isalnum())
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
that is what every console here is known to accept."""
if len(values) == 1:
return '{ fieldId: "%s" stringEqual: { value: %s } }' % (field, gql_string(values[0]))
return '{ fieldId: "%s" stringIn: { values: [%s] } }' % (
field, ", ".join(gql_string(v) for v in values))
def date_range_filter(field, start, end):
parts = []
if start is not None:
parts.append("start: %d" % start)
if end is not None:
parts.append("end: %d" % end)
return '{ fieldId: "%s" dateTimeRange: { %s } }' % (field, " ".join(parts))
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 ──────────────────────────────────────────────────────
for field, after_key, until_key in (("createdAt", "created_after", "created_until"),
("detectedAt", "detected_after", "detected_until")):
start = epoch_ms(inputs.get(after_key))
end = epoch_ms(inputs.get(until_key))
if start is not None or end is not None:
out.append(date_range_filter(field, start, end))
updated = epoch_ms(inputs.get("updated_after"))
if updated is not None:
out.append(date_range_filter("updatedAt", updated, None))
# ── Enum-valued fields: comma-separated, upper-cased ──────────────────
for field, key in (("status", "status"),
("severity", "severity"),
("analystVerdict", "analyst_verdict"),
("classification", "classification"),
("attackSurfaces", "attack_surface"),
("asset.osType", "os_type")):
values = csv_list(inputs.get(key), upper=True)
if values:
out.append(string_filter(field, values))
# ── Identifiers, taken as written ─────────────────────────────────────
for field, key in (("id", "alert_id"),
("asset.id", "asset_id"),
("externalId", "external_id")):
values = csv_list(inputs.get(key))
if values:
out.append(string_filter(field, values))
# ── The detecting analytic's category (e.g. Platform Rule) ────────────
if category_field:
values = csv_list(inputs.get("analytics_category"))
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:
out.append('{ fieldId: "name" match: { values: [%s] } }' % gql_string(search))
if str(inputs.get("unassigned_only") or "").strip().lower() in ("1", "true", "yes"):
out.append('{ fieldId: "assignee.userId" isNegated: true stringEqual: { value: null } }')
if str(inputs.get("unmitigated_only") or "").strip().lower() in ("1", "true", "yes"):
out.append('{ fieldId: "result" stringEqual: { value: "UNMITIGATED" } }')
return out
def sort_clause(form, by):
"""ASC either way, so a truncated run leaves the watermark on the oldest
unseen alert rather than skipping everything between it and the newest."""
if form == "sorts":
return 'sorts: [ { by: "%s", order: ASC } ]' % by
return 'sort: { by: "%s", order: ASC }' % by
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"
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. 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]
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
nodes, cursor, page_info = [], str(inputs.get("cursor") or "").strip(), {}
total = None
pages = 0
stopped_mid_page = None
while len(nodes) < limit and pages < MAX_PAGES:
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 or client_side_accounts) else min(limit - len(nodes), 100)
page_cursor = cursor
filters = build_filters(inputs, category_field, account_field)
filter_clause = "filters: [ %s ]" % " ".join(filters) if filters else ""
query = """query Alerts {
alerts(
first: %d
viewType: %s
after: %s
%s
%s
) {
totalCount
edges { node { %s } }
pageInfo { hasNextPage hasPreviousPage startCursor endCursor }
}
}""" % (page, view_type, gql_string(cursor) if cursor else "null",
sort_clause(sort_form, 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"):
# A console that does not know a fieldId or the sort spelling answers
# with an errors array rather than ignoring it, so the next shape is
# tried on the same cursor. Once a page has come back the shape is
# settled and a later error is a real one.
if not settled and attempt + 1 < len(attempts):
attempt += 1
continue
print(json.dumps({"error": "GraphQL error", "detail": resp["errors"]}))
sys.exit(1)
settled = True
pages += 1
alerts = (resp.get("data") or {}).get("alerts") or {}
if total is None:
total = alerts.get("totalCount")
edges = alerts.get("edges") or []
for i, edge in enumerate(edges):
node = edge.get("node")
if not node:
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
# reports the cursor this page was read with: the next run re-reads
# the page and the rule dedups on id, rather than the tail of the
# page being skipped for good.
if len(nodes) >= limit and i + 1 < len(edges):
stopped_mid_page = page_cursor
break
page_info = alerts.get("pageInfo") or {}
cursor = page_info.get("endCursor")
if stopped_mid_page is not None:
break
# 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
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.
print(json.dumps({
"data": nodes,
"pagination": {
# totalCount is the console's count for the filters it applied, so
# with a client-side category it counts more than data[] holds.
"totalCount": total,
"returned": len(nodes),
"hasNextPage": True if stopped_mid_page is not None else bool(page_info.get("hasNextPage")),
"endCursor": (stopped_mid_page or None) if stopped_mid_page is not None else page_info.get("endCursor"),
"pagesRead": pages,
# Which query shape the console accepted — worth reading once per
# 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,
},
}))
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)