Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a26e71f29e | |||
| da2fcbba6c | |||
| 31e596d770 |
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,
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user