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
This commit is contained in:
2026-09-28 14:43:53 +00:00
parent 232fa2f0a6
commit 71f8cd970e
4 changed files with 67 additions and 5 deletions
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
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,
@@ -24,6 +24,18 @@ MUTATION = """mutation AlertTriggerActions($id: String!, $actionId: ID!, $user:
}
}"""
# 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
@@ -60,11 +72,43 @@ def main():
# id exactly; it fits the Long the console declares.
"user": int(user_id),
}
resp = request("POST", base + "/unifiedalerts/graphql", headers, {"query": MUTATION, "variables": variables})
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)
print(json.dumps({"alert_id": alert_id, "assignee_user_id": user_id, "result": resp.get("data") or {}}))
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:
@@ -35,6 +35,15 @@ def main():
if resp.get("errors"):
print(json.dumps({"error": "GraphQL error", "detail": resp["errors"]}))
sys.exit(1)
# A refused or skipped alert comes back in the payload, not in errors: the
# console answers 200, and printed as-is it would read as a done update.
triggered = ((resp.get("data") or {}).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 update the alert",
"failure": failures, "skip": skipped, "result": resp.get("data")}))
sys.exit(1)
print(json.dumps(resp))
@@ -35,6 +35,15 @@ def main():
if resp.get("errors"):
print(json.dumps({"error": "GraphQL error", "detail": resp["errors"]}))
sys.exit(1)
# A refused or skipped alert comes back in the payload, not in errors: the
# console answers 200, and printed as-is it would read as a done update.
triggered = ((resp.get("data") or {}).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 update the alert",
"failure": failures, "skip": skipped, "result": resp.get("data")}))
sys.exit(1)
print(json.dumps(resp))