88407dd1b4
An alert only summarises its detection; addresses, user, process and command line live in the events tagged with its short id. with_events runs one event search per alert (alert_short_ids, first_seen_at - 1 min to now, as Sekoia's XSOAR integration does), in parallel within the 60 s command budget, and nests the dotted ECS names. A search not done in time leaves events_pending + events_job_uuid: the alert still goes out, as the rule's watermark would not offer it again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBnLYwQbUCaf5vpv77N14Q
149 lines
5.4 KiB
Python
149 lines
5.4 KiB
Python
import json, os, sys, time, urllib.request, urllib.parse, urllib.error
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
# Riposte stops a command after 60 s. Event searches get what is left of this
|
|
# budget once the alerts are listed; the margin covers container start-up.
|
|
STARTED = time.monotonic()
|
|
BUDGET = 45
|
|
|
|
|
|
def _cfg():
|
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
|
base = str(s.get("url") or "https://api.sekoia.io").rstrip("/")
|
|
headers = {"Authorization": "Bearer " + s.get("api_key", ""), "Content-Type": "application/json", "Accept": "application/json"}
|
|
return base, headers
|
|
|
|
|
|
def _inputs():
|
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
|
|
|
|
|
def left():
|
|
return BUDGET - (time.monotonic() - STARTED)
|
|
|
|
|
|
def request(method, path, params=None, body=None):
|
|
base, headers = _cfg()
|
|
url = base + "/" + path.lstrip("/")
|
|
if params:
|
|
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
|
if clean:
|
|
url += "?" + urllib.parse.urlencode(clean, doseq=True)
|
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
with urllib.request.urlopen(req, timeout=max(5, min(30, left()))) as r:
|
|
raw = r.read()
|
|
return json.loads(raw) if raw else {}
|
|
|
|
|
|
def nest(event):
|
|
"""Sekoia returns events flat, ECS names as dotted keys ("source.ip").
|
|
Rebuild the tree so a mapper reads events[0].source.ip; a key whose prefix
|
|
is already a plain value stays flat rather than overwrite it."""
|
|
out = {}
|
|
for key in sorted(event, key=lambda k: k.count(".")):
|
|
node, parts = out, key.split(".")
|
|
for p in parts[:-1]:
|
|
if p not in node:
|
|
node[p] = {}
|
|
if not isinstance(node[p], dict):
|
|
node = None
|
|
break
|
|
node = node[p]
|
|
if node is None or parts[-1] in node:
|
|
out[key] = event[key]
|
|
else:
|
|
node[parts[-1]] = event[key]
|
|
return out
|
|
|
|
|
|
def start_search(alert):
|
|
# The alert's events are the ones tagged with its short id. Sekoia's own
|
|
# XSOAR integration searches from first_seen_at less a minute up to now:
|
|
# events are indexed slightly after the time they carry.
|
|
try:
|
|
first = datetime.fromisoformat(str(alert.get("first_seen_at")).replace("Z", "+00:00"))
|
|
earliest = (first - timedelta(minutes=1)).astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
except ValueError:
|
|
earliest = "-7d"
|
|
job = request("POST", "/v1/sic/conf/events/search/jobs", body={
|
|
"earliest_time": earliest,
|
|
"latest_time": "now",
|
|
"term": "alert_short_ids:" + str(alert.get("short_id")),
|
|
})
|
|
return job.get("uuid")
|
|
|
|
|
|
def collect(job_uuid, max_events):
|
|
path = "/v1/sic/conf/events/search/jobs/" + urllib.parse.quote(job_uuid, safe="")
|
|
while left() > 2:
|
|
if request("GET", path).get("status") == 2:
|
|
res = request("GET", path + "/events", params={"limit": max_events})
|
|
return [nest(e) for e in res.get("items") or []], res.get("total")
|
|
time.sleep(1)
|
|
return None, None
|
|
|
|
|
|
def attach_events(alerts, max_events):
|
|
"""Put each alert's events on it, as events (nested) and events_total. A
|
|
search not done within the budget leaves events_pending and the job uuid
|
|
(events_results_query reads it later): the alert still goes out, because
|
|
the rule's watermark moves on with this run and would not offer it again."""
|
|
def one(alert):
|
|
try:
|
|
job = start_search(alert)
|
|
events, total = collect(job, max_events) if job else (None, None)
|
|
except Exception as e:
|
|
alert["events_error"] = str(e)
|
|
return
|
|
if events is None:
|
|
alert["events_pending"] = True
|
|
alert["events_job_uuid"] = job
|
|
else:
|
|
alert["events"] = events
|
|
alert["events_total"] = total
|
|
with ThreadPoolExecutor(max_workers=10) as pool:
|
|
list(pool.map(one, alerts))
|
|
|
|
|
|
def _range(value):
|
|
# A bare date is the lower bound ("<date>,now"); a full range is passed through.
|
|
v = str(value)
|
|
return v if "," in v else (v + ",now")
|
|
|
|
|
|
def run():
|
|
inp = _inputs()
|
|
params = {
|
|
"offset": inp.get("offset") or 0,
|
|
"direction": inp.get("direction") or "asc",
|
|
"sort": inp.get("sort_by") or "created_at",
|
|
}
|
|
if inp.get("limit"):
|
|
params["limit"] = inp["limit"]
|
|
if inp.get("status"):
|
|
params["match[status_name]"] = inp["status"]
|
|
if inp.get("created_at"):
|
|
params["date[created_at]"] = _range(inp["created_at"])
|
|
if inp.get("updated_at"):
|
|
params["date[updated_at]"] = _range(inp["updated_at"])
|
|
if inp.get("urgency"):
|
|
params["range[urgency]"] = inp["urgency"]
|
|
if inp.get("alerts_type"):
|
|
params["match[type_value]"] = inp["alerts_type"]
|
|
out = request("GET", "/v1/sic/alerts", params=params)
|
|
if str(inp.get("with_events") or "").lower() in ("true", "1", "yes"):
|
|
attach_events(out.get("items") or [], int(inp.get("max_events") or 10))
|
|
print(json.dumps(out))
|
|
|
|
|
|
try:
|
|
run()
|
|
except urllib.error.HTTPError as e:
|
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
|
sys.exit(1)
|
|
except Exception as e:
|
|
print(json.dumps({"error": str(e)}))
|
|
sys.exit(1)
|