306450a4b6
Both mappers read that field through a hardcoded list of keys — hostname, process.cmd_line, parent_process.*, user — taken from the one alert this work was built against, a process_start_event detection. Every entry is a JSONata predicate on an exact key, so a rule emitting anything else matches none of them. A real alert from a second tenant showed what that costs. It is a custom ingress_auth rule, and its keys are: account locuz_hma@totogroup.net source_ip 183.82.103.101 geoip_country_code IN result SUCCESS Not one appears in the hardcoded list. The alert was fetched, inlined and mapped, and produced an incident as empty as the ones this integration set out to fix — with the account and the source IP sitting in the payload, unread. Two changes: - The whole list is flattened verbatim into `detection`, and rule_matching_keys into `detection_match`. Keys nobody anticipated now arrive intact, and they reach the AI analysis, which reads the mapped context and never `raw`. - account, source_ip, destination_ip, geoip_country_code and result gain explicit OCSF entries. The explicit mappings are not redundant with the flattening: a value under `detection.` is only ever seen by the IOC regex, so an account there would be indexed as an email, or not at all, rather than as a user. Typing an entity correctly still requires naming its field. evidences[] carries the flattened keys too, so the same holds for the second and third alert of an investigation, not only the first. Verified against both rule shapes end to end. The process rule yields the two hosts it did before; the authentication rule now yields the source IP and the account, where it previously yielded nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
150 lines
13 KiB
YAML
150 lines
13 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" }
|
|
# ── 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" }
|
|
# ── 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" }
|