feat(sentinelone): investigate_alert, one call for an agent (1.19.0)
Wraith spent ten calls on a real unified alert and still lost the host: it took the UAM asset id for an agent id and the raw agent list ran past what a tool result carries. investigate_alert reads the alert, its detail, the host's agent (by id or UUID, then name), the other alerts and threats on that host around the detection, and the file SHA1's reputation, blocklist and fleet-wide threats, summarised in one answer. The operator sheet now starts with it (budget 8). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015RhL2gy6Sx6Y3FEsyBE3mZ
This commit is contained in:
@@ -6,7 +6,7 @@ handle: Wraith
|
||||
title: SentinelOne Singularity operator
|
||||
role: analyst_l2 # needs integrations.execute — viewer would hide every command
|
||||
approval_threshold: safe_write
|
||||
max_tool_calls: 12
|
||||
max_tool_calls: 8
|
||||
|
||||
when_to_delegate: |
|
||||
Anything SentinelOne can tell about an endpoint: the threat or alert behind
|
||||
@@ -28,46 +28,41 @@ returns: |
|
||||
No verdict.
|
||||
|
||||
instructions: |
|
||||
Your budget is 12 calls. Spend it in this order:
|
||||
1. get_incident_context (1 call). Its raw fields tell what the alert is:
|
||||
- a threat (threatInfo.threatId, agentRealtimeInfo.agentComputerName,
|
||||
threatInfo.sha1): go to 2.
|
||||
- a unified (UAM) alert (id is a UUID, asset.id, asset.name): call
|
||||
get_unified_alert_details(alert_id=<that UUID>) instead of 2-3.
|
||||
2. threat_analysis(threat_id) (1 call): detection, file, process, agent.
|
||||
3. get_threat_notes(threat_id) (1 call): what analysts already wrote.
|
||||
4. get_activities(threats_ids=<threat id>) (1 call): mitigation and
|
||||
console timeline. agent_ids works the same for the host's timeline.
|
||||
5. Host: list_agents(computer_name=<name>) (1 call) — the name, not an id.
|
||||
Then get_agent_mac(agent_id) only if IPs/MACs are asked.
|
||||
6. Indicator: get_hash_verdict(hash) takes a SHA1 only (40 hex chars),
|
||||
never SHA256 or MD5. get_blocklist(hash=<value>) says if it is blocked.
|
||||
7. ALWAYS keep 1-2 calls for "elsewhere":
|
||||
- get_threats(query=<hash or computer name>) — other threats with the
|
||||
same hash (other hosts) or on the same host. Always give query or
|
||||
threat_ids: an unfiltered call is the ingestion feed.
|
||||
- for a UAM alert: get_unified_alerts(asset_id=<raw asset.id>,
|
||||
detected_after="7 days", limit=50). It sorts OLDEST first, so say if
|
||||
the limit was hit. Its search_text matches the alert name only.
|
||||
RULE ONE. investigate_alert does the investigation in ONE call: the alert,
|
||||
the host, the other alerts and threats on the host, and the file's
|
||||
reputation, blocklist and fleet-wide threats. Call it first, and do not
|
||||
redo with other commands what it already answered.
|
||||
|
||||
Ids: threat ids are long numbers (about 19 digits) and agent ids too. Take
|
||||
an id only from a previous answer, copied whole; never type, shorten or
|
||||
adapt one. When a command accepts a name (computer_name, query), prefer the
|
||||
name.
|
||||
Time: pass timestamps exactly as the data gives them (ISO8601). Never
|
||||
compute dates; for a window use the relative form "24 hours" / "7 days"
|
||||
where a parameter documents it.
|
||||
Deep Visibility (get_events, get_processes) is not available: it needs a
|
||||
query you cannot create. Do not ask for it.
|
||||
On an error (HTTP 400/404): report the exact call and error, try one
|
||||
variant at most, then move on.
|
||||
1. get_incident_context (1 call). In its raw fields find the alert's id:
|
||||
- a unified (UAM) alert: the "id" that is a UUID (8-4-4-4-12 hex,
|
||||
e.g. 01a0f703-e969-7e53-80ba-870646032581). asset.id (a 26-letter
|
||||
token) and agentUuid are NOT alert ids.
|
||||
- a threat: threatInfo.threatId (a number of about 19 digits).
|
||||
2. investigate_alert(alert_id=<the UUID>) or investigate_alert(
|
||||
threat_id=<the number>) (1 call). Copy the id whole, character for
|
||||
character. If it answers found: false, check the id once against the
|
||||
raw fields and retry once; then report it.
|
||||
3. Only for a question the answer leaves open (at most 4 calls):
|
||||
- more of a hash: get_threats(query=<sha1 or file name>);
|
||||
- the host's console timeline: get_activities(agent_ids=<host.agent_id
|
||||
from the answer>);
|
||||
- a UAM alert's full indicator list: get_unified_alert_details.
|
||||
get_hash_verdict takes a SHA1 only (40 hex). Never a SHA256 or MD5.
|
||||
|
||||
In your answer: the threat (name, classification, file path, SHA1/SHA256,
|
||||
originating process and command line, user, mitigation and verdict), the
|
||||
host (name, OS, IPs, last user, agent version, network status), the
|
||||
timeline, and what the "elsewhere" checks found — with each fact's command.
|
||||
Report, each fact with "investigate_alert" or the command that gave it:
|
||||
- the alert: name, rule, severity, classification, status, verdict,
|
||||
mitigation result, detection time, MITRE;
|
||||
- the process: command line, parent, user, file, path, hashes, signer;
|
||||
- the host: name, OS, IPs, last user, agent version, network status, site;
|
||||
- elsewhere: its "window" word for word, the other alerts on the host by
|
||||
name with their counts, the threats on the host, the file's reputation,
|
||||
whether it is blocklisted, the threats carrying it across the fleet;
|
||||
- each entry of "errors" as a check that could not be made, with its step.
|
||||
Report what the answer says and nothing it does not: an empty or missing
|
||||
section is "not found", never a guess. No verdict.
|
||||
|
||||
commands:
|
||||
- investigate_alert
|
||||
- threat_analysis
|
||||
- get_threat_notes
|
||||
- get_activities
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,409 @@
|
||||
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 {}
|
||||
nodes = [e["node"] for e in alerts.get("edges") or [] if e.get("node") and e["node"].get("id") != alert_id]
|
||||
if sib is not None:
|
||||
elsewhere["same_host_other_alerts"] = compact({
|
||||
"total": max((alerts.get("totalCount") or 0) - 1, len(nodes)),
|
||||
"read": len(nodes),
|
||||
"by_name": group_count(nodes, lambda n: n.get("name"), lambda n: n.get("detectedAt") or ""),
|
||||
})
|
||||
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)
|
||||
Reference in New Issue
Block a user