db9a45c9bc
The previous commit said MTTD was retired. Migration 0256 did retire it, but 0257 restored it at once: what stays is its anchor, the moment Riposte records the alert. The OCSF `time` field is still not read to date detection — that part was right. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
169 lines
14 KiB
YAML
169 lines
14 KiB
YAML
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" }
|
|
# ── Event time (OCSF `time`) ───────────────────────────────────────
|
|
# Riposte no longer reads it to date detection: since migration 0256, MTTD
|
|
# starts when Riposte records the alert. It is the alert's own record of when
|
|
# the source saw the event.
|
|
# 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" }
|
|
# ── Lateral movement: source and destination ──────────────────────
|
|
# An asset_auth rule describes a connection, so it names BOTH ends and neither
|
|
# is "the host": source_asset_address / destination_asset_address, and
|
|
# source_account / destination_account. Mapping them to src_endpoint and
|
|
# dst_endpoint is not only the correct OCSF shape — it is also what makes them
|
|
# appear in the layout builder, whose field picker is a closed <select> over
|
|
# the OCSF catalogue. A path that is not in that catalogue cannot be placed on
|
|
# a layout at all, which is why `detection` below, useful as it is, only ever
|
|
# shows up in the Context tab.
|
|
- { source_path: "rule_keys_of_interest[key='source_asset_address'].values[0]", ocsf_field: "src_endpoint.hostname" }
|
|
- { source_path: "rule_keys_of_interest[key='destination_asset_address'].values[0]", ocsf_field: "dst_endpoint.hostname" }
|
|
- { source_path: "rule_keys_of_interest[key='destination_asset'].values[0]", ocsf_field: "dst_endpoint.hostname" }
|
|
# actor.user is who acted, user is who it was done TO. Keeping them apart is
|
|
# what lets an analyst read a lateral move in one line.
|
|
- { source_path: "rule_keys_of_interest[key='source_account'].values[0]", ocsf_field: "actor.user.name" }
|
|
- { source_path: "rule_keys_of_interest[key='destination_account'].values[0]", ocsf_field: "user.name" }
|
|
# ── Every key the rule found interesting ──────────────────────────
|
|
# rule_keys_of_interest is an OPEN vocabulary: each detection rule names its own
|
|
# keys. A process_start_event rule emits hostname / process.cmd_line /
|
|
# parent_process.*; an ingress_auth rule emits account / source_ip /
|
|
# geoip_country_code / result and none of the above. The explicit entries below
|
|
# can therefore only ever cover the rules someone has already seen — on any
|
|
# other rule they all resolve to nothing and the incident is empty again, which
|
|
# is the exact failure this integration started from.
|
|
#
|
|
# So the whole list is also flattened, verbatim, into one object. Keys nobody
|
|
# anticipated arrive intact and are visible to the analyst and to the AI
|
|
# analysis, which reads the mapped context but never `raw`. The explicit
|
|
# mappings stay because they are what types an entity correctly — a value under
|
|
# `detection.` is only ever matched by the IOC regex, so an account there would
|
|
# never be indexed as a user.
|
|
- { source_path: "rule_keys_of_interest{key: values[0]}", ocsf_field: "detection" }
|
|
- { source_path: "rule_matching_keys{key: values[0]}", ocsf_field: "detection_match" }
|
|
# ── Keys seen on non-process rules ────────────────────────────────
|
|
- { source_path: "rule_keys_of_interest[key='account'].values[0]", ocsf_field: "actor.user.account.name" }
|
|
- { source_path: "rule_keys_of_interest[key='source_ip'].values[0]", ocsf_field: "src_endpoint.ip" }
|
|
- { source_path: "rule_keys_of_interest[key='destination_ip'].values[0]", ocsf_field: "dst_endpoint.ip" }
|
|
- { source_path: "rule_keys_of_interest[key='geoip_country_code'].values[0]", ocsf_field: "src_endpoint.location.country" }
|
|
- { source_path: "rule_keys_of_interest[key='result'].values[0]", ocsf_field: "status_detail" }
|