feat(sentinelone): absorb the SentinelOne SDL package (v1.11.0)
The SDL package covered the same unified (UAM) alerts from a second
integration: one console, two packages, two instances, two tokens. Its
commands now live here, so it can be dropped.
get_unified_alerts gains the SDL filters — classification, attack_surface,
os_type, asset_id, external_id, search_text, unassigned_only,
unmitigated_only and a detectedAt window. status, severity, analyst_verdict
and analytics_category take comma-separated lists (stringEqual on one value,
stringIn on several), and every time input also reads a relative window
('24 hours') on top of ISO8601 and epochs. The query is byte-identical to
1.9.0's for the inputs an existing alert rule sends, apart from enum values
now being upper-cased — a lowercase severity used to filter nothing.
Three capabilities the SDL package had and this one did not:
get_unified_alert_details (indicators, their MITRE mapping, observables,
related alerts), add_uam_alert_note and trigger_uam_alert_action (any console
action by ID). update_uam_alert_assignee completes the status/verdict pair,
on the same alertTriggerActions mutation.
analytics_category filters the fetch to one analytic category, e.g. only the
platform-rule alerts (analyticsCategory=Platform Rule, the console's own
filter). The SDL package showed this endpoint takes dotted fieldIds
(asset.osType, assignee.userId) and spells its sort argument differently, so
both spellings of each are tried before giving up: the category degrades to a
filter over the fetched alerts rather than failing the run, and
pagination.categoryFilter / .sortForm report which shape the console accepted.
update_uam_alert_status and _verdict now report a GraphQL error instead of
printing it as a successful update — a playbook that chained on a refused
update used to carry on as if it had gone through.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Rk9N6vNtp3q7YvLpjcc
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
id: sentinelone
|
||||
name: SentinelOne
|
||||
version: 1.9.0
|
||||
version: 1.11.0
|
||||
description: "SentinelOne Singularity (API v2.1) — endpoint detection & response: triage threats, enrich, isolate/reconnect hosts, mitigate, scan."
|
||||
changelog: "1.9.0 — Every command now declares its risk tier, so the agent approval gate can tell a lookup from an action. Only test_connection and get_unified_alerts did; the other 83 were unset, and an unset risk is treated as destructive, which put get-threats and get-alerts behind the same gate as isolate-agent and run-remote-script. 39 read, 22 safe_write, 24 destructive. Destructive covers what removes detection or protection (exclusions, disabling or editing a STAR rule), what adds blocking (blocklist, firewall rule), what changes containment in either direction (isolate and reconnect both), what reboots or shuts a host down, and every delete. 1.8.0 — get_unified_alerts fetches the unified (UAM) alerts: the console-wide alert model served by the unifiedalerts GraphQL endpoint, which get_alerts (cloud-detection) does not cover. Walks the cursor to the requested limit, sorts ascending so a truncated run leaves the watermark on the oldest unseen alert, and flattens edges/node to a plain data[] so the alert rule dedups on a top-level id. Ships with an OCSF mapper (74 fields) and a 'SentinelOne Unified Alert' incident type. 1.7.1 — count_agents' isActive filter is a text field again: declared as a boolean it rendered as a checkbox, which has no empty state, so the count could never be asked for both the active and the inactive agents. Left empty the filter is not sent. 1.7.0 — count_agents takes an accountIds filter, so a multi-tenant console can be counted one account at a time. 1.6.0 — count_agents takes an isActive filter, to count the active agents apart from the inactive ones; left unset, the count still covers both. 1.5.0 — count_agents takes four more filters: agentVersion__gt to count the fleet still below a target build, networkStatuses to count only the endpoints in a given connection state, operationalStatesNin to leave states out of the count, and machineTypes to count servers apart from laptops. 1.4.0 — get_threats and get_alerts accept an optional account_ids filter (accountIds), so a multi-tenant console can be scoped to one or more accounts at fetch time. 1.3.0 — Exhaustive OCSF mappers: get_threats (38 fields) and get_alerts (58 fields) now cover device, finding, malware, actor/target process, file, registry, network, indicators and container. 1.2.4 — Re-modelled the OCSF mappers to OCSF actor/target semantics: the initiating process maps to actor.* (actor.process, actor.user), and the process/file acted upon maps to the target (process.*, file.*, user.*). 1.2.3 — Expanded the OCSF mappers (get_threats, get_alerts) with more fields: file hashes (sha1/md5), file ext/size, finding/rule ids, confidence, status, parent process and device/user identity. 1.2.2 — Added pre-built OCSF mappers (get_threats, get_alerts) for the mapper library. 1.2.1 — Added test_connection for the instance Test button. 1.2.0 — Added 13 commands: threat-analysis, threat-download-from-cloud, abort-endpoint-scan, endpoint-fetch-logs, fetch-file, get-remote-script-task-status/results, get-service-users, list-installed-singularity-marketplace-applications, update-uam-alert-status/verdict, run-powerquery and create-tag-rule (83 commands total). 1.1.0 — Command names prefixed with 'sentinelone-' (e.g. sentinelone-isolate-agent) for easier toolbox search; command IDs unchanged. 1.0.0 — Initial release: 70 commands covering agents, threats, alerts, blocklist/exclusions, IOCs, STAR rules, Deep Visibility, remote scripts, tags, firewall and network discovery based on the SentinelOne API v2.1."
|
||||
changelog: "1.11.0 — Absorbs the SentinelOne SDL package, which covered the same unified (UAM) alerts from a second integration: one console, one instance, one set of credentials. get_unified_alerts gains the SDL filters — classification, attack_surface, os_type, asset_id, external_id, search_text, unassigned_only, unmitigated_only and a detectedAt window — status, severity, analyst_verdict and analytics_category take comma-separated lists, and every time input also reads a relative window ('24 hours'). Three capabilities the SDL package had and this one did not: get_unified_alert_details (indicators, their MITRE mapping, observables, related alerts), add_uam_alert_note and trigger_uam_alert_action (any console action by ID). update_uam_alert_assignee completes the status/verdict pair. update_uam_alert_status and _verdict now report a GraphQL error instead of printing it as a successful update. analytics_category filters the fetch to one analytic category, e.g. only the platform-rule alerts (analyticsCategory=Platform Rule, the console's own filter): sent as a server-side filter under either fieldId spelling, and applied to the fetched alerts if the console rejects both, rather than failing the fetch — pagination.categoryFilter reports which ran. The sort argument is likewise tried in both spellings. 1.9.0 — Every command now declares its risk tier, so the agent approval gate can tell a lookup from an action. Only test_connection and get_unified_alerts did; the other 83 were unset, and an unset risk is treated as destructive, which put get-threats and get-alerts behind the same gate as isolate-agent and run-remote-script. 39 read, 22 safe_write, 24 destructive. Destructive covers what removes detection or protection (exclusions, disabling or editing a STAR rule), what adds blocking (blocklist, firewall rule), what changes containment in either direction (isolate and reconnect both), what reboots or shuts a host down, and every delete. 1.8.0 — get_unified_alerts fetches the unified (UAM) alerts: the console-wide alert model served by the unifiedalerts GraphQL endpoint, which get_alerts (cloud-detection) does not cover. Walks the cursor to the requested limit, sorts ascending so a truncated run leaves the watermark on the oldest unseen alert, and flattens edges/node to a plain data[] so the alert rule dedups on a top-level id. Ships with an OCSF mapper (74 fields) and a 'SentinelOne Unified Alert' incident type. 1.7.1 — count_agents' isActive filter is a text field again: declared as a boolean it rendered as a checkbox, which has no empty state, so the count could never be asked for both the active and the inactive agents. Left empty the filter is not sent. 1.7.0 — count_agents takes an accountIds filter, so a multi-tenant console can be counted one account at a time. 1.6.0 — count_agents takes an isActive filter, to count the active agents apart from the inactive ones; left unset, the count still covers both. 1.5.0 — count_agents takes four more filters: agentVersion__gt to count the fleet still below a target build, networkStatuses to count only the endpoints in a given connection state, operationalStatesNin to leave states out of the count, and machineTypes to count servers apart from laptops. 1.4.0 — get_threats and get_alerts accept an optional account_ids filter (accountIds), so a multi-tenant console can be scoped to one or more accounts at fetch time. 1.3.0 — Exhaustive OCSF mappers: get_threats (38 fields) and get_alerts (58 fields) now cover device, finding, malware, actor/target process, file, registry, network, indicators and container. 1.2.4 — Re-modelled the OCSF mappers to OCSF actor/target semantics: the initiating process maps to actor.* (actor.process, actor.user), and the process/file acted upon maps to the target (process.*, file.*, user.*). 1.2.3 — Expanded the OCSF mappers (get_threats, get_alerts) with more fields: file hashes (sha1/md5), file ext/size, finding/rule ids, confidence, status, parent process and device/user identity. 1.2.2 — Added pre-built OCSF mappers (get_threats, get_alerts) for the mapper library. 1.2.1 — Added test_connection for the instance Test button. 1.2.0 — Added 13 commands: threat-analysis, threat-download-from-cloud, abort-endpoint-scan, endpoint-fetch-logs, fetch-file, get-remote-script-task-status/results, get-service-users, list-installed-singularity-marketplace-applications, update-uam-alert-status/verdict, run-powerquery and create-tag-rule (83 commands total). 1.1.0 — Command names prefixed with 'sentinelone-' (e.g. sentinelone-isolate-agent) for easier toolbox search; command IDs unchanged. 1.0.0 — Initial release: 70 commands covering agents, threats, alerts, blocklist/exclusions, IOCs, STAR rules, Deep Visibility, remote scripts, tags, firewall and network discovery based on the SentinelOne API v2.1."
|
||||
category: endpoint
|
||||
|
||||
# Per-instance configuration. The scripts build the API base as <url>/web/api/v2.1.
|
||||
@@ -1004,12 +1004,23 @@ commands:
|
||||
inputs_schema:
|
||||
properties:
|
||||
created_after: { type: string, description: "Return alerts created at or after this time (ISO8601 or epoch). The incremental field: leave empty and let the alert rule set it." }
|
||||
created_until: { type: string, description: "Return alerts created at or before this time (ISO8601 or epoch)." }
|
||||
created_until: { type: string, description: "Return alerts created at or before this time (ISO8601, epoch, or a window like '24 hours')." }
|
||||
updated_after: { type: string, description: "Return alerts updated at or after this time. Set, it sorts on updatedAt instead of createdAt — use it to pick up alerts retriaged in the console, not to create incidents." }
|
||||
view_type: { type: string, description: "Surface to read: ALL (default) | CLOUD | CUSTOM_ALERTS | ENDPOINT | IDENTITY | STAR | THIRD_PARTY" }
|
||||
status: { type: string, description: "Filter on alert status, e.g. NEW | IN_PROGRESS | RESOLVED." }
|
||||
severity: { type: string, description: "Filter on severity: CRITICAL | HIGH | MEDIUM | LOW | INFO." }
|
||||
analyst_verdict: { type: string, description: "Filter on analyst verdict, e.g. TRUE_POSITIVE_MALWARE, FALSE_POSITIVE_BENIGN, UNDEFINED." }
|
||||
analytics_category: { type: string, description: "Filter on the detecting analytic's category, as the console spells it, e.g. 'Platform Rule' (analyticsCategory=Platform Rule) or 'STAR Rule'. Comma-separated for several. Left empty, every category is returned." }
|
||||
classification: { type: string, description: "Comma-separated classifications, e.g. MALWARE,RANSOMWARE,TROJAN." }
|
||||
attack_surface: { type: string, description: "Comma-separated attack surfaces: ENDPOINT,CLOUD,IDENTITY,NETWORK,EMAIL." }
|
||||
os_type: { type: string, description: "Comma-separated OS types of the affected asset: WINDOWS,LINUX,MACOS." }
|
||||
asset_id: { type: string, description: "Only alerts on this asset (comma-separated for several)." }
|
||||
external_id: { type: string, description: "Filter on the source's own alert ID (comma-separated for several)." }
|
||||
search_text: { type: string, description: "Free-text match on the alert name." }
|
||||
unassigned_only: { type: string, description: "true to return only the alerts with no assignee." }
|
||||
unmitigated_only: { type: string, description: "true to return only the alerts whose result is UNMITIGATED." }
|
||||
detected_after: { type: string, description: "Lower bound on detectedAt (ISO8601, epoch, or a window like '24 hours'). Detection time, as opposed to the console's ingestion time used by created_after." }
|
||||
detected_until: { type: string, description: "Upper bound on detectedAt." }
|
||||
status: { type: string, description: "Filter on alert status, e.g. NEW | IN_PROGRESS | RESOLVED. Comma-separated for several." }
|
||||
severity: { type: string, description: "Filter on severity: CRITICAL | HIGH | MEDIUM | LOW | INFO. Comma-separated for several." }
|
||||
analyst_verdict: { type: string, description: "Filter on analyst verdict, e.g. TRUE_POSITIVE_MALWARE, FALSE_POSITIVE_BENIGN, UNDEFINED. Comma-separated for several." }
|
||||
alert_id: { type: string, description: "Fetch one alert by its UUID." }
|
||||
limit: { type: number, description: "Max alerts to return across pages (default 100)." }
|
||||
cursor: { type: string, description: "Resume from a previous run's pagination.endCursor." }
|
||||
@@ -1020,6 +1031,16 @@ commands:
|
||||
dedup_key: id
|
||||
incremental_field: created_after
|
||||
|
||||
- id: get_unified_alert_details
|
||||
name: sentinelone-get-unified-alert-details
|
||||
description: "Full detail of one unified (UAM) alert: indicators and their MITRE ATT&CK mapping, observables, and the related alerts."
|
||||
risk: read
|
||||
inputs_schema:
|
||||
properties:
|
||||
alert_id: { type: string, description: "UAM alert ID (UUID)." }
|
||||
required: [alert_id]
|
||||
outputs_schema: { properties: {} }
|
||||
|
||||
- id: update_uam_alert_status
|
||||
name: sentinelone-update-uam-alert-status
|
||||
description: "Update the status of a unified (UAM) alert."
|
||||
@@ -1042,6 +1063,42 @@ commands:
|
||||
required: [alert_id, analyst_verdict]
|
||||
outputs_schema: { properties: {} }
|
||||
|
||||
- id: update_uam_alert_assignee
|
||||
name: sentinelone-update-uam-alert-assignee
|
||||
description: "Assign a unified (UAM) alert to a console user."
|
||||
risk: safe_write
|
||||
inputs_schema:
|
||||
properties:
|
||||
alert_id: { type: string, description: "UAM alert ID (UUID)." }
|
||||
assignee_user_id: { type: string, description: "Console user ID to assign the alert to (the userId returned in assignee.userId)." }
|
||||
action_id: { type: string, description: "Console action ID, only if this tenant names it something other than S1/alert/assignUser." }
|
||||
required: [alert_id, assignee_user_id]
|
||||
outputs_schema: { properties: {} }
|
||||
|
||||
- id: add_uam_alert_note
|
||||
name: sentinelone-add-uam-alert-note
|
||||
description: "Add an analyst note to a unified (UAM) alert. For the REST threat object, use sentinelone-write-threat-note instead."
|
||||
risk: safe_write
|
||||
inputs_schema:
|
||||
properties:
|
||||
alert_id: { type: string, description: "UAM alert ID (UUID)." }
|
||||
note_text: { type: string, description: "Note content." }
|
||||
required: [alert_id, note_text]
|
||||
outputs_schema: { properties: {} }
|
||||
|
||||
- id: trigger_uam_alert_action
|
||||
name: sentinelone-trigger-uam-alert-action
|
||||
description: "Trigger any console action on a unified (UAM) alert — quarantine, kill, remediate, blocklist — by its action ID."
|
||||
risk: destructive
|
||||
inputs_schema:
|
||||
properties:
|
||||
alert_id: { type: string, description: "UAM alert ID (UUID)." }
|
||||
action_id: { type: string, description: "Console action ID, e.g. S1/alert/quarantine. The action IDs an alert offers are listed in the console." }
|
||||
action_type: { type: string, description: "Action type when the action takes one: QUARANTINE, UNQUARANTINE, KILL, REMEDIATE, BLOCKLIST_ADD, EXCLUSION_ADD." }
|
||||
payload_json: { type: string, description: "Extra payload as a JSON object, for the actions that take more than a type." }
|
||||
required: [alert_id, action_id]
|
||||
outputs_schema: { properties: {} }
|
||||
|
||||
# ── PowerQuery (Singularity Data Lake) ────────────────────────────────────
|
||||
- id: run_powerquery
|
||||
name: sentinelone-run-powerquery
|
||||
|
||||
@@ -10,7 +10,7 @@ field_mappings:
|
||||
severity: "severity = 'CRITICAL' ? 5 : (severity = 'HIGH' ? 4 : (severity = 'MEDIUM' ? 3 : (severity = 'LOW' ? 2 : (severity = 'INFO' ? 1 : severity))))"
|
||||
# source_path is JSONata over ONE alert node (alert rule results_path = data).
|
||||
# Paths absent from a given alert are skipped at ingestion, so extra entries are safe.
|
||||
# A UAM alert carries a few fields with no honest OCSF home — assignee, ticketId,
|
||||
# A UAM alert carries a few fields with no honest OCSF home — ticketId,
|
||||
# noteExists, asset.policy, asset.connectivityToConsole, kubernetes controllers.
|
||||
# They are fetched and stay in the raw payload for playbooks rather than being
|
||||
# forced into an unrelated field.
|
||||
@@ -33,6 +33,8 @@ ocsf:
|
||||
- { source_path: "severity", ocsf_field: "severity" }
|
||||
- { source_path: "status", ocsf_field: "status" }
|
||||
- { source_path: "analystVerdict", ocsf_field: "status_detail" }
|
||||
- { source_path: "analystVerdict", ocsf_field: "disposition" }
|
||||
- { source_path: "classification", ocsf_field: "activity_name" }
|
||||
- { source_path: "result", ocsf_field: "action" }
|
||||
- { source_path: "confidenceLevel", ocsf_field: "confidence" }
|
||||
# ── Detecting analytic / rule ─────────────────────────────────────
|
||||
@@ -50,6 +52,9 @@ ocsf:
|
||||
- { source_path: "asset.agentVersion", ocsf_field: "metadata.product.version" }
|
||||
# ── Asset (current state) ─────────────────────────────────────────
|
||||
- { source_path: "asset.name", ocsf_field: "src_endpoint.hostname" }
|
||||
- { source_path: "asset.name", ocsf_field: "device.hostname" }
|
||||
- { source_path: "asset.osType", ocsf_field: "device.os.type" }
|
||||
- { source_path: "asset.agentVersion", ocsf_field: "device.agent.version" }
|
||||
- { source_path: "asset.agentUuid", ocsf_field: "device.uid" }
|
||||
- { source_path: "asset.id", ocsf_field: "device.uid_alt" }
|
||||
- { source_path: "asset.type", ocsf_field: "device.type" }
|
||||
@@ -93,6 +98,8 @@ ocsf:
|
||||
- { source_path: "process.file.name", ocsf_field: "actor.process.file.name" }
|
||||
- { source_path: "process.file.path", ocsf_field: "actor.process.file.path" }
|
||||
- { source_path: "process.file.sha256", ocsf_field: "actor.process.file.hashes.sha256" }
|
||||
- { source_path: "process.file.sha1", ocsf_field: "actor.process.file.hashes.sha1" }
|
||||
- { source_path: "process.file.md5", ocsf_field: "actor.process.file.hashes.md5" }
|
||||
# ── The file it ran — the target ──────────────────────────────────
|
||||
- { source_path: "process.file.name", ocsf_field: "file.name" }
|
||||
- { source_path: "process.file.path", ocsf_field: "file.path" }
|
||||
@@ -100,6 +107,9 @@ ocsf:
|
||||
- { source_path: "process.file.sha1", ocsf_field: "file.hashes.sha1" }
|
||||
- { source_path: "process.file.md5", ocsf_field: "file.hashes.md5" }
|
||||
- { source_path: "process.file.certSubject", ocsf_field: "file.signature.value" }
|
||||
# ── Triage ownership ──────────────────────────────────────────────
|
||||
- { source_path: "assignee.fullName", ocsf_field: "assignee.name" }
|
||||
- { source_path: "assignee.email", ocsf_field: "assignee.email_addr" }
|
||||
# ── The alert's own file summary, hash type unstated ──────────────
|
||||
- { source_path: "fileName", ocsf_field: "observables.name" }
|
||||
- { source_path: "fileHash", ocsf_field: "observables.value" }
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
||||
|
||||
# Notes on a unified alert are their own mutation — write_threat_note covers the
|
||||
# REST threat object, which is a different record from a UAM alert.
|
||||
MUTATION = """mutation AddNote($alertId: ID!, $text: String!) {
|
||||
addAlertNote(alertId: $alertId, text: $text) {
|
||||
data { id text createdAt author { userId fullName } }
|
||||
}
|
||||
}"""
|
||||
|
||||
|
||||
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()
|
||||
text = str(inputs.get("note_text") or "")
|
||||
if not alert_id or not text.strip():
|
||||
raise ValueError("alert_id and note_text are required")
|
||||
|
||||
body = {"query": MUTATION, "variables": {"alertId": alert_id, "text": text}}
|
||||
resp = request("POST", base + "/unifiedalerts/graphql", headers, body)
|
||||
# Without this the playbook would read a successful write on a note the
|
||||
# console refused.
|
||||
if resp.get("errors"):
|
||||
print(json.dumps({"error": "GraphQL error", "detail": resp["errors"]}))
|
||||
sys.exit(1)
|
||||
print(json.dumps((resp.get("data") or {}).get("addAlertNote") or {}))
|
||||
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,62 @@
|
||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
||||
|
||||
# The single-alert view of the UAM model. get_unified_alerts reads the list
|
||||
# shape; this adds what the list cannot carry per alert: the indicators behind
|
||||
# the detection, their MITRE mapping, the observables and the sibling alerts.
|
||||
QUERY = """query GetAlertDetails($id: ID!) {
|
||||
alert(id: $id) {
|
||||
id externalId name description severity status classification confidenceLevel
|
||||
analystVerdict result detectedAt createdAt updatedAt firstSeenAt lastSeenAt
|
||||
storylineId attackSurfaces noteExists
|
||||
asset { id name osType osVersion agentVersion lastLoggedInUser }
|
||||
assignee { userId fullName email }
|
||||
detectionSource { vendor product engine }
|
||||
process { cmdLine parentName username file { name path md5 sha1 sha256 size } }
|
||||
indicators { uid type message severity eventTime
|
||||
attacks { tactic { name uid } technique { name uid } }
|
||||
observables { name value type typeName } }
|
||||
observables { name value type typeName }
|
||||
relatedAlerts { id name }
|
||||
}
|
||||
}"""
|
||||
|
||||
|
||||
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=60) 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()
|
||||
if not alert_id:
|
||||
raise ValueError("alert_id is required")
|
||||
|
||||
body = {"query": QUERY, "variables": {"id": alert_id}}
|
||||
resp = request("POST", base + "/unifiedalerts/graphql", headers, body)
|
||||
# GraphQL answers 200 with an errors array; reported as an error rather than
|
||||
# printed as an empty alert, which would read as "this alert has no detail".
|
||||
if resp.get("errors"):
|
||||
print(json.dumps({"error": "GraphQL error", "detail": resp["errors"]}))
|
||||
sys.exit(1)
|
||||
print(json.dumps((resp.get("data") or {}).get("alert") or {}))
|
||||
|
||||
|
||||
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)
|
||||
@@ -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
|
||||
|
||||
# Unified (UAM) alerts live behind a GraphQL endpoint, not the REST v2.1 tree the
|
||||
# rest of this package uses. The console-wide alert model is a different object
|
||||
@@ -6,6 +6,22 @@ import json, os, sys, urllib.request, urllib.parse, urllib.error
|
||||
# endpoint, cloud, identity and third-party surfaces.
|
||||
VIEW_TYPES = ("ALL", "CLOUD", "CUSTOM_ALERTS", "ENDPOINT", "IDENTITY", "STAR", "THIRD_PARTY")
|
||||
|
||||
# Walking the cursor is bounded: filtering client-side (see the attempt ladder) a
|
||||
# page can be discarded almost whole, and without a cap a narrow filter on a busy
|
||||
# console would page until the run is killed.
|
||||
MAX_PAGES = 50
|
||||
|
||||
# The console narrows to platform-rule alerts with analyticsCategory=Platform
|
||||
# Rule, but the fieldIds this endpoint takes are a mix of flat names (createdAt,
|
||||
# externalId) and dotted paths (asset.osType, assignee.userId), so both spellings
|
||||
# of the category are tried before falling back to filtering what was fetched.
|
||||
CATEGORY_FIELD_IDS = ("analytics.category", "analyticsCategory")
|
||||
|
||||
# Same reason: the sort argument is spelled one way or the other depending on the
|
||||
# console's schema version, and a wrong guess fails the whole query rather than
|
||||
# degrading, so both are tried.
|
||||
SORT_FORMS = ("sort", "sorts")
|
||||
|
||||
# Fields requested per alert node. Kept in one place so the mapper and this
|
||||
# query cannot drift apart.
|
||||
NODE_FIELDS = """
|
||||
@@ -38,6 +54,9 @@ NODE_FIELDS = """
|
||||
realTime { scope { account { id name } group { id name } site { id name } } }
|
||||
"""
|
||||
|
||||
_UNITS = {"second": 1, "minute": 60, "hour": 3600, "day": 86400,
|
||||
"week": 604800, "month": 2592000, "year": 31536000}
|
||||
|
||||
|
||||
def request(method, url, headers, body=None):
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
@@ -47,16 +66,32 @@ def request(method, url, headers, body=None):
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
def rel_seconds(text):
|
||||
"""Read a relative window like '24 hours' or 'last 7 days' into seconds."""
|
||||
num = unit = None
|
||||
for t in str(text).lower().replace("last", "").split():
|
||||
if t.isdigit():
|
||||
num = int(t)
|
||||
elif t.rstrip("s") in _UNITS:
|
||||
unit = t.rstrip("s")
|
||||
return num * _UNITS[unit] if (num is not None and unit) else None
|
||||
|
||||
|
||||
def epoch_ms(v):
|
||||
"""Normalise a watermark to epoch milliseconds.
|
||||
|
||||
The alert-rule scheduler injects the watermark raw, in whichever format the
|
||||
operator picked for the rule (iso8601 | unix | unix_ms), so accept all three
|
||||
rather than forcing one and failing silently on the others.
|
||||
rather than forcing one and failing silently on the others. A relative window
|
||||
('24 hours') is accepted too, for a rule that reads a rolling window instead
|
||||
of a watermark.
|
||||
"""
|
||||
s = str(v or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
secs = rel_seconds(s)
|
||||
if secs is not None:
|
||||
return int((time.time() - secs) * 1000)
|
||||
if s.isdigit():
|
||||
n = int(s)
|
||||
return n * 1000 if n < 100000000000 else n # seconds vs milliseconds
|
||||
@@ -65,7 +100,7 @@ def epoch_ms(v):
|
||||
try:
|
||||
dt = datetime.datetime.fromisoformat(t)
|
||||
except ValueError:
|
||||
raise ValueError("cannot read timestamp %r: expected ISO8601 or an epoch" % s)
|
||||
raise ValueError("cannot read timestamp %r: expected ISO8601, an epoch or a window like '24 hours'" % s)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=datetime.timezone.utc)
|
||||
return int(dt.timestamp() * 1000)
|
||||
@@ -76,28 +111,104 @@ def gql_string(v):
|
||||
return json.dumps(str(v))
|
||||
|
||||
|
||||
def build_filters(inputs):
|
||||
def csv_list(v, upper=False):
|
||||
"""Split a comma-separated input into values, dropping the empties."""
|
||||
if v in (None, ""):
|
||||
return []
|
||||
items = v if isinstance(v, list) else str(v).split(",")
|
||||
out = [str(x).strip() for x in items if str(x).strip()]
|
||||
return [x.upper() for x in out] if upper else out
|
||||
|
||||
|
||||
def norm(v):
|
||||
"""Fold a category to letters/digits, so "Platform Rule", "platform rule" and
|
||||
PLATFORM_RULE all compare equal (used only by the client-side fallback)."""
|
||||
return "".join(ch for ch in str(v or "").lower() if ch.isalnum())
|
||||
|
||||
|
||||
def category_matches(node, categories):
|
||||
return norm(((node.get("analytics") or {}).get("category"))) in {norm(c) for c in categories}
|
||||
|
||||
|
||||
def string_filter(field, values):
|
||||
"""Equality on one value, membership on several — an unfiltered field is the
|
||||
caller's job to skip. stringEqual is kept for the single-value case because
|
||||
that is what every console here is known to accept."""
|
||||
if len(values) == 1:
|
||||
return '{ fieldId: "%s" stringEqual: { value: %s } }' % (field, gql_string(values[0]))
|
||||
return '{ fieldId: "%s" stringIn: { values: [%s] } }' % (
|
||||
field, ", ".join(gql_string(v) for v in values))
|
||||
|
||||
|
||||
def date_range_filter(field, start, end):
|
||||
parts = []
|
||||
if start is not None:
|
||||
parts.append("start: %d" % start)
|
||||
if end is not None:
|
||||
parts.append("end: %d" % end)
|
||||
return '{ fieldId: "%s" dateTimeRange: { %s } }' % (field, " ".join(parts))
|
||||
|
||||
|
||||
def build_filters(inputs, category_field):
|
||||
"""Assemble the filter list. category_field is the fieldId to filter the
|
||||
analytic category on, or None to leave it to the client-side pass."""
|
||||
out = []
|
||||
start = epoch_ms(inputs.get("created_after"))
|
||||
end = epoch_ms(inputs.get("created_until"))
|
||||
if start is not None or end is not None:
|
||||
parts = []
|
||||
if start is not None:
|
||||
parts.append("start: %d" % start)
|
||||
if end is not None:
|
||||
parts.append("end: %d" % end)
|
||||
out.append('{ fieldId: "createdAt" dateTimeRange: { %s } }' % " ".join(parts))
|
||||
|
||||
# ── Time windows ──────────────────────────────────────────────────────
|
||||
for field, after_key, until_key in (("createdAt", "created_after", "created_until"),
|
||||
("detectedAt", "detected_after", "detected_until")):
|
||||
start = epoch_ms(inputs.get(after_key))
|
||||
end = epoch_ms(inputs.get(until_key))
|
||||
if start is not None or end is not None:
|
||||
out.append(date_range_filter(field, start, end))
|
||||
updated = epoch_ms(inputs.get("updated_after"))
|
||||
if updated is not None:
|
||||
out.append('{ fieldId: "updatedAt" dateTimeRange: { start: %d } }' % updated)
|
||||
for field, key in (("status", "status"), ("severity", "severity"),
|
||||
("analystVerdict", "analyst_verdict"), ("id", "alert_id")):
|
||||
v = str(inputs.get(key) or "").strip()
|
||||
if v:
|
||||
out.append('{ fieldId: "%s" stringEqual: { value: %s } }' % (field, gql_string(v)))
|
||||
out.append(date_range_filter("updatedAt", updated, None))
|
||||
|
||||
# ── Enum-valued fields: comma-separated, upper-cased ──────────────────
|
||||
for field, key in (("status", "status"),
|
||||
("severity", "severity"),
|
||||
("analystVerdict", "analyst_verdict"),
|
||||
("classification", "classification"),
|
||||
("attackSurfaces", "attack_surface"),
|
||||
("asset.osType", "os_type")):
|
||||
values = csv_list(inputs.get(key), upper=True)
|
||||
if values:
|
||||
out.append(string_filter(field, values))
|
||||
|
||||
# ── Identifiers, taken as written ─────────────────────────────────────
|
||||
for field, key in (("id", "alert_id"),
|
||||
("asset.id", "asset_id"),
|
||||
("externalId", "external_id")):
|
||||
values = csv_list(inputs.get(key))
|
||||
if values:
|
||||
out.append(string_filter(field, values))
|
||||
|
||||
# ── The detecting analytic's category (e.g. Platform Rule) ────────────
|
||||
if category_field:
|
||||
values = csv_list(inputs.get("analytics_category"))
|
||||
if values:
|
||||
out.append(string_filter(category_field, values))
|
||||
|
||||
# ── Free text and the two console toggles ─────────────────────────────
|
||||
search = str(inputs.get("search_text") or "").strip()
|
||||
if search:
|
||||
out.append('{ fieldId: "name" match: { values: [%s] } }' % gql_string(search))
|
||||
if str(inputs.get("unassigned_only") or "").strip().lower() in ("1", "true", "yes"):
|
||||
out.append('{ fieldId: "assignee.userId" isNegated: true stringEqual: { value: null } }')
|
||||
if str(inputs.get("unmitigated_only") or "").strip().lower() in ("1", "true", "yes"):
|
||||
out.append('{ fieldId: "result" stringEqual: { value: "UNMITIGATED" } }')
|
||||
return out
|
||||
|
||||
|
||||
def sort_clause(form, by):
|
||||
"""ASC either way, so a truncated run leaves the watermark on the oldest
|
||||
unseen alert rather than skipping everything between it and the newest."""
|
||||
if form == "sorts":
|
||||
return 'sorts: [ { by: "%s", order: ASC } ]' % by
|
||||
return 'sort: { by: "%s", order: ASC }' % by
|
||||
|
||||
|
||||
def main():
|
||||
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
@@ -114,62 +225,108 @@ def main():
|
||||
|
||||
limit = int(inputs.get("limit") or 100)
|
||||
sort_by = "updatedAt" if str(inputs.get("updated_after") or "").strip() else "createdAt"
|
||||
filters = build_filters(inputs)
|
||||
filter_clause = "filters: [ %s ]" % " ".join(filters) if filters else ""
|
||||
categories = csv_list(inputs.get("analytics_category"))
|
||||
|
||||
# Query shapes to try, in order, until the console accepts one. Without a
|
||||
# category asked for there is nothing to degrade, so only the sort varies.
|
||||
category_modes = list(CATEGORY_FIELD_IDS) + [None] if categories else [None]
|
||||
attempts = [(s, c) for s in SORT_FORMS for c in category_modes]
|
||||
attempt = 0
|
||||
settled = False
|
||||
|
||||
# Ascending, so a truncated run leaves the watermark on the oldest unseen
|
||||
# alert rather than skipping everything between it and the newest.
|
||||
nodes, cursor, page_info = [], str(inputs.get("cursor") or "").strip(), {}
|
||||
total = None
|
||||
while len(nodes) < limit:
|
||||
page = min(limit - len(nodes), 100)
|
||||
pages = 0
|
||||
stopped_mid_page = None
|
||||
while len(nodes) < limit and pages < MAX_PAGES:
|
||||
sort_form, category_field = attempts[attempt]
|
||||
client_side_category = bool(categories) and category_field is None
|
||||
# Server-side, every alert on a page is one we keep, so ask for exactly
|
||||
# what is missing. Filtered client-side, most of a page is discarded, so
|
||||
# read full pages rather than walking one alert at a time.
|
||||
page = 100 if client_side_category else min(limit - len(nodes), 100)
|
||||
page_cursor = cursor
|
||||
filters = build_filters(inputs, category_field)
|
||||
filter_clause = "filters: [ %s ]" % " ".join(filters) if filters else ""
|
||||
query = """query Alerts {
|
||||
alerts(
|
||||
first: %d
|
||||
viewType: %s
|
||||
after: %s
|
||||
sort: { by: "%s", order: ASC }
|
||||
%s
|
||||
%s
|
||||
) {
|
||||
totalCount
|
||||
edges { node { %s } }
|
||||
pageInfo { hasNextPage hasPreviousPage startCursor endCursor }
|
||||
}
|
||||
}""" % (page, view_type, gql_string(cursor) if cursor else "null", sort_by, filter_clause, NODE_FIELDS)
|
||||
}""" % (page, view_type, gql_string(cursor) if cursor else "null",
|
||||
sort_clause(sort_form, sort_by), filter_clause, NODE_FIELDS)
|
||||
|
||||
resp = request("POST", base + "/unifiedalerts/graphql", headers, {"query": query})
|
||||
# GraphQL answers 200 with an errors array; without this an unusable
|
||||
# query would look like an endless run of empty, successful fetches.
|
||||
if resp.get("errors"):
|
||||
# A console that does not know a fieldId or the sort spelling answers
|
||||
# with an errors array rather than ignoring it, so the next shape is
|
||||
# tried on the same cursor. Once a page has come back the shape is
|
||||
# settled and a later error is a real one.
|
||||
if not settled and attempt + 1 < len(attempts):
|
||||
attempt += 1
|
||||
continue
|
||||
print(json.dumps({"error": "GraphQL error", "detail": resp["errors"]}))
|
||||
sys.exit(1)
|
||||
|
||||
settled = True
|
||||
pages += 1
|
||||
alerts = (resp.get("data") or {}).get("alerts") or {}
|
||||
if total is None:
|
||||
total = alerts.get("totalCount")
|
||||
edges = alerts.get("edges") or []
|
||||
for edge in edges:
|
||||
for i, edge in enumerate(edges):
|
||||
node = edge.get("node")
|
||||
if node:
|
||||
nodes.append(node)
|
||||
if not node:
|
||||
continue
|
||||
if client_side_category and not category_matches(node, categories):
|
||||
continue
|
||||
nodes.append(node)
|
||||
# Client-side, a page holds more alerts than the limit leaves room
|
||||
# for. A cursor only addresses page boundaries, so stopping here
|
||||
# reports the cursor this page was read with: the next run re-reads
|
||||
# the page and the rule dedups on id, rather than the tail of the
|
||||
# page being skipped for good.
|
||||
if len(nodes) >= limit and i + 1 < len(edges):
|
||||
stopped_mid_page = page_cursor
|
||||
break
|
||||
page_info = alerts.get("pageInfo") or {}
|
||||
cursor = page_info.get("endCursor")
|
||||
if stopped_mid_page is not None:
|
||||
break
|
||||
# An empty page ends the walk even when the console still claims a next
|
||||
# one: without this, a console answering hasNextPage on no edges would
|
||||
# keep the fetch spinning until the run is killed.
|
||||
if not edges or not page_info.get("hasNextPage") or not cursor:
|
||||
break
|
||||
|
||||
sort_form, category_field = attempts[attempt]
|
||||
# Flattened to {data: [...]} so the alert rule reads a plain array of alerts
|
||||
# (results_path: data) and dedups on a top-level id, matching the REST
|
||||
# commands in this package. The edges/node envelope carries no id of its own.
|
||||
print(json.dumps({
|
||||
"data": nodes,
|
||||
"pagination": {
|
||||
# totalCount is the console's count for the filters it applied, so
|
||||
# with a client-side category it counts more than data[] holds.
|
||||
"totalCount": total,
|
||||
"returned": len(nodes),
|
||||
"hasNextPage": bool(page_info.get("hasNextPage")),
|
||||
"endCursor": page_info.get("endCursor"),
|
||||
"hasNextPage": True if stopped_mid_page is not None else bool(page_info.get("hasNextPage")),
|
||||
"endCursor": (stopped_mid_page or None) if stopped_mid_page is not None else page_info.get("endCursor"),
|
||||
"pagesRead": pages,
|
||||
# Which query shape the console accepted — worth reading once per
|
||||
# tenant: 'client' means the category was filtered after the fetch.
|
||||
"categoryFilter": (None if not categories
|
||||
else ("server:" + category_field if category_field else "client")),
|
||||
"sortForm": sort_form,
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
||||
|
||||
# The generic form of the alertTriggerActions mutation the update_uam_alert_*
|
||||
# commands use with a fixed action: any action the console offers on an alert
|
||||
# (quarantine, kill, remediate, blocklist…) is reachable by passing its id.
|
||||
MUTATION = """mutation TriggerAction($actions: [TriggerActionInput!]!, $filter: OrFilterSelectionInput) {
|
||||
alertTriggerActions(actions: $actions, filter: $filter) {
|
||||
__typename
|
||||
... on ActionsTriggered {
|
||||
actions { actionId alertCount
|
||||
success { id }
|
||||
failure { id errorType errorMessage }
|
||||
skip { id } }
|
||||
}
|
||||
}
|
||||
}"""
|
||||
|
||||
|
||||
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=60) 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()
|
||||
action_id = str(inputs.get("action_id") or "").strip()
|
||||
if not alert_id or not action_id:
|
||||
raise ValueError("alert_id and action_id are required")
|
||||
|
||||
payload = {}
|
||||
action_type = str(inputs.get("action_type") or "").strip()
|
||||
if action_type:
|
||||
payload["type"] = action_type
|
||||
# Free-form payload for the actions that take more than a type; passed
|
||||
# through as-is so a new console action does not need a new command.
|
||||
extra = str(inputs.get("payload_json") or "").strip()
|
||||
if extra:
|
||||
parsed = json.loads(extra)
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError("payload_json must be a JSON object")
|
||||
payload.update(parsed)
|
||||
|
||||
variables = {
|
||||
"actions": [{"id": action_id, "payload": payload}],
|
||||
"filter": {"or": [{"and": [{"fieldId": "id", "stringEqual": {"value": alert_id}}]}]},
|
||||
}
|
||||
resp = request("POST", base + "/unifiedalerts/graphql", 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, "action_id": action_id, "result": resp.get("data") or {}}))
|
||||
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,67 @@
|
||||
import json, os, sys, 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.
|
||||
DEFAULT_ACTION_ID = "S1/alert/assignUser"
|
||||
|
||||
MUTATION = """mutation AlertTriggerActions($id: String!, $actionId: String!, $user: ID!) {
|
||||
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 } }
|
||||
}
|
||||
}
|
||||
}"""
|
||||
|
||||
|
||||
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")
|
||||
|
||||
variables = {
|
||||
"id": alert_id,
|
||||
"actionId": str(inputs.get("action_id") or "").strip() or DEFAULT_ACTION_ID,
|
||||
# The console types the user id as an ID, which accepts the number it
|
||||
# returns in assignee.userId as well as its string form.
|
||||
"user": int(user_id) if user_id.isdigit() else user_id,
|
||||
}
|
||||
resp = request("POST", base + "/unifiedalerts/graphql", 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 {}}))
|
||||
|
||||
|
||||
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)
|
||||
@@ -29,7 +29,13 @@ def main():
|
||||
}
|
||||
variables = {"id": inputs.get("alert_id"), "status": inputs.get("status")}
|
||||
body = {"query": MUTATION, "variables": variables}
|
||||
print(json.dumps(request("POST", base + "/unifiedalerts/graphql", headers, body)))
|
||||
resp = request("POST", base + "/unifiedalerts/graphql", headers, body)
|
||||
# GraphQL answers 200 with an errors array: printed as-is, a refused update
|
||||
# would read as a successful one in the playbook that called it.
|
||||
if resp.get("errors"):
|
||||
print(json.dumps({"error": "GraphQL error", "detail": resp["errors"]}))
|
||||
sys.exit(1)
|
||||
print(json.dumps(resp))
|
||||
|
||||
|
||||
try:
|
||||
|
||||
@@ -29,7 +29,13 @@ def main():
|
||||
}
|
||||
variables = {"id": inputs.get("alert_id"), "verdict": inputs.get("analyst_verdict")}
|
||||
body = {"query": MUTATION, "variables": variables}
|
||||
print(json.dumps(request("POST", base + "/unifiedalerts/graphql", headers, body)))
|
||||
resp = request("POST", base + "/unifiedalerts/graphql", headers, body)
|
||||
# GraphQL answers 200 with an errors array: printed as-is, a refused update
|
||||
# would read as a successful one in the playbook that called it.
|
||||
if resp.get("errors"):
|
||||
print(json.dumps({"error": "GraphQL error", "detail": resp["errors"]}))
|
||||
sys.exit(1)
|
||||
print(json.dumps(resp))
|
||||
|
||||
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user