Compare commits

..

3 Commits

Author SHA1 Message Date
f3nris db9a45c9bc docs(mappers): MTTD is not retired, only re-anchored
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>
2026-10-07 09:58:20 +00:00
f3nris ece2c41340 docs(mappers): time no longer dates detection
Eight mappers (Cortex XDR, CrowdStrike, Rapid7) still called the OCSF
`time` field "the MTTD anchor" that Riposte reads to stamp detected_at.
Riposte retired MTTD in migration 0256 and stamps detection at
ingestion for every alert; the mappings stay, as the alert's own record
of when the source saw the event. Comments only: no mapping changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:56:50 +00:00
f3nris ea04ea586c 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>
2026-10-07 09:13:03 +00:00
10 changed files with 76 additions and 39 deletions
@@ -23,11 +23,13 @@ ocsf:
- { source_path: "alert_category", ocsf_field: "finding_info.analytic.category" } - { source_path: "alert_category", ocsf_field: "finding_info.analytic.category" }
- { source_path: "alert_name", ocsf_field: "finding_info.analytic.name" } - { source_path: "alert_name", ocsf_field: "finding_info.analytic.name" }
- { source_path: "matching_service_rule_id", ocsf_field: "finding_info.analytic.uid" } - { source_path: "matching_service_rule_id", ocsf_field: "finding_info.analytic.uid" }
# ── Detection time: the MTTD anchor ─────────────────────────────── # ── Event time (OCSF `time`) ───────────────────────────────────────
# `time` is what Riposte measures detection-to-ingestion against. The grid # Riposte no longer reads it to date detection: since migration 0256, MTTD
# exposes when the tenant took the alert in (source_insert_ts); a sensor-side # starts when Riposte records the alert. It is the alert's own record of when
# detection timestamp, when the tenant sends one, is the better anchor and # the source saw the event.
# comes last so it wins. # The grid exposes when the tenant took the alert in (source_insert_ts); a
# sensor-side detection timestamp, when the tenant sends one, is closer to the
# event and comes last so it wins.
- { source_path: "source_insert_ts", ocsf_field: "time" } - { source_path: "source_insert_ts", ocsf_field: "time" }
- { source_path: "detection_timestamp", ocsf_field: "time" } - { source_path: "detection_timestamp", ocsf_field: "time" }
# ── Alert state ─────────────────────────────────────────────────── # ── Alert state ───────────────────────────────────────────────────
@@ -18,8 +18,11 @@ ocsf:
- { source_path: "modification_time", ocsf_field: "finding_info.modified_time" } - { source_path: "modification_time", ocsf_field: "finding_info.modified_time" }
- { source_path: "xdr_url", ocsf_field: "finding_info.src_url" } - { source_path: "xdr_url", ocsf_field: "finding_info.src_url" }
- { source_path: "status", ocsf_field: "status" } - { source_path: "status", ocsf_field: "status" }
# `time` is the MTTD anchor — when XDR opened the incident, as opposed to when # `time` is the OCSF event time — when XDR opened the incident, as opposed to
# Riposte ingested it. Without it the detection delay column stays empty. # when Riposte ingested it.
# 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.
- { source_path: "creation_time", ocsf_field: "time" } - { source_path: "creation_time", ocsf_field: "time" }
- { source_path: "alert_count", ocsf_field: "count" } - { source_path: "alert_count", ocsf_field: "count" }
# ── MITRE ATT&CK (first aggregated tactic/technique) ────────────── # ── MITRE ATT&CK (first aggregated tactic/technique) ──────────────
@@ -35,7 +35,10 @@ ocsf:
# The detection that opened the incident, so an analyst reading the aggregate # The detection that opened the incident, so an analyst reading the aggregate
# can still pivot to the single alert in the XDR console. # can still pivot to the single alert in the XDR console.
- { source_path: "alerts[0].alert_id", ocsf_field: "finding_info.uid_alt" } - { source_path: "alerts[0].alert_id", ocsf_field: "finding_info.uid_alt" }
# ── Detection time: the MTTD anchor ─────────────────────────────── # ── 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.
# Weakest first, strongest last. detection_time is often null on an XDR # Weakest first, strongest last. detection_time is often null on an XDR
# incident, and then the first alert's own detection timestamp is the honest # incident, and then the first alert's own detection timestamp is the honest
# anchor; incident creation is the last resort. # anchor; incident creation is the last resort.
@@ -27,11 +27,10 @@ ocsf:
- { source_path: "name", ocsf_field: "finding_info.title" } - { source_path: "name", ocsf_field: "finding_info.title" }
- { source_path: "description", ocsf_field: "finding_info.desc" } - { source_path: "description", ocsf_field: "finding_info.desc" }
- { source_path: "created_timestamp", ocsf_field: "finding_info.created_time" } - { source_path: "created_timestamp", ocsf_field: "finding_info.created_time" }
# `time` is the MTTD anchor: the ingestion engine reads THIS field and no other # `time` is the OCSF event time: when Falcon raised the alert.
# to stamp detected_at. Without it the incident is anchored at ingestion, so the # Riposte no longer reads it to date detection: since migration 0256, MTTD
# delay between Falcon detecting and Riposte collecting — over a day on a slow # starts when Riposte records the alert. It is the alert's own record of when
# poll — is measured as zero. finding_info.created_time below does NOT serve # the source saw the event.
# this: it is the finding's own record of the same instant, read by nothing.
- { source_path: "created_timestamp", ocsf_field: "time" } - { source_path: "created_timestamp", ocsf_field: "time" }
- { source_path: "updated_timestamp", ocsf_field: "finding_info.modified_time" } - { source_path: "updated_timestamp", ocsf_field: "finding_info.modified_time" }
- { source_path: "scenario", ocsf_field: "finding_info.analytic.name" } - { source_path: "scenario", ocsf_field: "finding_info.analytic.name" }
@@ -67,7 +67,10 @@ ocsf:
- { source_path: "organization_id", ocsf_field: "cloud.org.uid" } - { source_path: "organization_id", ocsf_field: "cloud.org.uid" }
- { source_path: "assignee.name", ocsf_field: "assignee.name" } - { source_path: "assignee.name", ocsf_field: "assignee.name" }
- { source_path: "assignee.email", ocsf_field: "assignee.email_addr" } - { source_path: "assignee.email", ocsf_field: "assignee.email_addr" }
# ── Detection time: the MTTD anchor ─────────────────────────────── # ── 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.
# Weakest first, strongest last. The investigation is created after the alert that # Weakest first, strongest last. The investigation is created after the alert that
# opened it; the alert's own alerted_at is when the event happened in the source system. # opened it; the alert's own alerted_at is when the event happened in the source system.
- { source_path: "created_time", ocsf_field: "time" } - { source_path: "created_time", ocsf_field: "time" }
@@ -41,9 +41,11 @@ ocsf:
- { source_path: "detection_name", ocsf_field: "finding_info.title" } - { source_path: "detection_name", ocsf_field: "finding_info.title" }
- { source_path: "detection_name", ocsf_field: "finding_info.analytic.name" } - { source_path: "detection_name", ocsf_field: "finding_info.analytic.name" }
- { source_path: "$fromMillis(timestamp)", ocsf_field: "finding_info.created_time" } - { source_path: "$fromMillis(timestamp)", ocsf_field: "finding_info.created_time" }
# ── Detection time: the MTTD anchor ─────────────────────────────── # ── Event time (OCSF `time`) ───────────────────────────────────────
# The log's own timestamp, not the poll's. A feed that ran late must not make the # Riposte no longer reads it to date detection: since migration 0256, MTTD
# detection look late with it. # starts when Riposte records the alert. It is the alert's own record of when
# the source saw the event.
# The log's own timestamp, not the poll's.
- { source_path: "$fromMillis(timestamp)", ocsf_field: "time" } - { source_path: "$fromMillis(timestamp)", ocsf_field: "time" }
# ── Product identity ────────────────────────────────────────────── # ── Product identity ──────────────────────────────────────────────
- { source_path: "'Rapid7 InsightIDR'", ocsf_field: "metadata.product.name" } - { source_path: "'Rapid7 InsightIDR'", ocsf_field: "metadata.product.name" }
@@ -40,9 +40,11 @@ ocsf:
- { source_path: "detection_name", ocsf_field: "finding_info.title" } - { source_path: "detection_name", ocsf_field: "finding_info.title" }
- { source_path: "detection_name", ocsf_field: "finding_info.analytic.name" } - { source_path: "detection_name", ocsf_field: "finding_info.analytic.name" }
- { source_path: "$fromMillis(timestamp)", ocsf_field: "finding_info.created_time" } - { source_path: "$fromMillis(timestamp)", ocsf_field: "finding_info.created_time" }
# ── Detection time: the MTTD anchor ─────────────────────────────── # ── Event time (OCSF `time`) ───────────────────────────────────────
# The log's own timestamp, not the poll's. A feed that ran late must not make the # Riposte no longer reads it to date detection: since migration 0256, MTTD
# detection look late with it. # starts when Riposte records the alert. It is the alert's own record of when
# the source saw the event.
# The log's own timestamp, not the poll's.
- { source_path: "$fromMillis(timestamp)", ocsf_field: "time" } - { source_path: "$fromMillis(timestamp)", ocsf_field: "time" }
# ── Product identity ────────────────────────────────────────────── # ── Product identity ──────────────────────────────────────────────
- { source_path: "'Rapid7 InsightIDR'", ocsf_field: "metadata.product.name" } - { source_path: "'Rapid7 InsightIDR'", ocsf_field: "metadata.product.name" }
@@ -50,7 +50,10 @@ ocsf:
- { source_path: "type", ocsf_field: "finding_info.analytic.name" } - { source_path: "type", ocsf_field: "finding_info.analytic.name" }
- { source_path: "rule.rrn", ocsf_field: "finding_info.analytic.uid" } - { source_path: "rule.rrn", ocsf_field: "finding_info.analytic.uid" }
- { source_path: "rule.version_rrn", ocsf_field: "finding_info.analytic.version" } - { source_path: "rule.version_rrn", ocsf_field: "finding_info.analytic.version" }
# ── Detection time: the MTTD anchor ─────────────────────────────── # ── 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 # 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. # when Rapid7 finished building the alert. Weakest first, strongest last.
- { source_path: "created_at", ocsf_field: "time" } - { source_path: "created_at", ocsf_field: "time" }
+2 -2
View File
@@ -1,8 +1,8 @@
id: sekoia_xdr id: sekoia_xdr
name: Sekoia XDR name: Sekoia XDR
version: 1.1.0 version: 1.2.0
description: "Sekoia Defend (XDR) REST API — alert ingestion and lifecycle (list/get/search, status workflow, comments), event search jobs (create/status/results + one-shot search), cases, asset management (get/list, attributes/keys add/remove), users, kill chains and a generic authenticated HTTP passthrough." description: "Sekoia Defend (XDR) REST API — alert ingestion and lifecycle (list/get/search, status workflow, comments), event search jobs (create/status/results + one-shot search), cases, asset management (get/list, attributes/keys add/remove), users, kill chains and a generic authenticated HTTP passthrough."
changelog: "1.1.0 — Declare the risk tier on the 1 command that carried none, all of them destructive. An unset risk was already gated as destructive, so the approval prompt does not move; what changes is that the tier is a stated decision rather than a default, and a command added later cannot inherit it by omission. | 1.0.0 — Initial release: alert ingestion (list_alerts) with an exhaustive OCSF mapper, 20 commands across alerts, event search, cases, assets, users and kill chains." changelog: "1.2.0 — Mapper reviewed on a real Entra ID alert. The incident source is now 'Sekoia XDR': it carried Sekoia's own `source` field, the alert's source address, which now goes to src_endpoint.ip (or .hostname for a host), and `target` to dst_endpoint instead of src_endpoint. The description is the alert's sentence with its {rule:<uuid>} placeholder replaced by the rule name, rather than the rule documentation (now finding_info.analytic.desc). Every TTP is mapped into attacks, not only the first; kill_chain_short_id, Sekoia's own kill chain, no longer poses as an ATT&CK tactic uid. Times are epoch milliseconds as OCSF expects (created_at/updated_at came in seconds, first/last seen as text); alert category in finding_info.types, detection type in analytic.category, product in metadata. | 1.1.0 — Declare the risk tier on the 1 command that carried none, all of them destructive. An unset risk was already gated as destructive, so the approval prompt does not move; what changes is that the tier is a stated decision rather than a default, and a command added later cannot inherit it by omission. | 1.0.0 — Initial release: alert ingestion (list_alerts) with an exhaustive OCSF mapper, 20 commands across alerts, event search, cases, assets, users and kill chains."
category: siem category: siem
# Per-instance configuration. The API host defaults to the EU region; an # Per-instance configuration. The API host defaults to the EU region; an
@@ -1,43 +1,63 @@
name: "Sekoia XDR Alerts → OCSF" 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: field_mappings:
title: "title" 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 # 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 — # 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. # 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" 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. # The producing system. Sekoia's own `source` field is the alert's source
source: "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. # results_path = items; source_path is JSONata over ONE alert object.
# Paths absent from a given alert return nothing and are skipped. # Paths absent from a given alert return nothing and are skipped.
ocsf: ocsf:
# ── Finding ─────────────────────────────────────────────────────── # ── Finding ───────────────────────────────────────────────────────
- { source_path: "uuid", ocsf_field: "finding_info.uid" } - { source_path: "uuid", ocsf_field: "finding_info.uid" }
- { source_path: "title", ocsf_field: "finding_info.title" } - { source_path: "title", ocsf_field: "finding_info.title" }
- { source_path: "rule.description", ocsf_field: "finding_info.desc" } - { source_path: "description ? $replace(description, '{rule:' & rule.uuid & '}', rule.name)", ocsf_field: "finding_info.desc" }
- { source_path: "created_at", ocsf_field: "finding_info.created_time" } # OCSF times are epoch milliseconds: created_at/updated_at come in seconds,
- { source_path: "updated_at", ocsf_field: "finding_info.modified_time" } # first/last seen as ISO 8601.
- { source_path: "first_seen_at", ocsf_field: "finding_info.first_seen_time" } - { source_path: "created_at * 1000", ocsf_field: "finding_info.created_time" }
- { source_path: "last_seen_at", ocsf_field: "finding_info.last_seen_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) ───────────────────────────────────── # ── Detection rule (analytic) ─────────────────────────────────────
- { source_path: "rule.name", ocsf_field: "finding_info.analytic.name" } - { source_path: "rule.name", ocsf_field: "finding_info.analytic.name" }
- { source_path: "rule.uuid", ocsf_field: "finding_info.analytic.uid" } - { 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: "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 ──────────────────────────────────────────────── # ── Incident state ────────────────────────────────────────────────
- { source_path: "status.name", ocsf_field: "status" } - { source_path: "status.name", ocsf_field: "status" }
- { source_path: "alert_type.value", ocsf_field: "activity_name" } - { source_path: "alert_type.value", ocsf_field: "activity_name" }
- { source_path: "similar", ocsf_field: "count" } - { source_path: "similar", ocsf_field: "count" }
- { source_path: "urgency.value", ocsf_field: "risk_score" } - { source_path: "urgency.value", ocsf_field: "risk_score" }
# ── MITRE ATT&CK (first TTP / kill-chain step) ──────────────────── # ── MITRE ATT&CK — every TTP, not only the first ──────────────────
- { source_path: "ttps[0].name", ocsf_field: "attacks.technique.name" } # Sekoia names techniques by their STIX id (attack-pattern--…), not their
- { source_path: "ttps[0].id", ocsf_field: "attacks.technique.uid" } # ATT&CK id (T1583), and gives no tactic: kill_chain_short_id is Sekoia's
- { source_path: "kill_chain_short_id", ocsf_field: "attacks.tactic.uid" } # own kill chain, not an ATT&CK tactic, so it is no longer written there.
# ── Target host ─────────────────────────────────────────────────── # The array is forced with [...] so a one-TTP alert still yields a list.
- { source_path: "target", ocsf_field: "src_endpoint.hostname" } - { 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 ──────────────────────────────────────── # ── Owning entity / tenant ────────────────────────────────────────
- { source_path: "entity.name", ocsf_field: "cloud.org.name" } - { source_path: "entity.name", ocsf_field: "cloud.org.name" }
- { source_path: "entity.uuid", ocsf_field: "cloud.org.uid" } - { source_path: "entity.uuid", ocsf_field: "cloud.org.uid" }
- { source_path: "short_id", ocsf_field: "metadata.uid" } - { source_path: "short_id", ocsf_field: "metadata.uid" }
- { source_path: "community_uuid", ocsf_field: "metadata.tenant_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" }