feat(sentinelone): with_details on the unified alerts fetch (v1.13.0)
The list nodes carry no indicator, no ATT&CK mapping, no observable and no related alert — everything that says why the console fired, and the analyst opens the alert in SentinelOne to find it. with_details reads the per-alert view for every alert the fetch returns and folds it into the alert, so the incident is created with that context instead of a title and a severity. Read in aliased batches of ten, so a default-limit fetch costs ten extra requests rather than a hundred. Merged under the list node — the list is the authority on the fields both carry, the detail only fills in — so the richer list asset/process survive the merge and gain process.username and file.size. Best effort: an alert the console will not detail keeps its list fields and the shortfall is reported in pagination.details, rather than failing a fetch whose alerts are already in hand. Off by default, and verified query-identical to 1.12.0 for a rule that does not set it. The mapper gains evidences, finding_info.attacks, finding_info.related_events and the full observables list. Each is guarded with $exists and wrapped in [ ] — checked against the jsonata-go build the scheduler uses: the brackets keep a single indicator from collapsing into a bare object, and the guard leaves the entry undefined on a plain list fetch so it cannot write an empty array over the file summary those alerts do carry. 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
@@ -113,3 +113,17 @@ ocsf:
|
||||
# ── The alert's own file summary, hash type unstated ──────────────
|
||||
- { source_path: "fileName", ocsf_field: "observables.name" }
|
||||
- { source_path: "fileHash", ocsf_field: "observables.value" }
|
||||
# ── Only present when the fetch ran with_details ──────────────────
|
||||
# Absent paths are skipped at ingestion, so these stay inert on a plain
|
||||
# list fetch. observables comes last on purpose: with the detail read, the
|
||||
# full list replaces the single file summary set just above.
|
||||
- { source_path: "process.username", ocsf_field: "actor.user.name" }
|
||||
- { source_path: "process.file.size", ocsf_field: "actor.process.file.size" }
|
||||
- { source_path: "process.file.size", ocsf_field: "file.size" }
|
||||
# [ ] forces a list of one to stay a list; the $exists guard keeps an empty
|
||||
# or absent value undefined, so a plain list fetch skips these entries
|
||||
# instead of writing an empty array over the file summary above.
|
||||
- { source_path: '$exists(indicators) ? [indicators]', ocsf_field: "evidences" }
|
||||
- { source_path: '$exists(indicators.attacks) ? [indicators.attacks.{"tactic": {"name": tactic.name, "uid": tactic.uid}, "technique": {"name": technique.name, "uid": technique.uid}}]', ocsf_field: "finding_info.attacks" }
|
||||
- { source_path: '$exists(relatedAlerts) ? [relatedAlerts.{"uid": id, "title": name}]', ocsf_field: "finding_info.related_events" }
|
||||
- { source_path: '$exists(observables) ? [observables.{"name": name, "value": value, "type": type}]', ocsf_field: "observables" }
|
||||
|
||||
@@ -60,6 +60,21 @@ NODE_FIELDS = """
|
||||
realTime { scope { account { id name } group { id name } site { id name } } }
|
||||
"""
|
||||
|
||||
# Only what the list nodes do not already carry, so merging a detail onto its
|
||||
# node cannot overwrite the richer list fields with the detail's shorter ones.
|
||||
DETAIL_FIELDS = """
|
||||
indicators { uid type message severity eventTime
|
||||
attacks { tactic { name uid } technique { name uid } }
|
||||
observables { name value type typeName } }
|
||||
observables { name value type typeName }
|
||||
relatedAlerts { id name }
|
||||
process { username file { size } }
|
||||
"""
|
||||
|
||||
# Details are read in aliased batches: one alert per request would cost 100
|
||||
# round trips on a default-limit fetch.
|
||||
DETAIL_BATCH = 10
|
||||
|
||||
_UNITS = {"second": 1, "minute": 60, "hour": 3600, "day": 86400,
|
||||
"week": 604800, "month": 2592000, "year": 31536000}
|
||||
|
||||
@@ -126,6 +141,52 @@ def csv_list(v, upper=False):
|
||||
return [x.upper() for x in out] if upper else out
|
||||
|
||||
|
||||
def truthy(v):
|
||||
return str(v).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def deep_merge(base, extra):
|
||||
"""Fold extra into base without overwriting what base already says: the list
|
||||
node is the authority on the fields both carry, the detail only fills in."""
|
||||
for k, v in (extra or {}).items():
|
||||
cur = base.get(k)
|
||||
if isinstance(cur, dict) and isinstance(v, dict):
|
||||
deep_merge(cur, v)
|
||||
elif cur in (None, "", [], {}):
|
||||
base[k] = v
|
||||
return base
|
||||
|
||||
|
||||
def fetch_details(base, headers, ids):
|
||||
"""Read the per-alert detail for ids, as {id: detail}. Best effort: an alert
|
||||
the console will not detail is left with its list fields rather than failing
|
||||
the fetch that already succeeded."""
|
||||
out, errors = {}, []
|
||||
for start in range(0, len(ids), DETAIL_BATCH):
|
||||
chunk = ids[start:start + DETAIL_BATCH]
|
||||
aliases = {"d%d" % i: aid for i, aid in enumerate(chunk)}
|
||||
query = "query AlertDetails {\n%s\n}" % "\n".join(
|
||||
" %s: alert(id: %s) { %s }" % (alias, gql_string(aid), DETAIL_FIELDS)
|
||||
for alias, aid in aliases.items())
|
||||
resp = request("POST", base + "/unifiedalerts/graphql", headers, {"query": query})
|
||||
for alias, value in ((resp.get("data") or {}) or {}).items():
|
||||
if value and alias in aliases:
|
||||
out[aliases[alias]] = value
|
||||
if resp.get("errors"):
|
||||
errors.extend(resp["errors"])
|
||||
# A batch answers partially: one unreadable alert nulls its alias and
|
||||
# leaves the rest. Retry only what is still missing, one at a time,
|
||||
# so a single bad id does not cost the whole batch.
|
||||
for aid in [a for a in chunk if a not in out]:
|
||||
single = request("POST", base + "/unifiedalerts/graphql", headers,
|
||||
{"query": "query AlertDetail { alert(id: %s) { %s } }"
|
||||
% (gql_string(aid), DETAIL_FIELDS)})
|
||||
value = (single.get("data") or {}).get("alert")
|
||||
if value:
|
||||
out[aid] = value
|
||||
return out, errors
|
||||
|
||||
|
||||
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)."""
|
||||
@@ -222,9 +283,9 @@ def build_filters(inputs, category_field, account_field):
|
||||
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"):
|
||||
if truthy(inputs.get("unassigned_only")):
|
||||
out.append('{ fieldId: "assignee.userId" isNegated: true stringEqual: { value: null } }')
|
||||
if str(inputs.get("unmitigated_only") or "").strip().lower() in ("1", "true", "yes"):
|
||||
if truthy(inputs.get("unmitigated_only")):
|
||||
out.append('{ fieldId: "result" stringEqual: { value: "UNMITIGATED" } }')
|
||||
return out
|
||||
|
||||
@@ -342,6 +403,22 @@ def main():
|
||||
if not edges or not page_info.get("hasNextPage") or not cursor:
|
||||
break
|
||||
|
||||
# Enrichment runs on the alerts actually returned, once the walk is over, so
|
||||
# the alerts dropped by a client-side filter cost nothing.
|
||||
details = {"requested": 0, "fetched": 0}
|
||||
if truthy(inputs.get("with_details")) and nodes:
|
||||
ids = [n.get("id") for n in nodes if n.get("id")]
|
||||
details["requested"] = len(ids)
|
||||
fetched, detail_errors = fetch_details(base, headers, ids)
|
||||
details["fetched"] = len(fetched)
|
||||
for node in nodes:
|
||||
deep_merge(node, fetched.get(node.get("id")) or {})
|
||||
# Alerts without their detail are still returned — an ingestion that got
|
||||
# the alerts but not their indicators beats one that got nothing — so the
|
||||
# shortfall is reported rather than raised.
|
||||
if detail_errors and len(fetched) < len(ids):
|
||||
details["error"] = detail_errors[0].get("message", str(detail_errors[0]))
|
||||
|
||||
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
|
||||
@@ -363,6 +440,7 @@ def main():
|
||||
"accountFilter": (None if not accounts
|
||||
else ("server:" + account_field if account_field else "client")),
|
||||
"sortForm": sort_form,
|
||||
"details": details,
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user