feat(cortex-xdr): ingest alerts, not only incidents (v1.3.0)

An XDR incident is an aggregate; the SOC works the detections under it. The
alerts endpoint was already exposed as a read command, but nothing could feed
an alert rule with it — no results path, no dedup key, no watermark, no mapper,
no incident type. All five are here now, so an alert rule can be pointed at
reply.alerts the same way it is pointed at reply.incidents.

get_alerts pages past the API's 100-results-per-call ceiling: an alert feed
carries far more than a hundred detections between two polls, and whatever a
single page leaves behind is never fetched again, because the next run's
watermark has already moved past it. On an incremental fetch it also sorts
oldest first, so a window larger than the limit drops its most recent alerts —
the only ones the next poll can still see — and says so via `truncated`.

Two fixes to the incident side while in the same files:

- The severity expression compared strings, which the mapping engine cannot do
  (it reads numeric comparisons only). Every test read as false, so every
  ingested incident silently took the alert rule's default severity. The bare
  field works: Riposte maps critical/high/medium/low onto 1-5 itself.
- The incident mapper carried no `time`, so the detection anchor was missing
  and MTTD stayed empty for the whole feed. creation_time fills it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-18 23:26:31 +02:00
parent c22982c8af
commit 17318d4225
5 changed files with 135 additions and 13 deletions
+38 -5
View File
@@ -76,19 +76,52 @@ def to_ms(v):
return None
# The API serves at most 100 alerts per call. Ingestion is what makes that a
# real ceiling: an alert feed easily carries more than a hundred detections
# between two polls, and anything a single page leaves behind is never fetched
# again — the next run's watermark has already moved past it.
PAGE = 100
def main():
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
limit = int(inputs.get("limit") or 100)
limit = max(1, int(inputs.get("limit") or 100))
filters = []
if inputs.get("severity"):
filters.append({"field": "severity", "operator": "in", "value": csv(inputs["severity"])})
created_ms = to_ms(inputs.get("created_after"))
if created_ms is not None:
filters.append({"field": "source_insert_ts", "operator": "gte", "value": created_ms})
rd = {"search_from": 0, "search_to": limit, "sort": {"field": "source_insert_ts", "keyword": "desc"}}
if filters:
rd["filters"] = filters
print(json.dumps(post("/alerts/get_alerts_by_filter_data/", rd)))
# Oldest first on an incremental fetch, so that a window holding more alerts
# than `limit` drops its most RECENT ones — the only ones the next poll can
# still see. Newest first otherwise, which is what an operator running the
# command by hand is asking for.
keyword = "asc" if created_ms is not None else "desc"
alerts, total = [], None
while len(alerts) < limit:
rd = {
"search_from": len(alerts),
"search_to": min(len(alerts) + PAGE, limit),
"sort": {"field": "source_insert_ts", "keyword": keyword},
}
if filters:
rd["filters"] = filters
reply = (post("/alerts/get_alerts_by_filter_data/", rd) or {}).get("reply") or {}
page = reply.get("alerts") or []
if total is None:
total = reply.get("total_count")
alerts.extend(page)
if len(page) < PAGE:
break
out = {"result_count": len(alerts), "alerts": alerts}
if total is not None:
out["total_count"] = total
# Say it when the window was larger than the limit: those alerts are not
# coming back on the next poll, and a silent cap reads like a quiet feed.
out["truncated"] = total > len(alerts)
print(json.dumps({"reply": out}))
try: