Files
riposte-marketplace/integrations/rapid7-insightidr/scripts/get_investigations_full.py
T
f3nris 69e3a3e81c fix(rapid7-insightidr): do not send a bare UUID to /alerts/ops/rrns (v1.4.1)
The run died with HTTP 400 and eight "The provided RRN: [...] is invalid",
every one a plain UUID. Two mistakes compounded.

The alert list under an investigation is not consistent about where the RRN
sits. On the ABA alert this work was built against, `id` held the RRN. On
others it holds a bare UUID. The code read `id` first and `rrn` only as a
fallback, so it picked the UUID whenever both were present.

Worse, the failure was total rather than partial. /alerts/ops/rrns validates the
whole list and rejects the entire request when one entry is malformed, so a
single bad identifier costs every alert in that batch — and with one batch, the
whole run. An investigation feed that stops dead because one alert is shaped
differently is not an acceptable failure mode.

So: read `rrn` first, and drop anything that is not RRN-shaped before the batch
is sent, counting it as alert_ids_without_rrn in the run metadata. The
investigation still arrives, its retrievable alerts still arrive, and what could
not be fetched is a number an operator can see rather than an absence nobody
would notice.

Both commands that walk this list are fixed; the selector is shared and was
checked against the two real shapes plus the empty and null cases.

This turns a total failure into a partial one. It does NOT establish that those
eight alerts are reachable at all: if they carry no RRN anywhere, they are not
in the Alert Triage system and no field_ids or batch call will produce them.
That needs the raw shape of one, which the next run of list_investigation_alerts
on the failing investigation will show.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 23:52:51 +02:00

214 lines
8.9 KiB
Python

import json, os, sys, urllib.request, urllib.parse, urllib.error
# The Alert Triage API lives under /idr/at, a base of its own — not /idr/v1 or
# /idr/v2 like the investigation endpoints. Same host, same API key.
AT = "idr/at/"
def _cfg():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
region = str(s.get("region") or "us").strip().lower()
base = "https://" + region + ".api.insight.rapid7.com/"
headers = {"X-Api-Key": s.get("api_key", ""), "Content-Type": "application/json", "Accept": "application/json"}
multi = str(s.get("is_multi_customer") or "").strip().lower() in ("1", "true", "yes")
return base, headers, multi
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _list(v):
if v in (None, ""):
return []
if isinstance(v, list):
return [str(x).strip() for x in v if str(x).strip()]
return [p.strip() for p in str(v).split(",") if p.strip()]
def request(method, path, params=None, body=None):
base, headers, _ = _cfg()
url = base + path.lstrip("/")
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(clean, doseq=True)
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
import time
from datetime import datetime, timedelta, timezone
ISO = "%Y-%m-%dT%H:%M:%SZ"
RRN_BATCH = 100
DEFAULT_BUDGET_S = 60
# idr/v2/investigations answers with 13 fields, three of them null: a title, a
# priority, a status, an assignee and timestamps. No host, no account, no
# process, no MITRE code — and not one of the alerts the investigation
# aggregates. An incident built from it indexes zero entities, so it cannot be
# pivoted on, cannot be correlated, and gives an analyst nothing to read.
#
# The detection content is two hops away: the investigation's alert list gives
# RRNs, and the Alert Triage API turns those into alerts carrying
# rule_keys_of_interest (the host, the process, its command line, its parent,
# the hashes) and rule.mitre_tcodes. This command walks both hops and returns
# the investigation with its alerts inlined, so the INVESTIGATION stays the unit
# that becomes an incident — results_path = data, dedup on rrn, exactly as the
# plain investigation feed. Only the command changes.
def _alert_rrn(a):
"""The alert's RRN, or None when this alert has none.
The investigation alert list is not consistent about where the RRN sits.
Some entries carry it in `id` (rrn:alerts:eu:<org>:alert:1:<hex>); others put
a plain UUID there and the RRN, if any, in `rrn`. `rrn` is therefore read
first, and anything that is not RRN-shaped is dropped rather than sent on.
That last part is not defensive habit. /alerts/ops/rrns validates the whole
list and rejects the entire request when one entry is malformed, so a single
UUID slipping through costs every alert in the batch — which is exactly how
this failed: HTTP 400, eight invalid RRNs, zero alerts fetched, the whole run
lost. Dropping them here means the investigation still arrives, with its
unretrievable alerts counted rather than silently missing.
"""
for v in (a.get("rrn"), a.get("id")):
if isinstance(v, str) and v.startswith("rrn:"):
return v
return None
def run():
_, _, multi = _cfg()
inp = _inputs()
limit = int(inp.get("limit") or 50)
max_alerts = int(inp.get("max_alerts") or 500)
budget = float(inp.get("time_budget_seconds") or DEFAULT_BUDGET_S)
started = time.monotonic()
start_time = inp.get("start_time")
if not start_time:
days = int(inp.get("default_days") or 1)
start_time = (datetime.now(timezone.utc) - timedelta(days=days)).strftime(ISO)
inv_page = request("GET", "idr/v2/investigations", params={
"index": inp.get("index") or "0",
"size": inp.get("page_size") or limit,
"statuses": inp.get("statuses"),
"priorities": inp.get("priorities"),
"start_time": start_time,
"end_time": inp.get("end_time"),
"sort_field": "Created time",
"sort_direction": "ASC",
"multi-customer": "true" if multi else None,
})
investigations = (inv_page.get("data") or [])[:limit]
# Alert RRNs per investigation. One call each — there is no bulk form — so
# this is the step that grows with the window, and the one the time budget
# guards. An investigation whose alerts cannot be read keeps its place in the
# output with an alerts_error: losing the whole run over one of them, or
# dropping it silently, would both be worse.
rrns_by_inv, order, truncated, skipped = {}, [], False, 0
for inv in investigations:
inv_rrn = inv.get("rrn") or ""
if not inv_rrn:
continue
if time.monotonic() - started > budget or len(order) >= max_alerts:
truncated = True
break
try:
page = request("GET", "idr/v2/investigations/" + urllib.parse.quote(inv_rrn, safe="") + "/alerts",
params={"multi-customer": "true" if multi else None})
except urllib.error.HTTPError as e:
inv["alerts_error"] = "HTTP " + str(e.code)
continue
got = []
for a in (page.get("data") or []):
rrn = _alert_rrn(a)
if rrn is None:
skipped += 1
continue
got.append(rrn)
if rrn not in order:
order.append(rrn)
rrns_by_inv[inv_rrn] = got
# The alerts themselves, batched, then indexed so each investigation can be
# handed its own.
field_ids = _list(inp.get("field_ids"))
by_rrn = {}
for i in range(0, len(order), RRN_BATCH):
body = {"rrns": order[i:i + RRN_BATCH]}
if field_ids:
body["field_ids"] = field_ids
out = request("POST", AT + "alerts/ops/rrns", body=body)
for a in (out.get("alerts") or []):
if a.get("rrn"):
by_rrn[a["rrn"]] = a
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)
# 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):
inv["alerts_truncated"] = True
fetched += len(alerts)
# Lift a few first-alert values to the top level. The incident-field
# mapper resolves dotted paths only — it cannot index an array — so
# `alerts[0].ai_suggested_disposition` in a field_mappings entry reads as
# nothing, silently, and the custom field arrives empty with no error
# anywhere. The OCSF entries are evaluated by full JSONata and do not
# need this; only field_mappings does.
if alerts:
a = alerts[0]
log = (a.get("log_details") or [{}])[0]
inv.update({
"first_alert_rrn": a.get("rrn"),
"first_alert_type": a.get("type"),
"first_alert_source": a.get("external_source"),
"first_alert_status": a.get("status"),
"first_alert_disposition": a.get("disposition"),
"first_alert_ai_disposition": a.get("ai_suggested_disposition"),
"first_alert_event_type": a.get("triggering_event_type"),
"first_alert_rule_rrn": (a.get("rule") or {}).get("rrn"),
"first_alert_log_id": log.get("log_id"),
"first_alert_logset_id": log.get("logset_id"),
"first_alert_log_entry_id": log.get("log_entry_id"),
})
print(json.dumps({
"data": investigations,
"metadata": {
"investigations_returned": len(investigations),
"alert_rrns_collected": len(order),
"alerts_fetched": fetched,
"alert_ids_without_rrn": skipped,
"truncated": truncated,
"elapsed_seconds": round(time.monotonic() - started, 1),
},
}))
try:
run()
except urllib.error.HTTPError as e:
detail = e.read().decode("utf-8", "replace")
if e.code in (401, 403):
detail += (" — the Alert Triage API (/idr/at) is only available to Managed Detection and"
" Response customers with access to the Alerts experience; a key that works on"
" the investigation endpoints can still be refused here")
print(json.dumps({"error": "HTTP " + str(e.code), "detail": detail}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)