Compare commits
3 Commits
157af87c17
...
220fa75b99
| Author | SHA1 | Date | |
|---|---|---|---|
| 220fa75b99 | |||
| 7e231c7c78 | |||
| b6903d4590 |
@@ -1,8 +1,8 @@
|
|||||||
id: rapid7_insightidr
|
id: rapid7_insightidr
|
||||||
name: 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."
|
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
|
category: siem
|
||||||
|
|
||||||
# Per-instance configuration. The Insight platform region selects the API host
|
# 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_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_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)" }
|
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_field: { type: string, description: "Field id to sort on" }
|
||||||
sort_direction: { type: string, description: "ASC or DESC (default ASC)" }
|
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)" }
|
rrns_only: { type: string, description: "Return only alert RRNs instead of full details (true/false)" }
|
||||||
@@ -414,6 +414,31 @@ commands:
|
|||||||
required: []
|
required: []
|
||||||
outputs_schema: { properties: {} }
|
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 ─────────────────────────────────────────────────────
|
# ── Connectivity test ─────────────────────────────────────────────────────
|
||||||
- id: test_connection
|
- id: test_connection
|
||||||
name: rapid7-insight-idr-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")
|
raw = inp.get("terms_json")
|
||||||
if raw:
|
if raw:
|
||||||
parsed = json.loads(raw) if isinstance(raw, str) else raw
|
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 parsed if isinstance(parsed, list) else [parsed]
|
||||||
|
return raw if isinstance(raw, list) else [raw]
|
||||||
field_ids = _list(inp.get("search_field"))
|
field_ids = _list(inp.get("search_field"))
|
||||||
values = _list(inp.get("search_values"))
|
values = _list(inp.get("search_values"))
|
||||||
if not field_ids or not 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)
|
||||||
Reference in New Issue
Block a user