diff --git a/integrations/rapid7-insightidr/manifest.yaml b/integrations/rapid7-insightidr/manifest.yaml index 98473a1..ebcc9df 100644 --- a/integrations/rapid7-insightidr/manifest.yaml +++ b/integrations/rapid7-insightidr/manifest.yaml @@ -1,8 +1,8 @@ id: rapid7_insightidr name: Rapid7 InsightIDR -version: 1.12.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.12.0 — Make the LEQL queries ingestible: query_log and query_log_set gain an ingest block, a stable event_id for dedup, a bounded page walk and a detection_name stamp, and they now read start_time as ISO-8601, UNIX seconds or milliseconds alike. A tenant whose basic detection rules only notify (email, Slack) creates no investigation and no alert an API can read, so replaying the rule's own query on a schedule is the only way its detections reach a SOAR. | 1.11.1 — Strip the whitespace out of the comma-separated filter params (statuses, priorities, sources, tags) before the URL. \"HIGH, CRITICAL\" as typed sent \" CRITICAL\" to the API, which rejects the whole call — so a priority filter written naturally fetched nothing. | 1.11.0 —Build one evidence row per alert evidence into custom:rapid7_evidence, for the table field: sortable and filterable where the Markdown summary only reads. | 1.10.0 — Render every detection key as Markdown into custom:rapid7_summary, so a layout can show what the OCSF catalogue has no field for. Map both ends of an account action, read source_json top-level scalars, drop placeholder values, and force evidences to stay an array. | 1.9.2 — Read the evidence data in both of its shapes. A parsed source states its event flat, with no source_data/custom_data wrapper, and the parser returned {} for all of them. | 1.9.1 — get_alert_process_trees: explain the 404, which the API returns with an empty body for three different situations. | 1.9.0 — Fetch each alert process tree and flatten it into ctx.process_tree, the shape the layout Process Tree block reads. Gated on the event type by default, since an authentication alert never has one. | 1.8.0 — Fetch each alert evidence at ingestion and parse it: the log line behind the alert lives in a JSON-encoded string no mapper can reach, and it is where the source and destination addresses, the account and the device actually are. | 1.7.0 — Map source/destination assets and accounts to src_endpoint, dst_endpoint, actor.user and user. A connection rule names both ends and neither is \"the host\", so an RDP-to-DC alert produced no entity at all and nothing a layout could show. | 1.6.0 — Surface region_failures: the Alert Triage API answers HTTP 200 with a partial result when it cannot reach a region, and that list was being dropped. Also report the regions seen in the RRNs against the configured one. | 1.5.0 — Flatten the whole of rule_keys_of_interest into the context. It is an open vocabulary — a process rule and an authentication rule share no keys — so the hardcoded list only covered rules already seen and left every other alert empty. | 1.4.1 — Read the alert RRN from `rrn` before `id` and drop anything not RRN-shaped: the investigation alert list also returns bare UUIDs, and one of those rejects the entire /alerts/ops/rrns batch (HTTP 400), losing every alert in the run. | 1.4.0 — Add get_investigations_full: the investigation with its alerts inlined, so it stays the unit that becomes an incident. Drop-in for list_investigations (results_path data, dedup rrn). | 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." +version: 1.13.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, investigation comments (add/list), custom threat indicators (add/replace), log management and LEQL log/log-set queries with downloads, and user directory search." +changelog: "1.13.0 — Add add_comment and list_comments: the investigation comment thread is where an analyst’s reasoning lives, and a playbook had no way to write into it or read it back. The Comments API is v1-only and addresses its target by RRN — the short investigation ID the other v1 endpoints accept is rejected with a 400 that names nothing, so both commands refuse it up front. | 1.12.0 — Make the LEQL queries ingestible: query_log and query_log_set gain an ingest block, a stable event_id for dedup, a bounded page walk and a detection_name stamp, and they now read start_time as ISO-8601, UNIX seconds or milliseconds alike. A tenant whose basic detection rules only notify (email, Slack) creates no investigation and no alert an API can read, so replaying the rule's own query on a schedule is the only way its detections reach a SOAR. | 1.11.1 — Strip the whitespace out of the comma-separated filter params (statuses, priorities, sources, tags) before the URL. \"HIGH, CRITICAL\" as typed sent \" CRITICAL\" to the API, which rejects the whole call — so a priority filter written naturally fetched nothing. | 1.11.0 —Build one evidence row per alert evidence into custom:rapid7_evidence, for the table field: sortable and filterable where the Markdown summary only reads. | 1.10.0 — Render every detection key as Markdown into custom:rapid7_summary, so a layout can show what the OCSF catalogue has no field for. Map both ends of an account action, read source_json top-level scalars, drop placeholder values, and force evidences to stay an array. | 1.9.2 — Read the evidence data in both of its shapes. A parsed source states its event flat, with no source_data/custom_data wrapper, and the parser returned {} for all of them. | 1.9.1 — get_alert_process_trees: explain the 404, which the API returns with an empty body for three different situations. | 1.9.0 — Fetch each alert process tree and flatten it into ctx.process_tree, the shape the layout Process Tree block reads. Gated on the event type by default, since an authentication alert never has one. | 1.8.0 — Fetch each alert evidence at ingestion and parse it: the log line behind the alert lives in a JSON-encoded string no mapper can reach, and it is where the source and destination addresses, the account and the device actually are. | 1.7.0 — Map source/destination assets and accounts to src_endpoint, dst_endpoint, actor.user and user. A connection rule names both ends and neither is \"the host\", so an RDP-to-DC alert produced no entity at all and nothing a layout could show. | 1.6.0 — Surface region_failures: the Alert Triage API answers HTTP 200 with a partial result when it cannot reach a region, and that list was being dropped. Also report the regions seen in the RRNs against the configured one. | 1.5.0 — Flatten the whole of rule_keys_of_interest into the context. It is an open vocabulary — a process rule and an authentication rule share no keys — so the hardcoded list only covered rules already seen and left every other alert empty. | 1.4.1 — Read the alert RRN from `rrn` before `id` and drop anything not RRN-shaped: the investigation alert list also returns bare UUIDs, and one of those rejects the entire /alerts/ops/rrns batch (HTTP 400), losing every alert in the run. | 1.4.0 — Add get_investigations_full: the investigation with its alerts inlined, so it stays the unit that becomes an incident. Drop-in for list_investigations (results_path data, dedup rrn). | 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 @@ -203,6 +203,32 @@ commands: required: [investigation_id] outputs_schema: { properties: {} } + - id: add_comment + name: rapid7-insight-idr-add-comment + description: "Post a comment on an investigation (v1). The target is addressed by RRN only — the short investigation ID is rejected." + risk: safe_write + inputs_schema: + properties: + investigation_id: { type: string, description: "Investigation RRN to comment on (rrn:investigation:::investigation:)" } + comment: { type: string, description: "Body of the comment" } + attachments: { type: string, description: "Comma-separated attachment RRNs to associate with the comment" } + required: [investigation_id, comment] + outputs_schema: { properties: {} } + + - id: list_comments + name: rapid7-insight-idr-list-comments + description: "List the comments on an investigation, newest first by default (v1). The target is addressed by RRN only." + risk: read + inputs_schema: + properties: + investigation_id: { type: string, description: "Investigation RRN to read comments from (rrn:investigation:::investigation:)" } + sort_direction: { type: string, description: "Sort by creation time: ASC or DESC (default DESC)" } + index: { type: string, description: "0-based page index to start from" } + page_size: { type: string, description: "Page size (1-100, default 100)" } + all_results: { type: string, description: "Walk every page, overriding the limit (true/false, default false)" } + limit: { type: number, description: "Maximum number of comments to retrieve (default 50)" } + required: [investigation_id] + outputs_schema: { properties: {} } - id: add_threat_indicators name: rapid7-insight-idr-add-threat-indicators description: "Add indicators (IPs, hashes, domains, URLs) to one or more custom threats by key." diff --git a/integrations/rapid7-insightidr/scripts/add_comment.py b/integrations/rapid7-insightidr/scripts/add_comment.py new file mode 100644 index 0000000..3cceed7 --- /dev/null +++ b/integrations/rapid7-insightidr/scripts/add_comment.py @@ -0,0 +1,69 @@ +import json, os, sys, urllib.request, urllib.parse, urllib.error + + +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"} + return base, headers + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def _prune(d): + return {k: v for k, v in d.items() if v not in (None, "", {}, [])} + + +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, body=None): + base, headers = _cfg() + data = json.dumps(body).encode("utf-8") if body is not None else None + req = urllib.request.Request(base + path.lstrip("/"), 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 {} + + +def run(): + inp = _inputs() + # The Comments API addresses its target by RRN only. The short investigation ID + # the v1 endpoints accept is rejected here, and the API answers 400 without + # naming the parameter — so refuse it up front with the reason. + target = str(inp.get("investigation_id") or "").strip() + if not target: + raise ValueError("investigation_id is required: the RRN of the investigation to comment on") + if not target.startswith("rrn:"): + raise ValueError( + "investigation_id must be an RRN (rrn:investigation:::investigation:), " + "not the short investigation ID: the Comments API targets a resource by RRN. Got " + repr(target) + ) + comment = inp.get("comment") + attachments = _list(inp.get("attachments")) + if comment in (None, "") and not attachments: + raise ValueError("comment is required (or attachments, to post a comment carrying only files)") + body = _prune({ + "target": target, + "body": comment, + "attachments": attachments, + }) + print(json.dumps(request("POST", "idr/v1/comments", body=body))) + + +try: + run() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/rapid7-insightidr/scripts/list_comments.py b/integrations/rapid7-insightidr/scripts/list_comments.py new file mode 100644 index 0000000..a4d0bc5 --- /dev/null +++ b/integrations/rapid7-insightidr/scripts/list_comments.py @@ -0,0 +1,80 @@ +import json, os, sys, urllib.request, urllib.parse, urllib.error + +MAX_PAGE = 100 # the API caps `size` at 100 +PAGE_CAP = 50 # backstop on the page walk, so a runaway target cannot loop forever + + +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"} + return base, headers + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def request(method, path, params=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) + req = urllib.request.Request(url, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def run(): + inp = _inputs() + target = str(inp.get("investigation_id") or "").strip() + if not target: + raise ValueError("investigation_id is required: the RRN of the investigation to read comments from") + if not target.startswith("rrn:"): + raise ValueError( + "investigation_id must be an RRN (rrn:investigation:::investigation:), " + "not the short investigation ID: the Comments API targets a resource by RRN. Got " + repr(target) + ) + sort_direction = str(inp.get("sort_direction") or "DESC").strip().upper() + if sort_direction not in ("ASC", "DESC"): + sort_direction = "DESC" + all_results = str(inp.get("all_results") or "false").strip().lower() in ("1", "true", "yes") + limit = int(inp.get("limit") or 50) + page_size = min(int(inp.get("page_size") or MAX_PAGE), MAX_PAGE) + index = int(inp.get("index") or 0) + + data, metadata, pages = [], {}, 0 + while pages < PAGE_CAP: + out = request("GET", "idr/v1/comments", params={ + "target": target, + "index": index, + "size": page_size, + "sortDirection": sort_direction, + }) + metadata = out.get("metadata") or {} + page = out.get("data") or [] + data.extend(page) + pages += 1 + index += 1 + total_pages = int(metadata.get("total_pages") or 0) + if not page or index >= total_pages: + break + if not all_results and len(data) >= limit: + break + if not all_results: + data = data[:limit] + print(json.dumps({"target": target, "comment": data, "metadata": metadata})) + + +try: + run() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1)