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:
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.
|
# of the category are tried before falling back to filtering what was fetched.
|
||||||
CATEGORY_FIELD_IDS = ("analytics.category", "analyticsCategory")
|
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
|
# 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
|
# console's schema version, and a wrong guess fails the whole query rather than
|
||||||
# degrading, so both are tried.
|
# 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}
|
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):
|
def string_filter(field, values):
|
||||||
"""Equality on one value, membership on several — an unfiltered field is the
|
"""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
|
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))
|
return '{ fieldId: "%s" dateTimeRange: { %s } }' % (field, " ".join(parts))
|
||||||
|
|
||||||
|
|
||||||
def build_filters(inputs, category_field):
|
def build_filters(inputs, category_field, account_field):
|
||||||
"""Assemble the filter list. category_field is the fieldId to filter the
|
"""Assemble the filter list. category_field and account_field are the fieldIds
|
||||||
analytic category on, or None to leave it to the client-side pass."""
|
to filter on, or None to leave that one to the client-side pass."""
|
||||||
out = []
|
out = []
|
||||||
|
|
||||||
# ── Time windows ──────────────────────────────────────────────────────
|
# ── Time windows ──────────────────────────────────────────────────────
|
||||||
@@ -190,6 +212,12 @@ def build_filters(inputs, category_field):
|
|||||||
if values:
|
if values:
|
||||||
out.append(string_filter(category_field, 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 ─────────────────────────────
|
# ── Free text and the two console toggles ─────────────────────────────
|
||||||
search = str(inputs.get("search_text") or "").strip()
|
search = str(inputs.get("search_text") or "").strip()
|
||||||
if search:
|
if search:
|
||||||
@@ -226,11 +254,14 @@ def main():
|
|||||||
limit = int(inputs.get("limit") or 100)
|
limit = int(inputs.get("limit") or 100)
|
||||||
sort_by = "updatedAt" if str(inputs.get("updated_after") or "").strip() else "createdAt"
|
sort_by = "updatedAt" if str(inputs.get("updated_after") or "").strip() else "createdAt"
|
||||||
categories = csv_list(inputs.get("analytics_category"))
|
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
|
# Query shapes to try, in order, until the console accepts one. A filter that
|
||||||
# category asked for there is nothing to degrade, so only the sort varies.
|
# 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]
|
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
|
attempt = 0
|
||||||
settled = False
|
settled = False
|
||||||
|
|
||||||
@@ -239,14 +270,15 @@ def main():
|
|||||||
pages = 0
|
pages = 0
|
||||||
stopped_mid_page = None
|
stopped_mid_page = None
|
||||||
while len(nodes) < limit and pages < MAX_PAGES:
|
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_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
|
# 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
|
# what is missing. Filtered client-side, most of a page is discarded, so
|
||||||
# read full pages rather than walking one alert at a time.
|
# 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
|
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 ""
|
filter_clause = "filters: [ %s ]" % " ".join(filters) if filters else ""
|
||||||
query = """query Alerts {
|
query = """query Alerts {
|
||||||
alerts(
|
alerts(
|
||||||
@@ -289,6 +321,8 @@ def main():
|
|||||||
continue
|
continue
|
||||||
if client_side_category and not category_matches(node, categories):
|
if client_side_category and not category_matches(node, categories):
|
||||||
continue
|
continue
|
||||||
|
if client_side_accounts and not account_matches(node, accounts):
|
||||||
|
continue
|
||||||
nodes.append(node)
|
nodes.append(node)
|
||||||
# Client-side, a page holds more alerts than the limit leaves room
|
# Client-side, a page holds more alerts than the limit leaves room
|
||||||
# for. A cursor only addresses page boundaries, so stopping here
|
# 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:
|
if not edges or not page_info.get("hasNextPage") or not cursor:
|
||||||
break
|
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
|
# 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
|
# (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.
|
# 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.
|
# tenant: 'client' means the category was filtered after the fetch.
|
||||||
"categoryFilter": (None if not categories
|
"categoryFilter": (None if not categories
|
||||||
else ("server:" + category_field if category_field else "client")),
|
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,
|
"sortForm": sort_form,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
Reference in New Issue
Block a user