feat(sentinelone): absorb the SentinelOne SDL package (v1.11.0)
The SDL package covered the same unified (UAM) alerts from a second
integration: one console, two packages, two instances, two tokens. Its
commands now live here, so it can be dropped.
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 (stringEqual on one value,
stringIn on several), and every time input also reads a relative window
('24 hours') on top of ISO8601 and epochs. The query is byte-identical to
1.9.0's for the inputs an existing alert rule sends, apart from enum values
now being upper-cased — a lowercase severity used to filter nothing.
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,
on the same alertTriggerActions mutation.
analytics_category filters the fetch to one analytic category, e.g. only the
platform-rule alerts (analyticsCategory=Platform Rule, the console's own
filter). The SDL package showed this endpoint takes dotted fieldIds
(asset.osType, assignee.userId) and spells its sort argument differently, so
both spellings of each are tried before giving up: the category degrades to a
filter over the fetched alerts rather than failing the run, and
pagination.categoryFilter / .sortForm report which shape the console accepted.
update_uam_alert_status and _verdict now report a GraphQL error instead of
printing it as a successful update — a playbook that chained on a refused
update used to carry on as if it had gone through.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Rk9N6vNtp3q7YvLpjcc
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
||||
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
|
||||
@@ -6,6 +6,22 @@ import json, os, sys, urllib.request, urllib.parse, urllib.error
|
||||
# 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")
|
||||
|
||||
# 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 = """
|
||||
@@ -38,6 +54,9 @@ NODE_FIELDS = """
|
||||
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
|
||||
@@ -47,16 +66,32 @@ def request(method, url, headers, body=None):
|
||||
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.
|
||||
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
|
||||
@@ -65,7 +100,7 @@ def epoch_ms(v):
|
||||
try:
|
||||
dt = datetime.datetime.fromisoformat(t)
|
||||
except ValueError:
|
||||
raise ValueError("cannot read timestamp %r: expected ISO8601 or an epoch" % s)
|
||||
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)
|
||||
@@ -76,28 +111,104 @@ def gql_string(v):
|
||||
return json.dumps(str(v))
|
||||
|
||||
|
||||
def build_filters(inputs):
|
||||
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 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):
|
||||
"""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."""
|
||||
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))
|
||||
|
||||
# ── 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('{ 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)))
|
||||
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))
|
||||
|
||||
# ── 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", "{}"))
|
||||
@@ -114,62 +225,108 @@ def main():
|
||||
|
||||
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 ""
|
||||
categories = csv_list(inputs.get("analytics_category"))
|
||||
|
||||
# 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.
|
||||
category_modes = list(CATEGORY_FIELD_IDS) + [None] if categories else [None]
|
||||
attempts = [(s, c) for s in SORT_FORMS for c in category_modes]
|
||||
attempt = 0
|
||||
settled = False
|
||||
|
||||
# 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)
|
||||
pages = 0
|
||||
stopped_mid_page = None
|
||||
while len(nodes) < limit and pages < MAX_PAGES:
|
||||
sort_form, category_field = attempts[attempt]
|
||||
client_side_category = bool(categories) and category_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_cursor = cursor
|
||||
filters = build_filters(inputs, category_field)
|
||||
filter_clause = "filters: [ %s ]" % " ".join(filters) if filters else ""
|
||||
query = """query Alerts {
|
||||
alerts(
|
||||
first: %d
|
||||
viewType: %s
|
||||
after: %s
|
||||
sort: { by: "%s", order: ASC }
|
||||
%s
|
||||
%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)
|
||||
}""" % (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 edge in edges:
|
||||
for i, edge in enumerate(edges):
|
||||
node = edge.get("node")
|
||||
if node:
|
||||
nodes.append(node)
|
||||
if not node:
|
||||
continue
|
||||
if client_side_category and not category_matches(node, categories):
|
||||
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 = 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": bool(page_info.get("hasNextPage")),
|
||||
"endCursor": page_info.get("endCursor"),
|
||||
"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")),
|
||||
"sortForm": sort_form,
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user