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 alert comes back in the payload, not in errors. A skipped one is # what the console answers for an alert already assigned to that user — a # second run of the same playbook — so it is settled by the read-back below # rather than failed outright. 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 [])] succeeded = any(a.get("success") for a in triggered) if failures or not (succeeded or skipped): 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: said = "reported success" if succeeded else "skipped the alert" print(json.dumps({ "error": "The console " + said + " 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, "already_assigned": not succeeded, "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)