Files
riposte-marketplace/integrations/sentinelone/scripts/update_uam_alert_assignee.py
T
f3nris 71f8cd970e fix(sentinelone): fail a UAM update the console did not apply (1.17.2)
The assignee mutation came back with the alert under success while the
console kept its previous assignee, and the playbook block passed. The
script now reads the alert back and fails, naming the assignee the
console holds, when it is not the requested user.

The status, verdict and assignee updates also fail when the alert is
listed under failure or skip: the console answers those with a 200 and
no errors array.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013u8PFdkYuuPiC6JdZBV5x3
2026-09-28 14:43:53 +00:00

122 lines
5.2 KiB
Python

import json, os, sys, time, urllib.request, urllib.parse, urllib.error
# Assignment rides the same alertTriggerActions mutation as the status and
# verdict updates, with the assignUser payload. The action id is the console's,
# and can be overridden for a tenant that names it differently.
#
# The variable types are the console's, not a guess: the action id is an ID!
# and assignUser.value a Long. Declared String! and ID!, the console rejected
# the mutation at validation (VariableTypeMismatch) before assigning anything.
DEFAULT_ACTION_ID = "S1/alert/assignUser"
MUTATION = """mutation AlertTriggerActions($id: String!, $actionId: ID!, $user: Long) {
alertTriggerActions(
filter: { or: [ { and: [ { fieldId: "id", stringEqual: { value: $id } } ] } ] },
actions: [ { id: $actionId, payload: { assignUser: { value: $user } } } ]
) {
__typename
... on ActionsTriggered {
actions { actionId alertCount
success { id }
failure { id errorType errorMessage }
skip { id } }
}
}
}"""
# What the alert says afterwards. The mutation answering "success" only means
# the console accepted the action: a run reported the alert under success while
# the console still showed its previous assignee. Reading the alert back is the
# only way the playbook learns the assignment did not happen.
READ_BACK = """query AlertAssignee($id: ID!) {
alert(id: $id) { assignee { userId fullName email } }
}"""
# The action is triggered, not necessarily applied, when the mutation returns;
# give the console a few seconds before calling it a no-op.
READ_BACK_DELAYS = (0, 1, 2, 4)
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=30) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
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"
headers = {
"Authorization": "ApiToken " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
alert_id = str(inputs.get("alert_id") or "").strip()
user_id = str(inputs.get("assignee_user_id") or "").strip()
if not alert_id or not user_id:
raise ValueError("alert_id and assignee_user_id are required")
# A Long is a number: an email or a display name would only be refused by
# the console with a type error that does not say which input was wrong.
if not user_id.isdigit():
raise ValueError("assignee_user_id must be the console's numeric user id "
"(assignee.userId, or the id from list users), got " + repr(user_id))
variables = {
"id": alert_id,
"actionId": str(inputs.get("action_id") or "").strip() or DEFAULT_ACTION_ID,
# Sent as a JSON number. Python's int keeps the 19 digits of a console
# id exactly; it fits the Long the console declares.
"user": int(user_id),
}
url = base + "/unifiedalerts/graphql"
resp = request("POST", url, headers, {"query": MUTATION, "variables": variables})
if resp.get("errors"):
print(json.dumps({"error": "GraphQL error", "detail": resp["errors"]}))
sys.exit(1)
result = resp.get("data") or {}
# A refused or skipped alert comes back in the payload, not in errors.
triggered = (result.get("alertTriggerActions") or {}).get("actions") or []
failures = [f for a in triggered for f in (a.get("failure") or [])]
skipped = [s for a in triggered for s in (a.get("skip") or [])]
if failures or skipped or not any(a.get("success") for a in triggered):
print(json.dumps({"error": "The console did not assign the alert",
"failure": failures, "skip": skipped, "result": result}))
sys.exit(1)
assignee = None
for delay in READ_BACK_DELAYS:
time.sleep(delay)
back = request("POST", url, headers, {"query": READ_BACK, "variables": {"id": alert_id}})
if back.get("errors"):
# The assignment went through as far as the console says; failing
# here on a read we could not make would report a false negative.
assignee = {"unverified": back["errors"]}
break
assignee = ((back.get("data") or {}).get("alert") or {}).get("assignee")
if str((assignee or {}).get("userId") or "") == user_id:
break
else:
print(json.dumps({
"error": "The console reported success but the alert is not assigned to "
+ user_id + " — check that this id is the console user's (assignee.userId)",
"assignee_now": assignee, "result": result}))
sys.exit(1)
print(json.dumps({"alert_id": alert_id, "assignee_user_id": user_id,
"assignee": assignee, "result": result}))
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)