4554354e06
The console skips an alert already in the requested state, and 1.17.2 failed every skip: re-assigning an alert to its current holder, as a second run of the same playbook does, broke the block. A skipped alert is now read back. Already assigned to that user, or already carrying that status or verdict, the block succeeds with already_assigned / already_set; otherwise it fails naming the state the console holds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013u8PFdkYuuPiC6JdZBV5x3
128 lines
5.6 KiB
Python
128 lines
5.6 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 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)
|