Compare commits

..

3 Commits

Author SHA1 Message Date
f3nris a26e71f29e fix(sentinelone): make the process tree draw itself (v1.15.0)
The block reads actor.process.file.name and would have rendered the chain,
but mapperHasProcessChain — the test that places it when a layout is
generated from a mapper — wants actor.process.name alongside the parent's.
This mapper declared only the file name, so the block was never placed and
the chain went unseen on every SentinelOne incident.

process.file.name now feeds both, and certSubject feeds the node's signer
badge (the block reads file.company_name for it, and certSubject is the only
signing identity a unified alert carries).

Verified by running processTree.ts itself over this mapper's output on the
live ClickFix alert: placement true, and a two-node chain explorer.exe ->
powershell.exe carrying the host, the user, both paths and both sets of
hashes when the fetch ran with_details. Without it the chain still draws,
with the parent named but unhashed — which is all the list nodes say.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Rk9N6vNtp3q7YvLpjcc
2026-09-09 12:42:28 +00:00
f3nris da2fcbba6c fix(sentinelone): map the unified alert as a real one is shaped (v1.14.0)
Checked against a live STAR alert, three of the details mappings were wrong
about where the data actually sits.

Its alert-level observables list is empty while all twelve observables sit
under the indicators, so the mapper saw none. The two lists are appended now
rather than reading the alert level alone.

The same alert has process.username null while its indicators name the user
(src.process.user), and names its parent process without hashing it anywhere
else. actor.user.name and the parent process path and hashes are read from the
indicator observables, with the alert's own field winning where it is filled
in, so nothing regresses on an alert that does populate it.

attacks and relatedAlerts are guarded on $count rather than $exists: that
alert carries "attacks": null, and null exists in JSONata — the entry would
have written an empty array onto every alert without an ATT&CK mapping.

Verified by running the whole mapper through the scheduler's own jsonata-go
build on that payload, with and without with_details: 95 entries, no empty
arrays, and the plain list fetch still falls back to the fileName/fileHash
observable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Rk9N6vNtp3q7YvLpjcc
2026-09-09 12:38:21 +00:00
f3nris 31e596d770 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
2026-09-09 12:32:49 +00:00
3 changed files with 117 additions and 4 deletions
File diff suppressed because one or more lines are too long
@@ -93,6 +93,11 @@ ocsf:
- { source_path: "detectionTime.kubernetes.containerImageName", ocsf_field: "container.image.name" }
- { source_path: "detectionTime.kubernetes.podName", ocsf_field: "container.pod_uuid" }
# ── Detected process — the actor ──────────────────────────────────
# actor.process.name alongside the file name: the process-tree block reads
# either, but places itself automatically only on a mapper that declares
# actor.process.name together with the parent's, and a chain of two named
# processes is exactly what this alert carries.
- { source_path: "process.file.name", ocsf_field: "actor.process.name" }
- { source_path: "process.cmdLine", ocsf_field: "actor.process.cmd_line" }
- { source_path: "process.parentName", ocsf_field: "actor.process.parent_process.name" }
- { source_path: "process.file.name", ocsf_field: "actor.process.file.name" }
@@ -107,9 +112,38 @@ ocsf:
- { source_path: "process.file.sha1", ocsf_field: "file.hashes.sha1" }
- { source_path: "process.file.md5", ocsf_field: "file.hashes.md5" }
- { source_path: "process.file.certSubject", ocsf_field: "file.signature.value" }
# The signer badge on the process node reads company_name; certSubject is the
# only signing identity a unified alert carries.
- { source_path: "process.file.certSubject", ocsf_field: "actor.process.file.company_name" }
# ── Triage ownership ──────────────────────────────────────────────
- { source_path: "assignee.fullName", ocsf_field: "assignee.name" }
- { source_path: "assignee.email", ocsf_field: "assignee.email_addr" }
# ── 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.file.size", ocsf_field: "actor.process.file.size" }
- { source_path: "process.file.size", ocsf_field: "file.size" }
# An indicator's observables carry what the alert body leaves null — on a
# real STAR alert process.username is null while src.process.user is set,
# and the parent process is named but never hashed outside this list.
# [0] takes the first indicator to name each one; they agree in practice.
- { source_path: '(indicators.observables[name="src.process.user"].value)[0]', ocsf_field: "actor.user.name" }
- { source_path: '(indicators.observables[name="src.parent.process.file.path"].value)[0]', ocsf_field: "actor.process.parent_process.file.path" }
- { source_path: '(indicators.observables[name="src.parent.process.file.sha256"].value)[0]', ocsf_field: "actor.process.parent_process.file.hashes.sha256" }
- { source_path: '(indicators.observables[name="src.parent.process.file.sha1"].value)[0]', ocsf_field: "actor.process.parent_process.file.hashes.sha1" }
- { source_path: '(indicators.observables[name="src.parent.process.file.md5"].value)[0]', ocsf_field: "actor.process.parent_process.file.hashes.md5" }
# Last, so the alert's own field wins when it is filled in.
- { source_path: "process.username", ocsf_field: "actor.user.name" }
# [ ] 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: '$count(indicators.attacks) > 0 ? [indicators.attacks.{"tactic": {"name": tactic.name, "uid": tactic.uid}, "technique": {"name": technique.name, "uid": technique.uid}}]', ocsf_field: "finding_info.attacks" }
- { source_path: '$count(relatedAlerts) > 0 ? [relatedAlerts.{"uid": id, "title": name}]', ocsf_field: "finding_info.related_events" }
# The alert-level list is empty on a STAR alert while every observable sits
# under the indicators, so the two are appended rather than one preferred.
- { source_path: '$exists(observables) or $exists(indicators.observables) ? [($append($boolean(observables) ? observables : [], $boolean(indicators.observables) ? indicators.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,
},
}))