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
This commit is contained in:
2026-09-09 12:03:53 +00:00
parent 4cd1cda7cb
commit fe564cec2c
2 changed files with 49 additions and 12 deletions
File diff suppressed because one or more lines are too long
@@ -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,
},
}))