From b26257aaeb20d0804d7f0ebce3164f9cd340347c Mon Sep 17 00:00:00 2001 From: Guillaume BOURGEOIS Date: Tue, 8 Sep 2026 18:04:14 +0200 Subject: [PATCH] feat(rapid7-insightidr): render the detection as Markdown, and fix four gaps (v1.10.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An "Multiple User Accounts Were Disabled" investigation came back with one entity — the actor — while the layout stayed nearly empty. Four separate reasons, and the last one is why the first three keep recurring. evidences was an OBJECT, not an array. JSONata returns a bare object when the path yields one item, so the key had one shape on an investigation with a single alert and another with two. Bracketed to force an array. Only the actor was mapped. An ad_admin rule names who acted AND who it was done to, and on this alert the disabled accounts are the point of it. Target account, target user and both domains now map to the OCSF Target User group, the actor to Actor — the distinction that lets the sentence be read in one line. The domain controller was nowhere. A Windows event puts it in source_json.computerName and nowhere else, and source_json was skipped whole because the Azure AD one runs to kilobytes. Its top-level SCALARS are now read — bounded, and where computerName, osVersion and callerIpAddress live — while its nested part stays skipped. source_asset was the string "unknown". Mapped as-is it becomes an asset named "unknown" in the entity index, shared by every incident that had no asset: a pivot that groups things by the absence of a fact. Placeholders are dropped, and a key whose value cleans away to nothing is removed rather than kept empty, since "" is not nil and would still be written to its OCSF field. Five entities now, from one: both accounts, both display names, the DC. And the reason this kept happening. The layout builder's field picker is a closed over the OCSF + catalogue, so a path that is not in it — `detection`, `evidence`, anything + an unanticipated rule emits — cannot be placed on a layout at all. Mapping + each key to a real OCSF field is the right answer where one exists, but + rule_keys_of_interest is an open vocabulary and always will be: three + vocabularies turned up in as many days. + + So the whole of it is also rendered as Markdown and mapped to a custom + field, which a layout CAN show. Nothing is lost to the analyst even when + nothing was mapped, and the mapping work stops being what stands between + a new rule type and a readable incident. + """ + lines = [] + for a in alerts: + title = a.get("type") or a.get("title") or "Alert" + lines.append("### " + str(title)) + rule = a.get("rule") or {} + tcodes = [t for t in (rule.get("mitre_tcodes") or []) if t] + meta = [] + if a.get("triggering_event_type"): + meta.append("`" + str(a["triggering_event_type"]) + "`") + if tcodes: + meta.append("ATT&CK " + ", ".join(str(t) for t in tcodes)) + if a.get("status"): + meta.append("status " + str(a["status"])) + if a.get("disposition"): + meta.append("disposition " + str(a["disposition"])) + if meta: + lines.append(" · ".join(meta)) + rows = [] + seen = set() + # Keys of interest first: the rule itself says these are what matter. + for kv in (a.get("rule_keys_of_interest") or []): + k = kv.get("key") + vals = [str(v) for v in (kv.get("values") or []) if v not in (None, "")] + if k and vals and k not in seen: + seen.add(k) + rows.append((k, ", ".join(vals))) + for k, v in sorted((a.get("evidence_fields") or {}).items()): + if k in seen or k in _MD_SKIP or v in (None, ""): + continue + seen.add(k) + rows.append((k, str(v))) + if rows: + lines.append("") + lines.append("| Field | Value |") + lines.append("| --- | --- |") + for k, v in rows[:_MD_MAX_ROWS]: + if len(v) > _MD_MAX_VALUE: + v = v[:_MD_MAX_VALUE] + "…" + # A pipe inside a value would split the row into a new column. + lines.append("| " + k.replace("|", "\\|") + " | " + v.replace("|", "\\|") + " |") + if len(rows) > _MD_MAX_ROWS: + lines.append("") + lines.append("_%d more field(s) not shown._" % (len(rows) - _MD_MAX_ROWS)) + lines.append("") + return "\n".join(lines).strip() + + def run(): _, _, multi = _cfg() inp = _inputs() @@ -435,6 +531,8 @@ def run(): # 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]. + if alerts: + inv["summary_markdown"] = _markdown_summary(inv, alerts) rolled = [] for a in alerts: rolled.extend(a.get("process_tree") or [])