Compare commits
3 Commits
fe564cec2c
...
a26e71f29e
| 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.containerImageName", ocsf_field: "container.image.name" }
|
||||||
- { source_path: "detectionTime.kubernetes.podName", ocsf_field: "container.pod_uuid" }
|
- { source_path: "detectionTime.kubernetes.podName", ocsf_field: "container.pod_uuid" }
|
||||||
# ── Detected process — the actor ──────────────────────────────────
|
# ── 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.cmdLine", ocsf_field: "actor.process.cmd_line" }
|
||||||
- { source_path: "process.parentName", ocsf_field: "actor.process.parent_process.name" }
|
- { source_path: "process.parentName", ocsf_field: "actor.process.parent_process.name" }
|
||||||
- { source_path: "process.file.name", ocsf_field: "actor.process.file.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.sha1", ocsf_field: "file.hashes.sha1" }
|
||||||
- { source_path: "process.file.md5", ocsf_field: "file.hashes.md5" }
|
- { source_path: "process.file.md5", ocsf_field: "file.hashes.md5" }
|
||||||
- { source_path: "process.file.certSubject", ocsf_field: "file.signature.value" }
|
- { 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 ──────────────────────────────────────────────
|
# ── Triage ownership ──────────────────────────────────────────────
|
||||||
- { source_path: "assignee.fullName", ocsf_field: "assignee.name" }
|
- { source_path: "assignee.fullName", ocsf_field: "assignee.name" }
|
||||||
- { source_path: "assignee.email", ocsf_field: "assignee.email_addr" }
|
- { source_path: "assignee.email", ocsf_field: "assignee.email_addr" }
|
||||||
# ── The alert's own file summary, hash type unstated ──────────────
|
# ── The alert's own file summary, hash type unstated ──────────────
|
||||||
- { source_path: "fileName", ocsf_field: "observables.name" }
|
- { source_path: "fileName", ocsf_field: "observables.name" }
|
||||||
- { source_path: "fileHash", ocsf_field: "observables.value" }
|
- { 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 } } }
|
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,
|
_UNITS = {"second": 1, "minute": 60, "hour": 3600, "day": 86400,
|
||||||
"week": 604800, "month": 2592000, "year": 31536000}
|
"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
|
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):
|
def norm(v):
|
||||||
"""Fold a category to letters/digits, so "Platform Rule", "platform rule" and
|
"""Fold a category to letters/digits, so "Platform Rule", "platform rule" and
|
||||||
PLATFORM_RULE all compare equal (used only by the client-side fallback)."""
|
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()
|
search = str(inputs.get("search_text") or "").strip()
|
||||||
if search:
|
if search:
|
||||||
out.append('{ fieldId: "name" match: { values: [%s] } }' % gql_string(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 } }')
|
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" } }')
|
out.append('{ fieldId: "result" stringEqual: { value: "UNMITIGATED" } }')
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -342,6 +403,22 @@ 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
|
||||||
|
|
||||||
|
# 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]
|
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
|
||||||
@@ -363,6 +440,7 @@ def main():
|
|||||||
"accountFilter": (None if not accounts
|
"accountFilter": (None if not accounts
|
||||||
else ("server:" + account_field if account_field else "client")),
|
else ("server:" + account_field if account_field else "client")),
|
||||||
"sortForm": sort_form,
|
"sortForm": sort_form,
|
||||||
|
"details": details,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user