feat(rapid7-insightidr): feed the layout Process Tree block (v1.9.0)

Rapid7 exposes a process tree per alert; the layout renderer has a block that
draws one. They do not speak the same shape.

The block reads ctx.process_tree as N FLAT chains, root first, each
{alert_id, rule, host, user, nodes[]} — it merges shared ancestors itself.
Rapid7 answers with a NESTED tree: one root entity and children[]. So the
command walks each root-to-leaf path into its own chain, maps the node fields
(cmdline -> cmd_line, hashes.md5 -> md5, start_time -> started_at, pid coerced
from the string Rapid7 sends), and rolls every alert's chains up onto the
investigation, since the block reads the key at the top level and no mapper
expression reaches past alerts[0].

Verified through the block's own reader: a tree of explorer -> cmd ->
{bcdedit, vssadmin} flattens to two chains and comes back as one tree with the
ancestors shared and both leaves marked.

That mark is worth stating plainly. The block marks the last node of a chain as
the target, and Rapid7 flags no node as the one the alert fired on — so here it
means "end of this branch" and nothing stronger. Naming a target would assert
something the sensor never said.

Gated, because this is a third call per alert on top of the evidence one:
include_process_tree=auto (the default) asks only for the event types an
endpoint sensor produces a tree for, true asks for every alert, false never.
An authentication or firewall alert never has one, and on a feed where those
dominate the default saves a call per alert. 404 is treated as "no tree", which
is what it means, and only other statuses are recorded as errors. Bounded at 12
chains and 24 levels so an unexpected shape cannot expand without limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 15:53:22 +02:00
parent 995a39ea66
commit 1888648930
3 changed files with 138 additions and 2 deletions
@@ -169,6 +169,95 @@ def _evidence_fields(ev):
return fields, (source if isinstance(source, str) else raw)
# Event types worth asking a process tree for. The endpoint sensor produces one;
# an authentication or firewall alert never will, and on a feed where those
# dominate, asking anyway is one wasted call per alert. include_process_tree=true
# overrides this and asks for every alert.
_PROCESS_EVENT_TYPES = {
"process_start_event", "advanced_malware", "virus",
"endpoint_activity", "file_modification", "local_service_installed",
}
# Guards on a tree of unknown shape: a wide one would otherwise expand into more
# chains than anybody reads, and a cycle would not terminate at all.
_MAX_CHAINS = 12
_MAX_DEPTH = 24
def _flatten_tree(root, out, path=None, depth=0):
"""Rapid7 nests a process tree through `children`; the layout block reads a
flat chain, root first. So each root-to-leaf path becomes one chain.
The block marks the LAST node of a chain as the target. Rapid7 flags no node
as the one the alert fired on, so that mark means "end of this branch" here
and nothing stronger — inventing a target would assert something the sensor
never said.
"""
if not isinstance(root, dict) or depth > _MAX_DEPTH or len(out) >= _MAX_CHAINS:
return
name = root.get("name")
node = {}
if name:
node["name"] = str(name)
if root.get("cmdline"):
node["cmd_line"] = str(root["cmdline"])
pid = root.get("pid")
if pid not in (None, ""):
try:
node["pid"] = int(str(pid))
except ValueError:
pass
h = root.get("hashes") or {}
if isinstance(h, dict):
for k in ("md5", "sha256"):
if h.get(k):
node[k] = str(h[k])
if root.get("start_time") not in (None, ""):
node["started_at"] = root["start_time"]
if root.get("username"):
node["_username"] = str(root["username"])
chain = (path or []) + ([node] if node else [])
children = [c for c in (root.get("children") or []) if isinstance(c, dict)]
if not children:
if chain:
out.append(chain)
return
for c in children:
_flatten_tree(c, out, chain, depth + 1)
def _tree_chains(payload, alert):
"""The process_tree block's own shape: N chains, each with its nodes."""
chains = []
for tree in (payload.get("process_trees") or []):
if not isinstance(tree, dict):
continue
paths = []
_flatten_tree(tree.get("process_tree"), paths)
for nodes in paths:
user = ""
for n in nodes:
user = n.pop("_username", user) or user
if not nodes:
continue
chain = {"nodes": nodes, "alert_id": tree.get("alert_rrn") or alert.get("rrn")}
if user:
chain["user"] = user
if alert.get("type"):
chain["rule"] = alert["type"]
host = (alert.get("evidence_fields") or {}).get("devname") or (alert.get("evidence_fields") or {}).get("hostname")
if not host:
for kv in (alert.get("rule_keys_of_interest") or []):
if kv.get("key") == "hostname" and kv.get("values"):
host = kv["values"][0]
if host:
chain["host"] = host
chains.append(chain)
if len(chains) >= _MAX_CHAINS:
return chains
return chains
def run():
_, _, multi = _cfg()
inp = _inputs()
@@ -270,12 +359,49 @@ def run():
a["evidence_fields"] = evs[0].get("fields") or {}
a["evidence_raw_line"] = evs[0].get("raw_line")
# ⑤ Process trees. A third call per alert, so it is gated: "auto" asks only
# for the event types an endpoint sensor produces one for, "true" asks
# always, "false" never. Chains are attached to the alert AND to the
# investigation, because the layout block reads ctx.process_tree at the
# top level.
tree_mode = str(inp.get("include_process_tree") or "auto").strip().lower()
tree_calls, tree_errors = 0, 0
if tree_mode != "false":
for rrn, a in by_rrn.items():
if time.monotonic() - started > budget:
truncated = True
break
if tree_mode == "auto" and str(a.get("triggering_event_type") or "") not in _PROCESS_EVENT_TYPES:
continue
try:
page = request("POST", AT + "alerts/" + urllib.parse.quote(rrn, safe="") + "/process_trees/latest",
params={"index": "0", "size": "5"})
tree_calls += 1
except urllib.error.HTTPError as e:
# 404 is the normal answer for an alert that has no tree, not a
# fault: recorded only when it is something else.
if e.code != 404:
a["process_tree_error"] = "HTTP " + str(e.code)
tree_errors += 1
continue
chains = _tree_chains(page, a)
if chains:
a["process_tree"] = chains
fetched = 0
for inv in investigations:
rrns = rrns_by_inv.get(inv.get("rrn") or "", [])
alerts = [by_rrn[r] for r in rrns if r in by_rrn]
inv["alerts"] = alerts
inv["alert_count"] = len(rrns)
# The block reads ctx.process_tree at the top level and draws one chain
# per alert, so every alert's chains are gathered here rather than left
# nested where no mapper expression could reach past alerts[0].
rolled = []
for a in alerts:
rolled.extend(a.get("process_tree") or [])
if rolled:
inv["process_tree"] = rolled[:_MAX_CHAINS]
# Say it when the alert list is a sample: alert_count is what the
# investigation actually has, len(alerts) what this run retrieved.
if len(alerts) < len(rrns):
@@ -316,6 +442,8 @@ def run():
"alerts_fetched": fetched,
"evidence_calls": evidence_calls,
"evidence_errors": evidence_errors,
"process_tree_calls": tree_calls,
"process_tree_errors": tree_errors,
"alert_ids_without_rrn": skipped,
"truncated": truncated,
# HTTP 200 with a non-empty region_failures is a PARTIAL result: