fix(sekoia-xdr): mapper reviewed on a real alert (1.2.0)

- incident source is 'Sekoia XDR'; Sekoia's `source` (the alert's source
  address) goes to src_endpoint.ip/.hostname, `target` to dst_endpoint;
- description: the alert's sentence with {rule:<uuid>} replaced by the rule
  name; the rule documentation (`details`) moves to analytic.desc;
- every TTP mapped into attacks; kill_chain_short_id no longer written as
  an ATT&CK tactic uid;
- OCSF times in epoch ms; finding_info.types, analytic.category,
  metadata.product added.

Checked with Riposte's mapper preview on the alert and a variant (IPv4
source, named target, one TTP): no expression error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 09:13:03 +00:00
parent d831d0973a
commit ea04ea586c
2 changed files with 38 additions and 18 deletions
@@ -1,43 +1,63 @@
name: "Sekoia XDR Alerts → OCSF"
description: "Maps a Sekoia Defend (XDR) alert (/v1/sic/alerts, results_path = items) to OCSF Detection Finding fields. Alerts aggregate the matching events; use search_events for per-event detail."
description: "Maps a Sekoia Defend (XDR) alert (/v1/sic/alerts, results_path = items) to OCSF Detection Finding fields. Alerts aggregate the matching events; use search_events for per-event detail. Sekoia's `source` and `target` are the alert's main source and target (an IP address or a host), not the producing system."
field_mappings:
title: "title"
description: "details"
# The alert's own sentence, with the rule placeholder Sekoia leaves in it
# ('{rule:<uuid>}') replaced by the rule's name. `details` is the rule's
# documentation — the same for every alert of the rule, MITRE prose and
# vendor links included — and goes to finding_info.analytic.desc instead.
description: "jsonata: description ? $replace(description, '{rule:' & rule.uuid & '}', rule.name) : title"
# urgency is a 0-100 score; bucket it onto the 1-5 incident scale. No parentheses (the engine chains
# right-associatively) and the lowest band uses '>= 0 ? 1' so the literal 1 sits in a then-branch —
# a bare trailing else is read as a field path, not an integer, and would fall back to the default.
severity: "urgency.value >= 80 ? 5 : urgency.value >= 60 ? 4 : urgency.value >= 40 ? 3 : urgency.value >= 20 ? 2 : urgency.value >= 0 ? 1 : 1"
# How the alert was generated.
source: "source"
# The producing system. Sekoia's own `source` field is the alert's source
# address or host — it goes to src_endpoint below.
source: "jsonata: 'Sekoia XDR'"
# results_path = items; source_path is JSONata over ONE alert object.
# Paths absent from a given alert return nothing and are skipped.
ocsf:
# ── Finding ───────────────────────────────────────────────────────
- { source_path: "uuid", ocsf_field: "finding_info.uid" }
- { source_path: "title", ocsf_field: "finding_info.title" }
- { source_path: "rule.description", ocsf_field: "finding_info.desc" }
- { source_path: "created_at", ocsf_field: "finding_info.created_time" }
- { source_path: "updated_at", ocsf_field: "finding_info.modified_time" }
- { source_path: "first_seen_at", ocsf_field: "finding_info.first_seen_time" }
- { source_path: "last_seen_at", ocsf_field: "finding_info.last_seen_time" }
- { source_path: "description ? $replace(description, '{rule:' & rule.uuid & '}', rule.name)", ocsf_field: "finding_info.desc" }
# OCSF times are epoch milliseconds: created_at/updated_at come in seconds,
# first/last seen as ISO 8601.
- { source_path: "created_at * 1000", ocsf_field: "finding_info.created_time" }
- { source_path: "updated_at * 1000", ocsf_field: "finding_info.modified_time" }
- { source_path: "$toMillis(first_seen_at)", ocsf_field: "finding_info.first_seen_time" }
- { source_path: "$toMillis(last_seen_at)", ocsf_field: "finding_info.last_seen_time" }
- { source_path: "alert_type.category ? [alert_type.category]", ocsf_field: "finding_info.types" }
# ── Detection rule (analytic) ─────────────────────────────────────
- { source_path: "rule.name", ocsf_field: "finding_info.analytic.name" }
- { source_path: "rule.uuid", ocsf_field: "finding_info.analytic.uid" }
- { source_path: "rule.description", ocsf_field: "finding_info.analytic.desc" }
- { source_path: "rule.type", ocsf_field: "finding_info.analytic.type" }
- { source_path: "detection_type", ocsf_field: "finding_info.analytic.category" }
# The rule's documentation (Sekoia's `details`); rule objects carry no
# description of their own.
- { source_path: "details", ocsf_field: "finding_info.analytic.desc" }
# ── Incident state ────────────────────────────────────────────────
- { source_path: "status.name", ocsf_field: "status" }
- { source_path: "alert_type.value", ocsf_field: "activity_name" }
- { source_path: "similar", ocsf_field: "count" }
- { source_path: "urgency.value", ocsf_field: "risk_score" }
# ── MITRE ATT&CK (first TTP / kill-chain step) ────────────────────
- { source_path: "ttps[0].name", ocsf_field: "attacks.technique.name" }
- { source_path: "ttps[0].id", ocsf_field: "attacks.technique.uid" }
- { source_path: "kill_chain_short_id", ocsf_field: "attacks.tactic.uid" }
# ── Target host ───────────────────────────────────────────────────
- { source_path: "target", ocsf_field: "src_endpoint.hostname" }
# ── MITRE ATT&CK — every TTP, not only the first ──────────────────
# Sekoia names techniques by their STIX id (attack-pattern--…), not their
# ATT&CK id (T1583), and gives no tactic: kill_chain_short_id is Sekoia's
# own kill chain, not an ATT&CK tactic, so it is no longer written there.
# The array is forced with [...] so a one-TTP alert still yields a list.
- { source_path: "ttps ? [ttps[type = 'attack-pattern'].{'technique': {'name': name, 'uid': id}}]", ocsf_field: "attacks" }
# ── Source and target ─────────────────────────────────────────────
# An address goes to .ip, anything else (a host name) to .hostname. The
# target is where the activity landed: dst_endpoint, not src_endpoint.
- { source_path: "source and ($contains(source, ':') or $contains(source, /^[0-9]{1,3}(\\.[0-9]{1,3}){3}$/)) ? source", ocsf_field: "src_endpoint.ip" }
- { source_path: "source and $not($contains(source, ':') or $contains(source, /^[0-9]{1,3}(\\.[0-9]{1,3}){3}$/)) ? source", ocsf_field: "src_endpoint.hostname" }
- { source_path: "target and ($contains(target, ':') or $contains(target, /^[0-9]{1,3}(\\.[0-9]{1,3}){3}$/)) ? target", ocsf_field: "dst_endpoint.ip" }
- { source_path: "target and $not($contains(target, ':') or $contains(target, /^[0-9]{1,3}(\\.[0-9]{1,3}){3}$/)) ? target", ocsf_field: "dst_endpoint.hostname" }
# ── Owning entity / tenant ────────────────────────────────────────
- { source_path: "entity.name", ocsf_field: "cloud.org.name" }
- { source_path: "entity.uuid", ocsf_field: "cloud.org.uid" }
- { source_path: "short_id", ocsf_field: "metadata.uid" }
- { source_path: "community_uuid", ocsf_field: "metadata.tenant_uid" }
- { source_path: "'Sekoia XDR'", ocsf_field: "metadata.product.name" }
- { source_path: "'Sekoia'", ocsf_field: "metadata.product.vendor_name" }