71f8cd970e
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
122 lines
5.2 KiB
Python
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)
|