Compare commits

...

3 Commits

Author SHA1 Message Date
f3nris 220fa75b99 feat(rapid7-insightidr): ingest only the alerts that belong to an investigation (v1.3.0)
Ingesting from search_alerts brings back every alert the tenant produces,
including the operational noise — two of the first three sampled were an agent
reporting it lacked Full Disk Access. What is worth an incident is what Rapid7
or the analyst folded into an investigation.

Filtering search_alerts on investigation_rrn does not work. The term is accepted
— no validation error, HTTP 200 — and matches nothing: zero alerts over fifteen
days, on a tenant where an alert carrying an investigation_rrn provably exists
in that window. The field is not searchable, and the API says so by returning an
empty page rather than by refusing the term. That failure mode is the reason not
to build an ingestion feed on it: a filter that silently stops matching drains
the feed to zero with no error anywhere, and nothing would ever say why.

search_investigation_alerts is guaranteed by construction instead:

  1. the investigations in the window (incremental on their created_time)
  2. their alert RRNs, one call per investigation — there is no bulk form
  3. the alerts themselves via /alerts/ops/rrns, batched 100 at a time

So 1+N+1 calls, and an alert with no investigation is never even named. Its
failure modes are loud: an unreadable investigation is skipped and counted, and
HTTP errors surface as themselves.

Step 2 is the one that grows with the window, so it runs under a time budget
(60s, against the server's 90s ceiling) and reports truncated=true rather than
being killed with the whole run's alerts lost. A partial fetch that says it is
partial beats a silent zero — the same reasoning that rules out the filter.

The output shape matches search_alerts exactly, so the mapper committed in 1.2.0
serves both. It gains five entries for the parent investigation, which resolve
to nothing on the search_alerts feed and are skipped there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 23:34:57 +02:00
f3nris 7e231c7c78 fix(rapid7-insightidr): name the input when terms_json will not parse (v1.2.1)
json.loads answers a bad value with "Expecting value: line 1 column 1 (char 0)",
which names neither the input at fault nor what it actually received. The
command has three string inputs; nothing in that message says which one broke,
so the operator is left guessing.

The mistake it hides is the obvious one. Documentation shows the input name and
the value together, so `terms_json: [{...}]` gets pasted whole into the value
field, and the parser then chokes on the field name it was handed. Echoing the
first 80 characters back makes that visible immediately.

Also carry a worked example in the manifest — the term that keeps only alerts
attached to an investigation, which is the filter this input mainly exists for —
and say explicitly to paste the array alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 23:31:27 +02:00
f3nris b6903d4590 feat(rapid7-insightidr): map alerts to OCSF, with real observables (v1.2.0)
An incident built from a Rapid7 investigation indexed zero entities. Nothing to
pivot on, nothing to correlate, and an AI analysis with nothing to read. The
reason turned out not to be the mapper but the feed: an investigation is an
envelope, and neither it nor its /alerts list carries a host, an account, a
process or a MITRE code.

The Alert Triage API does, in rule_keys_of_interest. On the alert behind the
investigation that started this, that field holds:

  hostname                            NBSGIT01-26
  process.name                        bcdedit.exe
  process.cmd_line                    bcdedit  /set {default} recoveryenabled No
  process.exe_path                    C:\WINDOWS\System32\bcdedit.exe
  parent_process.name                 cmd.exe
  parent_process.cmd_line             "C:\WINDOWS\system32\cmd.exe"
  parent_process.exe_file.hashes.md5  e463e36104d22a2479046a7d4f620816

with rule.mitre_tcodes ["TA0040", "T1490"] — Impact / Inhibit System Recovery,
which is what disabling Windows recovery actually is. None of it was reachable
before.

It is a list of {key, values} pairs rather than an object, so every entry reads
through a JSONata predicate — rule_keys_of_interest[key='hostname'].values[0].
A key a rule does not emit resolves to nothing and is skipped, so one file
covers every detection rule.

Two details worth keeping:

  - mitre_tcodes mixes tactics and techniques in one array ("TA0040" beside
    "T1490"). They are split on the "TA" prefix so a tactic never lands in the
    technique field, where it would name a technique that does not exist.

  - rule.name is documented but absent from live ABA alerts, so the analytic
    name falls back to `type`, which repeats the rule's display name.

Rapid7's own status, disposition and AI-suggested disposition ride as custom
fields. They cannot reach the incident's status and verdict columns — MapFields
accepts only title, severity, source, description and custom:* — but an analyst
can at least see that the source already closed the alert and what it decided.
The AI suggestion is exposed as a fact, never as a verdict.

Validated end to end against a live alert: field mapping, OCSF mapping and
entity extraction, which now yields the host and the parent process hash where
it previously yielded nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 23:07:53 +02:00
4 changed files with 324 additions and 5 deletions
+28 -3
View File
@@ -1,8 +1,8 @@
id: rapid7_insightidr
name: Rapid7 InsightIDR
version: 1.1.1
version: 1.3.0
description: "Rapid7 InsightIDR (REST API) — investigation ingestion and full lifecycle (list/get/search/create/update/assign/set-status/bulk-close), investigation alerts and Rapid7 product alerts, custom threat indicators (add/replace), log management and LEQL log/log-set queries with downloads, and user directory search."
changelog: "1.1.1 — list_alert_fields: default path_depth to 1 and expose the search filter. The V2 fields endpoint refuses an unbounded walk of the tree (\"root path with path depth greater than 1 is not supported for V2 without a search filter\"), so the bare call returned HTTP 400. | 1.1.0 — Add the Alert Triage API (/idr/at): search alerts, get an alert, its evidence, its actors and its process trees, plus the tenant's alert field catalogue. These carry the detection content the investigation endpoints do not — MITRE T-codes, matched rule keys, assets and accounts, command lines. MDR-gated. | 1.0.0 — Initial release: investigation ingestion (list_investigations) with an exhaustive OCSF mapper, 18 commands across investigations, alerts, custom threats, logs/LEQL search and users. API v1/v2 selectable per instance and per command."
changelog: "1.3.0 — Add search_investigation_alerts: ingest only the alerts that belong to an investigation, with their full detection content, in 1+N+1 calls. Filtering search_alerts on investigation_rrn does not work — the term is accepted and silently matches nothing. | 1.2.1 — search_alerts: report a malformed terms_json with the input named and echoed, instead of json's bare \"Expecting value: line 1 column 1\". | 1.2.0 — Add the search_alerts OCSF mapper: rule_keys_of_interest carries the host, the process, its command line, its parent and the file hashes, so an alert-based feed indexes entities where the investigation feed indexed none. Validated against a live ABA alert. | 1.1.1 — list_alert_fields: default path_depth to 1 and expose the search filter. The V2 fields endpoint refuses an unbounded walk of the tree (\"root path with path depth greater than 1 is not supported for V2 without a search filter\"), so the bare call returned HTTP 400. | 1.1.0 — Add the Alert Triage API (/idr/at): search alerts, get an alert, its evidence, its actors and its process trees, plus the tenant's alert field catalogue. These carry the detection content the investigation endpoints do not — MITRE T-codes, matched rule keys, assets and accounts, command lines. MDR-gated. | 1.0.0 — Initial release: investigation ingestion (list_investigations) with an exhaustive OCSF mapper, 18 commands across investigations, alerts, custom threats, logs/LEQL search and users. API v1/v2 selectable per instance and per command."
category: siem
# Per-instance configuration. The Insight platform region selects the API host
@@ -337,7 +337,7 @@ commands:
search_field: { type: string, description: "Comma-separated field ids for a single search term" }
search_values: { type: string, description: "Comma-separated values the search_field must match (OR'ed together)" }
search_operator: { type: string, description: "EQUALS, NOT_EQUALS, CONTAINS, GREATER_THAN or LESS_THAN (default EQUALS)" }
terms_json: { type: string, description: "Raw JSON array of search terms, for criteria the simple triple cannot express. Takes precedence over search_field/search_values." }
terms_json: { type: string, description: "Raw JSON array of search terms, for criteria the simple triple cannot express — e.g. [{\"field_ids\":[\"investigation_rrn\"],\"operator\":\"CONTAINS\",\"terms\":[\"rrn:investigation\"]}] to keep only alerts attached to an investigation. Paste the array alone, without the field name. Takes precedence over search_field/search_values." }
sort_field: { type: string, description: "Field id to sort on" }
sort_direction: { type: string, description: "ASC or DESC (default ASC)" }
rrns_only: { type: string, description: "Return only alert RRNs instead of full details (true/false)" }
@@ -414,6 +414,31 @@ commands:
required: []
outputs_schema: { properties: {} }
- id: search_investigation_alerts
name: rapid7-insight-idr-search-investigation-alerts
description: "Ingestion: the alerts that belong to an investigation, with their full detection content. Walks the investigations in a time window, collects their alert RRNs and retrieves the alerts in batches from the Alert Triage API — so an alert Rapid7 never folded into an investigation is never returned. Results path = alerts, dedup on rrn, incremental on start_time (the investigation's creation time). Use the search_alerts mapper: the alert objects are identical, with the parent investigation carried alongside under `investigation`."
risk: read
inputs_schema:
properties:
start_time: { type: string, description: "ISO-8601 lower bound on investigation created_time. Incremental fetch watermark." }
end_time: { type: string, description: "ISO-8601 upper bound on investigation created_time" }
default_days: { type: string, description: "Window size in days used when start_time is absent (default 1)" }
statuses: { type: string, description: "Comma-separated investigation statuses to include (open, investigating, closed). Leaving out closed skips what the source has already triaged." }
priorities: { type: string, description: "Comma-separated investigation priorities to include (Unspecified, Low, Medium, High, Critical)" }
field_ids: { type: string, description: "Comma-separated additional alert field ids to include (see rapid7-insight-idr-list-alert-fields)" }
include_investigation: { type: string, description: "Carry the parent investigation on each alert under `investigation` (true/false, default true)" }
max_alerts: { type: string, description: "Ceiling on alert RRNs collected in one run (default 500)" }
time_budget_seconds: { type: string, description: "Stop walking investigations after this many seconds and report truncated=true, rather than exceed the server's 90-second command budget and lose the whole run (default 60)" }
index: { type: string, description: "0-based page index of investigations" }
page_size: { type: string, description: "Investigation page size" }
limit: { type: number, description: "Maximum number of investigations to walk (default 50)" }
required: []
outputs_schema: { properties: {} }
ingest:
results_path: alerts
dedup_key: rrn
incremental_field: start_time
# ── Connectivity test ─────────────────────────────────────────────────────
- id: test_connection
name: rapid7-insight-idr-test-connection
@@ -0,0 +1,126 @@
name: "Rapid7 InsightIDR Alerts → OCSF"
description: |
Maps one Rapid7 alert from the Alert Triage API to OCSF finding fields. Serves both
search_alerts and search_investigation_alerts (results_path = alerts on either): the
alert objects are identical, and the second command simply carries the parent
investigation alongside under `investigation`.
This mapper exists because the investigation feed cannot carry a detection. An
investigation, and the /investigations/{rrn}/alerts list behind it, hold a title, a
priority, a status and timestamps — no host, no account, no process, no MITRE code. An
incident built from one indexes zero entities, so it cannot be pivoted on, cannot be
correlated, and gives an analyst nothing to read.
The alert carries all of it in rule_keys_of_interest: a list of {key, values} pairs
whose keys are already dotted paths (process.cmd_line, parent_process.name, hostname).
Reading them needs a JSONata predicate per key rather than a plain path, which is why
every entry below looks heavier than in other mappers. A key a given rule does not emit
resolves to nothing and is skipped, so the same file covers every detection rule.
field_mappings:
title: "title"
# toSeverity maps CRITICAL→5, HIGH→4, MEDIUM→3, LOW→2, and anything else to the alert
# rule's default. (The investigation mapper's comment on this is wrong — checked
# against internal/incidenttypes/engine.go.)
severity: "priority"
source: "external_source"
# Rapid7's own triage state. It cannot reach the incident's status or verdict columns —
# MapFields only accepts title, severity, source, description and custom:* — so it is
# kept as custom fields, where an analyst at least sees that the source already closed
# this alert and what it concluded.
"custom:rapid7_status": "status"
"custom:rapid7_disposition": "disposition"
# Rapid7's AI suggestion, exposed as a fact and never as a verdict.
"custom:rapid7_ai_disposition": "ai_suggested_disposition"
"custom:rapid7_responsibility": "responsibility"
# The log identifiers a LEQL query needs to pull the raw event back out of Log Search.
# Kept as custom fields rather than forced onto an OCSF field that means something else.
"custom:rapid7_log_id": "log_details[0].log_id"
"custom:rapid7_logset_id": "log_details[0].logset_id"
ocsf:
# ── Finding ───────────────────────────────────────────────────────
- { source_path: "rrn", ocsf_field: "finding_info.uid" }
- { source_path: "external_id", ocsf_field: "finding_info.uid_alt" }
- { source_path: "title", ocsf_field: "finding_info.title" }
- { source_path: "created_at", ocsf_field: "finding_info.created_time" }
- { source_path: "updated_at", ocsf_field: "finding_info.modified_time" }
- { source_path: "alerted_at", ocsf_field: "finding_info.first_seen_time" }
- { source_path: "tags", ocsf_field: "metadata.labels" }
# The detection rule. `rule.name` is documented but absent from live ABA alerts, so
# `type` — which repeats the rule's display name — is the honest fallback.
- { source_path: "type", ocsf_field: "finding_info.analytic.name" }
- { source_path: "rule.rrn", ocsf_field: "finding_info.analytic.uid" }
- { source_path: "rule.version_rrn", ocsf_field: "finding_info.analytic.version" }
# ── Detection time: the MTTD anchor ───────────────────────────────
# alerted_at is when the underlying event happened in the source system; created_at is
# when Rapid7 finished building the alert. Weakest first, strongest last.
- { source_path: "created_at", ocsf_field: "time" }
- { source_path: "alerted_at", ocsf_field: "time" }
# ── State, as the source sees it ──────────────────────────────────
- { source_path: "status", ocsf_field: "status" }
- { source_path: "disposition", ocsf_field: "disposition" }
- { source_path: "priority", ocsf_field: "priority" }
- { source_path: "responsibility", ocsf_field: "comment" }
# ── Product identity ──────────────────────────────────────────────
- { source_path: "'Rapid7 InsightIDR'", ocsf_field: "metadata.product.name" }
- { source_path: "'Rapid7'", ocsf_field: "metadata.product.vendor_name" }
- { source_path: "external_source", ocsf_field: "metadata.log_source" }
- { source_path: "triggering_event_type", ocsf_field: "activity_name" }
- { source_path: "triggering_event_sub_type", ocsf_field: "status_detail" }
# The parent investigation, on the OCSF field meant for joining findings that belong
# together. Every alert Rapid7 folded into one investigation carries the same value,
# which is what lets the correlation engine rebuild that grouping as a case.
- { source_path: "investigation_rrn", ocsf_field: "metadata.correlation_uid" }
- { source_path: "organization.id", ocsf_field: "cloud.org.uid" }
- { source_path: "organization.name", ocsf_field: "cloud.org.name" }
# ── MITRE ATT&CK ──────────────────────────────────────────────────
# mitre_tcodes mixes both kinds in one array: "TA0040" is a tactic, "T1490" a
# technique. Splitting on the "TA" prefix keeps a tactic out of the technique field,
# where it would name a technique that does not exist.
- { source_path: "rule.mitre_tcodes[$substring($, 0, 2) = 'TA'][0]", ocsf_field: "attacks.tactic.uid" }
- { source_path: "rule.mitre_tcodes[$substring($, 0, 2) != 'TA'][0]", ocsf_field: "attacks.technique.uid" }
# ── The affected host ─────────────────────────────────────────────
# device.hostname and src_endpoint.hostname both index the host as an entity; the
# duplication is what other vendor mappers do and what the pivot graph reads.
- { source_path: "rule_keys_of_interest[key='hostname'].values[0]", ocsf_field: "device.hostname" }
- { source_path: "rule_keys_of_interest[key='hostname'].values[0]", ocsf_field: "src_endpoint.hostname" }
# ── The process the alert is about ────────────────────────────────
- { source_path: "rule_keys_of_interest[key='process.name'].values[0]", ocsf_field: "process.name" }
- { source_path: "rule_keys_of_interest[key='process.name'].values[0]", ocsf_field: "process.file.name" }
- { source_path: "rule_keys_of_interest[key='process.cmd_line'].values[0]", ocsf_field: "process.cmd_line" }
- { source_path: "rule_keys_of_interest[key='process.exe_path'].values[0]", ocsf_field: "process.file.path" }
- { source_path: "rule_keys_of_interest[key='process.exe_file.hashes.md5'].values[0]", ocsf_field: "process.file.hashes.md5" }
- { source_path: "rule_keys_of_interest[key='process.exe_file.hashes.sha256'].values[0]", ocsf_field: "process.file.hashes.sha256" }
- { source_path: "rule_keys_of_interest[key='process.pid'].values[0]", ocsf_field: "process.pid" }
# ── Its parent, which is what says how it was launched ────────────
- { source_path: "rule_keys_of_interest[key='parent_process.name'].values[0]", ocsf_field: "actor.process.parent_process.name" }
- { source_path: "rule_keys_of_interest[key='parent_process.cmd_line'].values[0]", ocsf_field: "actor.process.parent_process.cmd_line" }
- { source_path: "rule_keys_of_interest[key='parent_process.exe_path'].values[0]", ocsf_field: "actor.process.parent_process.file.path" }
- { source_path: "rule_keys_of_interest[key='parent_process.exe_file.hashes.md5'].values[0]", ocsf_field: "actor.process.parent_process.file.hashes.md5" }
- { source_path: "rule_keys_of_interest[key='parent_process.exe_file.hashes.sha256'].values[0]", ocsf_field: "actor.process.parent_process.file.hashes.sha256" }
# ── The acting account ────────────────────────────────────────────
# There is no canonical user namespace in this tenant's field catalogue, so the account
# only appears when a rule names it among its keys of interest. Both spellings are
# mapped because rules are not consistent about which one they emit.
- { source_path: "rule_keys_of_interest[key='user'].values[0]", ocsf_field: "actor.user.name" }
- { source_path: "rule_keys_of_interest[key='username'].values[0]", ocsf_field: "actor.user.name" }
- { source_path: "rule_keys_of_interest[key='account'].values[0]", ocsf_field: "actor.user.account.name" }
# ── Network, when the rule is about a flow ────────────────────────
- { source_path: "rule_keys_of_interest[key='source_address'].values[0]", ocsf_field: "src_endpoint.ip" }
- { source_path: "rule_keys_of_interest[key='destination_address'].values[0]", ocsf_field: "dst_endpoint.ip" }
- { source_path: "rule_keys_of_interest[key='destination_port'].values[0]", ocsf_field: "dst_endpoint.port" }
# ── Who owns it at the source ─────────────────────────────────────
- { source_path: "assignee.first_name & ' ' & assignee.last_name", ocsf_field: "assignee.name" }
- { source_path: "assignee.email", ocsf_field: "assignee.email_addr" }
# ── The log entry behind the alert ────────────────────────────────
# Not investigable on its own, but it is the handle a LEQL query needs to pull the raw
# event out of Log Search — the only bridge from an alert back to the line that made it.
- { source_path: "log_details[0].log_entry_id", ocsf_field: "metadata.uid" }
# ── The parent investigation ──────────────────────────────────────
# Present only on the search_investigation_alerts feed, which carries the container
# beside the alert. Absent on search_alerts, where these resolve to nothing and are
# skipped — the same file serves both.
- { source_path: "investigation.title", ocsf_field: "finding_info.related_events.title" }
- { source_path: "investigation.status", ocsf_field: "finding_info.related_events.status" }
- { source_path: "investigation.priority", ocsf_field: "finding_info.related_events.priority" }
- { source_path: "investigation.disposition", ocsf_field: "finding_info.related_events.disposition" }
- { source_path: "investigation.rrn", ocsf_field: "finding_info.related_events.uid" }
@@ -54,8 +54,20 @@ def _terms(inp):
"""
raw = inp.get("terms_json")
if raw:
parsed = json.loads(raw) if isinstance(raw, str) else raw
return parsed if isinstance(parsed, list) else [parsed]
if isinstance(raw, str):
try:
parsed = json.loads(raw)
except ValueError as e:
# json's own message ("Expecting value: line 1 column 1") names
# neither the input nor what it received, so an operator who
# pasted the field name along with the value — the obvious
# mistake, since both are shown together — gets a parser error
# with nothing to act on. Echo the head of what arrived.
head = raw[:80] + ("…" if len(raw) > 80 else "")
raise ValueError("terms_json is not valid JSON (" + str(e) + "). "
"Expected a JSON array of search terms, got: " + head)
return parsed if isinstance(parsed, list) else [parsed]
return raw if isinstance(raw, list) else [raw]
field_ids = _list(inp.get("search_field"))
values = _list(inp.get("search_values"))
if not field_ids or not values:
@@ -0,0 +1,156 @@
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"
# /alerts/ops/rrns takes a list; batching keeps one URL and one body reasonable.
RRN_BATCH = 100
# The server gives a command 90 seconds. Stop walking investigations well before
# that and report the truncation, rather than have the whole run killed and every
# alert in the window lost — a partial fetch that says so beats a silent zero.
DEFAULT_BUDGET_S = 60
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)
# 1) The investigations in the window. This is the unit that decides what is
# worth ingesting: an alert Rapid7 never folded into one is not returned by
# this command at all, which is the whole point of it.
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]
# 2) Their alert RRNs. One call per investigation — there is no bulk form —
# so this is the step the time budget guards.
order, by_rrn, truncated = [], {}, False
for inv in investigations:
if time.monotonic() - started > budget or len(order) >= max_alerts:
truncated = True
break
inv_rrn = inv.get("rrn") or ""
if not inv_rrn:
continue
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:
# One unreadable investigation must not cost the whole window. The
# alert simply does not appear; the counts below say how many did.
continue
for a in (page.get("data") or []):
rrn = a.get("id") or a.get("rrn")
if rrn and rrn not in by_rrn:
order.append(rrn)
by_rrn[rrn] = inv
if len(order) >= max_alerts:
truncated = True
break
# 3) The alerts themselves, in batches. This is where the detection content
# lives — rule_keys_of_interest, mitre_tcodes, disposition — none of which
# the investigation or its alert list carries.
field_ids = _list(inp.get("field_ids"))
alerts = []
for i in range(0, len(order), RRN_BATCH):
batch = order[i:i + RRN_BATCH]
body = {"rrns": batch}
if field_ids:
body["field_ids"] = field_ids
out = request("POST", AT + "alerts/ops/rrns", body=body)
alerts.extend(out.get("alerts") or [])
# The investigation each alert came from, carried alongside so a mapper can
# read the container's own title, priority and disposition without a second
# feed. Alert fields are left untouched: `investigation_rrn` already exists
# on the alert and nothing here may shadow it.
if str(inp.get("include_investigation") or "true").strip().lower() in ("1", "true", "yes"):
for a in alerts:
inv = by_rrn.get(a.get("rrn"))
if inv:
a["investigation"] = inv
print(json.dumps({
"alerts": alerts,
"metadata": {
"investigations_read": len(investigations),
"alert_rrns_collected": len(order),
"alerts_returned": len(alerts),
"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)