Files
f3nris 5085d96628 feat(webhook-relay): map Rapid7 InsightIDR basic rule alerts (1.1.0)
fetch_rapid7_alerts opens the alert InsightIDR Essential posts as a JSON
string in a single payload field (JSON or form body) and returns one flat
item per relayed message; the correlation id is the dedup key. Own mapper
and alert type, written from a real delivery.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xa1QDbsPTYiVREv2Cvs3Zn
2026-10-19 07:56:45 +00:00

139 lines
5.8 KiB
Python

import datetime, json, os, sys, urllib.request, urllib.parse, urllib.error
PAGE = 1000 # the relay's largest page
DEFAULT_LIMIT = 10000 # the relay's default max_pending per source
def _cfg():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = str(s.get("base_url") or "").strip().rstrip("/")
if not base.startswith("https://"):
raise ValueError("base_url must be the relay's https:// address")
return base, {"Authorization": "Bearer " + str(s.get("read_token") or "").strip(), "Accept": "application/json"}
def _get(path, params):
base, headers = _cfg()
url = base + path + "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v not in (None, "")})
req = urllib.request.Request(url, headers=headers, method="GET")
with urllib.request.urlopen(req, timeout=60) as r:
return json.loads(r.read() or b"{}")
# InsightIDR basic detection rules post a single field, `payload`, holding the
# alert as a JSON-encoded string — as a JSON body {"payload": "..."} or as a form
# body payload=... depending on the delivery. Both are opened here, in Python:
# event.s is a 64-bit sequence that a float-based JSON reader would round.
OPTED_OUT = "The log line that triggered this alert is not included"
def _payload(m):
j = m.get("json")
if isinstance(j, dict) and "payload" in j:
p = j["payload"]
return json.loads(p) if isinstance(p, str) else p
body = m.get("body") or ""
if isinstance(j, dict):
return j
form = urllib.parse.parse_qs(body)
if "payload" in form:
return json.loads(form["payload"][0])
raise ValueError("no Rapid7 payload in this message")
def _iso_ms(t):
try:
return datetime.datetime.fromtimestamp(int(t) / 1000, datetime.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
except (TypeError, ValueError, OverflowError, OSError):
return None
def rapid7(m):
out = {k: m.get(k) for k in ("id", "source", "received_at", "remote_ip", "content_type", "verified", "body")}
# A message the relay could not tie to a Rapid7 alert still becomes an
# alert: dropping it would hide a delivery nobody would then look for.
out["alert_id"] = "relay:" + str(m.get("id"))
try:
r = _payload(m)
except Exception as e:
out["decode_error"] = str(e)
return out
if not isinstance(r, dict):
out["decode_error"] = "payload is not an object"
return out
alert, event, log, host = (r.get(k) if isinstance(r.get(k), dict) else {} for k in ("alert", "event", "log", "host"))
if r.get("correlation_id"):
# Rapid7's id for this firing: a redelivery carries it again, a new
# relay message id does not, so it is the dedup key.
out["alert_id"] = str(r["correlation_id"])
out["correlation_id"] = r.get("correlation_id")
out["rule"] = alert.get("name")
out["region"] = r.get("region")
out["event_time"] = _iso_ms(event.get("t"))
out["event_sequence"] = str(event["s"]) if event.get("s") is not None else None
line = event.get("m")
out["log_line_withheld"] = isinstance(line, str) and line.startswith(OPTED_OUT)
out["log_line"] = None if out["log_line_withheld"] else line
if isinstance(line, str) and not out["log_line_withheld"]:
try:
out["log_event"] = json.loads(line)
except ValueError:
pass
out["log_name"] = log.get("name")
out["log_key"] = log.get("key")
# In a basic rule's payload `host.name` is the log set (Asset Authentication),
# not a machine; host.hostname is the machine, when Rapid7 knows one.
out["log_set"] = host.get("name")
out["hostname"] = host.get("hostname")
out["rapid7"] = r
return out
def run():
inp = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
source = str(inp.get("source") or "").strip()
if not source:
raise ValueError("source is required: the relay source id, e.g. r7-acme")
try:
limit = int(inp.get("limit") or DEFAULT_LIMIT)
except (TypeError, ValueError):
limit = DEFAULT_LIMIT
since = str(inp.get("since") or "").strip()
# Every page is read within this one run. The next run's watermark is the
# instant of this one, so a page left for later would never be asked for.
messages, after = [], ""
while True:
page = _get("/relay/v1/messages", {"source": source, "since": since, "after": after, "limit": PAGE})
messages.extend(page.get("messages") or [])
if not page.get("more"):
break
after = page.get("next") or ""
if not after:
break
if len(messages) >= limit:
# Failing keeps the watermark where it is; returning a truncated
# list would move it past what was never read.
raise RuntimeError(
"more than %d messages wait on source %s since %s: raise this command's limit "
"(nothing was ingested; the next run reads the same window)" % (limit, source, since or "the beginning"))
alerts = [rapid7(m) for m in messages]
print(json.dumps({"alerts": alerts, "count": len(alerts), "source": source, "since": since}))
try:
run()
except urllib.error.HTTPError as e:
detail = e.read().decode("utf-8", "replace")
out = {"error": "HTTP " + str(e.code), "detail": detail}
if e.code == 401:
out["hint"] = "The relay refused the read token, or this Riposte's address is not in RELAY_CONSUMER_ALLOW_IPS."
elif e.code == 404:
out["hint"] = ("Unknown source on this relay, or this Riposte's address is not allowed by the proxy in front "
"of it (it answers 404 to readers it does not know). Run test_connection.")
print(json.dumps(out))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)