Files
Claude 91855fce4f fix(sentinelone): count the host's other alerts from what was read (1.19.1)
A real run answered one other alert and named none: the count came
from the console's total minus the alert itself, and that total did
not hold the alert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RhL2gy6Sx6Y3FEsyBE3mZ
2026-10-12 11:37:19 +00:00

417 lines
20 KiB
Python

import datetime, json, os, re, sys, urllib.request, urllib.parse, urllib.error
# One call for an agent investigating a SentinelOne alert: what the alert is,
# the host behind it, and what else the console holds around it. An operator
# model spent ten calls rebuilding this and still lost the host — it took the
# UAM asset id (a 26-character token) for an agent id, and the raw agent list
# it fell back to ran past what a tool result can carry. Every step here is
# best effort and summarised: a step that fails is named in "errors" and the
# rest of the answer still comes back.
UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
SHA1_RE = re.compile(r"^[0-9a-fA-F]{40}$")
# List-shape fields (the ones get_unified_alerts reads, known to every console)
# for the alert itself, fetched by an id filter.
ALERT_FIELDS = """
id name description severity status result analystVerdict classification
confidenceLevel detectedAt firstSeenAt lastSeenAt storylineId fileName fileHash
analytics { name category }
detectionSource { product vendor engine }
asset { id name agentUuid agentVersion osType osVersion lastLoggedInUser connectivityToConsole }
detectionTime {
asset { domain ipV4 lastLoggedInUser osName }
attacker { host ip }
scope { accountName groupName siteName }
targetUser { name domain }
}
process { cmdLine parentName file { certSubject md5 name path sha1 sha256 } }
"""
# What only the per-alert view carries.
DETAIL_FIELDS = """
indicators { message attacks { tactic { name } technique { name uid } } }
observables { name value typeName }
relatedAlerts { id name }
process { username }
"""
SIBLING_FIELDS = "id name severity status detectedAt classification"
def request(method, url, headers, body=None):
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=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def trim(v, n):
s = "" if v is None else str(v)
return s if len(s) <= n else s[:n] + "…"
def compact(d):
"""Drop the empty values, so the answer says only what the console said."""
if isinstance(d, dict):
out = {k: compact(v) for k, v in d.items()}
return {k: v for k, v in out.items() if v not in (None, "", [], {})}
if isinstance(d, list):
return [compact(x) for x in d if x not in (None, "", [], {})]
return d
def parse_time(s):
try:
dt = datetime.datetime.fromisoformat(str(s).replace("Z", "+00:00"))
except (TypeError, ValueError):
return None
return dt if dt.tzinfo else dt.replace(tzinfo=datetime.timezone.utc)
def iso(dt):
return dt.astimezone(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
def group_count(items, key, extra=None, top=10):
"""Count items per key value, most frequent first, with the latest time seen."""
groups = {}
for it in items:
k = key(it) or "?"
g = groups.setdefault(k, {"value": k, "count": 0, "last": ""})
g["count"] += 1
t = extra(it) if extra else ""
if t and t > g["last"]:
g["last"] = t
out = sorted(groups.values(), key=lambda g: -g["count"])[:top]
if not extra:
for g in out:
g.pop("last")
return out
class Console:
def __init__(self, base, headers):
self.base, self.headers, self.errors = base, headers, []
def get(self, step, path, qs=None, missing=None):
"""missing, when given, is what a 404 means for this step: an answer,
not a failed check."""
url = self.base + path + ("?" + urllib.parse.urlencode({k: v for k, v in (qs or {}).items() if v not in (None, "")}) if qs else "")
try:
return request("GET", url, self.headers)
except urllib.error.HTTPError as e:
if e.code == 404 and missing is not None:
return missing
self.errors.append({"step": step, "error": "HTTP %d" % e.code,
"detail": trim(e.read().decode("utf-8", "replace"), 300)})
except Exception as e:
self.errors.append({"step": step, "error": trim(e, 300)})
return None
def gql(self, step, query):
try:
resp = request("POST", self.base + "/unifiedalerts/graphql", self.headers, {"query": query})
except urllib.error.HTTPError as e:
self.errors.append({"step": step, "error": "HTTP %d" % e.code,
"detail": trim(e.read().decode("utf-8", "replace"), 300)})
return None
except Exception as e:
self.errors.append({"step": step, "error": trim(e, 300)})
return None
if resp.get("errors"):
self.errors.append({"step": step, "error": "GraphQL error",
"detail": trim(json.dumps(resp["errors"]), 300)})
return resp.get("data") or {}
def alerts_query(filters, fields, first):
# Both spellings of the sort argument exist across console versions; none
# is needed here, so the query carries no sort at all.
return """query Alerts { alerts(first: %d viewType: ALL filters: [ %s ]) {
totalCount edges { node { %s } } } }""" % (first, " ".join(filters), fields)
def summarize_agent(a):
ips = []
for nic in a.get("networkInterfaces") or []:
for ip in nic.get("inet") or []:
if ip and ip not in ips:
ips.append(ip)
return compact({
"computer_name": a.get("computerName"),
"agent_id": a.get("id"),
"os": a.get("osName"),
"os_revision": a.get("osRevision"),
"machine_type": a.get("machineType"),
"domain": a.get("domain"),
"ips": ips[:6],
"external_ip": a.get("externalIp"),
"last_logged_in_user": a.get("lastLoggedInUserName"),
"agent_version": a.get("agentVersion"),
"is_active": a.get("isActive"),
"network_status": a.get("networkStatus"),
"infected": a.get("infected"),
"active_threats": a.get("activeThreats"),
"last_active": a.get("lastActiveDate"),
"site": a.get("siteName"),
"group": a.get("groupName"),
"account": a.get("accountName"),
})
def find_agent(con, agent_uuid=None, agent_id=None, name=None):
"""The host's agent, by the most precise key the alert gave."""
for step, qs in (("agent_by_id", {"ids": agent_id}),
("agent_by_uuid", {"uuids": agent_uuid}),
("agent_by_name", {"computerName": name})):
if not list(qs.values())[0]:
continue
resp = con.get(step, "/agents", dict(qs, limit=5))
data = (resp or {}).get("data") or []
if data:
return data[0], step
return None, None
def threats_summary(con, step, qs):
resp = con.get(step, "/threats", dict(qs, limit=100, sortBy="createdAt", sortOrder="desc"))
if resp is None:
return None
data = resp.get("data") or []
total = ((resp.get("pagination") or {}).get("totalItems"))
return compact({
"total": total if total is not None else len(data),
"by_name": group_count(data, lambda t: (t.get("threatInfo") or {}).get("threatName"),
lambda t: (t.get("threatInfo") or {}).get("createdAt") or ""),
"by_host": group_count(data, lambda t: (t.get("agentRealtimeInfo") or {}).get("agentComputerName")),
})
def hash_checks(con, sha1, site_id):
out = {"sha1": sha1}
verdict = con.get("hash_verdict", "/hashes/%s/verdict" % sha1,
missing={"data": {"verdict": "unknown to SentinelOne (404)"}})
if verdict is not None:
out["reputation"] = ((verdict.get("data") or {}).get("verdict")) or "unknown"
blocked = []
for scope_qs in ({"tenant": "true"}, {"siteIds": site_id} if site_id else None):
if not scope_qs:
continue
resp = con.get("blocklist", "/restrictions", dict(scope_qs, type="black_hash", value__contains=sha1, limit=10))
for r in (resp or {}).get("data") or []:
blocked.append(compact({"scope": r.get("scopeName") or r.get("scope"), "description": trim(r.get("description"), 120)}))
out["blocklisted"] = bool(blocked)
if blocked:
out["blocklist_entries"] = blocked[:5]
out["threats_fleet_wide"] = threats_summary(con, "threats_by_hash", {"contentHashes": sha1})
return compact(out)
def investigate_uam(con, alert_id, days):
data = con.gql("alert", alerts_query(['{ fieldId: "id" stringEqual: { value: %s } }' % json.dumps(alert_id)],
ALERT_FIELDS, 1))
if data is None:
# The console could not be asked: that is a failed call, not an alert
# that does not exist.
raise RuntimeError("the alert could not be read: %s" % json.dumps(con.errors[-1]))
edges = (data.get("alerts") or {}).get("edges") or []
if not edges:
return {"found": False, "alert_id": alert_id,
"hint": "No unified alert has this id. Copy the id whole from get_incident_context (raw id), or pass a threat_id for a threat."}
a = edges[0]["node"]
det = (con.gql("alert_detail", "query D { alert(id: %s) { %s } }" % (json.dumps(alert_id), DETAIL_FIELDS)) or {}).get("alert") or {}
proc = a.get("process") or {}
f = proc.get("file") or {}
dt_asset = (a.get("detectionTime") or {}).get("asset") or {}
scope = (a.get("detectionTime") or {}).get("scope") or {}
indicators = []
for ind in (det.get("indicators") or [])[:10]:
attacks = ["%s / %s (%s)" % ((x.get("tactic") or {}).get("name"), (x.get("technique") or {}).get("name"),
(x.get("technique") or {}).get("uid")) for x in ind.get("attacks") or []]
indicators.append(compact({"message": trim(ind.get("message"), 220), "mitre": attacks[:3]}))
observables, seen = [], set()
for ob in (det.get("observables") or []):
key = (ob.get("typeName") or ob.get("name"), ob.get("value"))
if key in seen or not ob.get("value"):
continue
seen.add(key)
observables.append("%s: %s" % (key[0], trim(key[1], 160)))
out = {
"kind": "unified alert",
"alert": compact({
"id": a.get("id"), "name": a.get("name"), "description": trim(a.get("description"), 400),
"severity": a.get("severity"), "classification": a.get("classification"),
"confidence": a.get("confidenceLevel"), "status": a.get("status"),
"analyst_verdict": a.get("analystVerdict"), "mitigation_result": a.get("result"),
"detected_at": a.get("detectedAt"), "first_seen": a.get("firstSeenAt"), "last_seen": a.get("lastSeenAt"),
"rule": (a.get("analytics") or {}).get("name"), "rule_category": (a.get("analytics") or {}).get("category"),
"detected_by": " ".join(x for x in ((a.get("detectionSource") or {}).get("product"),
(a.get("detectionSource") or {}).get("engine")) if x),
"storyline_id": a.get("storylineId"),
"attacker": (a.get("detectionTime") or {}).get("attacker"),
"target_user": (a.get("detectionTime") or {}).get("targetUser"),
"scope": " / ".join(x for x in (scope.get("accountName"), scope.get("siteName"), scope.get("groupName")) if x),
}),
"process": compact({
"command_line": trim(proc.get("cmdLine"), 600), "parent": proc.get("parentName"),
"user": det.get("process", {}).get("username") if det.get("process") else None,
"file": f.get("name") or a.get("fileName"), "path": f.get("path"),
"sha1": f.get("sha1"), "sha256": f.get("sha256"), "md5": f.get("md5"),
"signer": f.get("certSubject"),
}),
"indicators": indicators,
"observables": observables[:25],
"related_alerts": [trim(r.get("name"), 120) for r in (det.get("relatedAlerts") or [])[:10]],
}
asset = a.get("asset") or {}
agent, how = find_agent(con, agent_uuid=asset.get("agentUuid"), name=asset.get("name"))
out["host"] = summarize_agent(agent) if agent else compact({
"computer_name": asset.get("name"), "os": dt_asset.get("osName") or asset.get("osVersion"),
"ip_at_detection": dt_asset.get("ipV4"), "domain": dt_asset.get("domain"),
"last_logged_in_user": dt_asset.get("lastLoggedInUser") or asset.get("lastLoggedInUser"),
"agent_version": asset.get("agentVersion"), "connectivity": asset.get("connectivityToConsole"),
"note": "no SentinelOne agent found for this asset (not an endpoint, or uninstalled): fields from the alert",
})
if agent:
out["host"]["found_by"] = how
if dt_asset.get("ipV4"):
out["host"]["ip_at_detection"] = dt_asset.get("ipV4")
anchor = parse_time(a.get("detectedAt")) or datetime.datetime.now(datetime.timezone.utc)
start, end = anchor - datetime.timedelta(days=days), anchor + datetime.timedelta(days=1)
window = "%s to %s (%d days before the alert's detection to 1 day after)" % (iso(start), iso(end), days)
elsewhere = {"window": window}
if asset.get("id"):
sib = con.gql("same_host_alerts", alerts_query([
'{ fieldId: "asset.id" stringEqual: { value: %s } }' % json.dumps(asset["id"]),
'{ fieldId: "detectedAt" dateTimeRange: { start: %d end: %d } }' % (start.timestamp() * 1000, end.timestamp() * 1000),
], SIBLING_FIELDS, 100))
alerts = (sib or {}).get("alerts") or {}
edges_all = [e["node"] for e in alerts.get("edges") or [] if e.get("node")]
nodes = [n for n in edges_all if n.get("id") != alert_id]
if sib is not None:
# Counted from what was read, never from totalCount minus one: a
# real run reported "1 other alert" with none to name, because the
# console's total did not hold this alert. The console's own count
# is only given when more alerts exist than were read.
other = {"count": len(nodes),
"by_name": group_count(nodes, lambda n: n.get("name"), lambda n: n.get("detectedAt") or "")}
total = alerts.get("totalCount")
if total is not None and total > len(edges_all):
other["console_total_including_this_alert"] = total
other["note"] = "more alerts than the 100 read"
elsewhere["same_host_other_alerts"] = compact(other) if nodes else "none"
if agent:
elsewhere["same_host_threats"] = threats_summary(con, "threats_on_host", {
"agentIds": agent.get("id"), "createdAt__gt": iso(start), "createdAt__lt": iso(end)})
sha1 = f.get("sha1") or (a.get("fileHash") if SHA1_RE.match(str(a.get("fileHash") or "")) else None)
if sha1:
out["file_checks"] = hash_checks(con, sha1, agent.get("siteId") if agent else None)
else:
out["file_checks"] = "no SHA1 on this alert: reputation, blocklist and fleet-wide checks skipped"
out["elsewhere"] = compact(elsewhere)
return out
def investigate_threat(con, threat_id, days):
resp = con.get("threat", "/threats", {"ids": threat_id})
if resp is None:
raise RuntimeError("the threat could not be read: %s" % json.dumps(con.errors[-1]))
data = (resp or {}).get("data") or []
if not data:
return {"found": False, "threat_id": threat_id,
"hint": "No threat has this id. Copy the id whole from get_incident_context (threatInfo.threatId), or pass alert_id for a unified alert."}
t = data[0]
ti, rt, di = t.get("threatInfo") or {}, t.get("agentRealtimeInfo") or {}, t.get("agentDetectionInfo") or {}
out = {
"kind": "threat",
"threat": compact({
"id": ti.get("threatId"), "name": ti.get("threatName"), "classification": ti.get("classification"),
"confidence": ti.get("confidenceLevel"), "analyst_verdict": ti.get("analystVerdict"),
"incident_status": ti.get("incidentStatus"), "mitigation_status": ti.get("mitigationStatus"),
"detection_type": ti.get("detectionType"), "engines": ti.get("engines"),
"created_at": ti.get("createdAt"), "identified_at": ti.get("identifiedAt"),
"storyline": ti.get("storyline"),
}),
"process": compact({
"file_path": ti.get("filePath"), "sha1": ti.get("sha1"), "sha256": ti.get("sha256"), "md5": ti.get("md5"),
"signer": ti.get("publisherName"), "verified": ti.get("fileVerificationType"),
"originating_process": ti.get("originatorProcess"), "process_user": ti.get("processUser"),
"command_line": trim(ti.get("maliciousProcessArguments"), 600),
"initiated_by": ti.get("initiatedBy"),
}),
}
notes = con.get("threat_notes", "/threats/%s/notes" % threat_id, {"limit": 10})
out["notes"] = [compact({"by": n.get("creator"), "at": n.get("createdAt"), "text": trim(n.get("text"), 300)})
for n in (notes or {}).get("data") or []]
acts = con.get("threat_timeline", "/activities", {"threatIds": threat_id, "limit": 30, "sortBy": "createdAt", "sortOrder": "asc"})
out["timeline"] = ["%s %s" % (x.get("createdAt"), trim(x.get("primaryDescription"), 200))
for x in (acts or {}).get("data") or []][:20]
agent, how = find_agent(con, agent_id=rt.get("agentId"), name=rt.get("agentComputerName"))
out["host"] = summarize_agent(agent) if agent else compact({
"computer_name": rt.get("agentComputerName"), "os": rt.get("agentOsName"),
"agent_version": rt.get("agentVersion"), "network_status": rt.get("networkStatus"),
"note": "agent not found in the console: fields from the threat",
})
if agent:
out["host"]["found_by"] = how
out["host"]["user_at_detection"] = di.get("agentLastLoggedInUserName")
out["host"]["ip_at_detection"] = di.get("agentIpV4")
out["host"] = compact(out["host"])
anchor = parse_time(ti.get("createdAt")) or datetime.datetime.now(datetime.timezone.utc)
start, end = anchor - datetime.timedelta(days=days), anchor + datetime.timedelta(days=1)
elsewhere = {"window": "%s to %s (%d days before the threat to 1 day after)" % (iso(start), iso(end), days)}
if agent:
elsewhere["same_host_threats"] = threats_summary(con, "threats_on_host", {
"agentIds": agent.get("id"), "createdAt__gt": iso(start), "createdAt__lt": iso(end)})
out["elsewhere"] = compact(elsewhere)
if ti.get("sha1"):
out["file_checks"] = hash_checks(con, ti["sha1"], agent.get("siteId") if agent else None)
return out
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/") + "/web/api/v2.1"
con = Console(base, {
"Authorization": "ApiToken " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
})
alert_id = str(inputs.get("alert_id") or "").strip()
threat_id = str(inputs.get("threat_id") or "").strip()
days = max(1, min(int(inputs.get("days_before") or 7), 30))
# A long number in alert_id is a threat id given in the wrong field.
if alert_id.isdigit() and not threat_id:
threat_id, alert_id = alert_id, ""
if threat_id:
if not threat_id.isdigit():
raise ValueError("threat_id is a long number (about 19 digits); for a UUID use alert_id")
out = investigate_threat(con, threat_id, days)
elif alert_id:
if not UUID_RE.match(alert_id):
raise ValueError("alert_id is the unified alert's UUID (8-4-4-4-12 hex), copied whole; got %r (asset.id and agentUuid are not alert ids)" % alert_id)
out = investigate_uam(con, alert_id, days)
else:
raise ValueError("give alert_id (unified alert UUID) or threat_id (threat number)")
if con.errors:
out["errors"] = con.errors[:8]
print(json.dumps(out, ensure_ascii=False, separators=(",", ":")))
try:
main()
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)