feat(sentinelone): fetch the unified (UAM) alerts (v1.8.0)
The package could already write to a unified alert — update its status and its analyst verdict — but never read one. The only fetch was get_alerts, which reads /cloud-detection/alerts: a different object, covering only the detections the STAR and custom rules raise. Everything SentinelOne surfaces through the console-wide alert model — cloud, identity, third-party — had no way into an incident. get_unified_alerts reads the unifiedalerts GraphQL endpoint, alongside the two mutations already pointed at it. Notes on the shape it returns: - edges/node is flattened to a plain data[]. The envelope carries no id of its own, so a rule dedup'ing on a top-level id would key every alert on nothing; flattened, the ingest block matches the REST commands in this package (results_path: data, dedup_key: id). - Sorted ascending, so a run truncated by the limit leaves the watermark on the oldest unseen alert instead of skipping everything behind the newest. - created_after is normalised from ISO8601, epoch seconds or epoch milliseconds: the scheduler injects the watermark raw, in whichever of the three formats the rule was configured with. The script builds the GraphQL filter itself, so the incremental input stays a plain timestamp. - A GraphQL error arrives as HTTP 200 with an errors array. Left unread, an unusable query would look like an endless run of empty, successful fetches. The mapper covers 74 fields. Its severity branch ends on the raw value rather than a number, so an alert with no severity — or a level SentinelOne adds later — falls through to the rule's default instead of being asserted Informational on data that never said so. A UAM alert also carries fields with no honest OCSF home (assignee, ticketId, the kubernetes controllers); they are fetched and left in the raw payload rather than forced into an unrelated field. The package default ingestion still points at get_threats: the new command is opt-in from the Collect editor, so no existing instance changes what it collects. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
name: "SentinelOne Unified Alert"
|
||||
color: "#f28c28"
|
||||
icon: "shield-alert"
|
||||
@@ -1,8 +1,8 @@
|
||||
id: sentinelone
|
||||
name: SentinelOne
|
||||
version: 1.7.1
|
||||
version: 1.8.0
|
||||
description: "SentinelOne Singularity (API v2.1) — endpoint detection & response: triage threats, enrich, isolate/reconnect hosts, mitigate, scan."
|
||||
changelog: "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.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.
|
||||
@@ -915,6 +915,32 @@ commands:
|
||||
outputs_schema: { properties: {} }
|
||||
|
||||
# ── Unified (UAM) alerts ──────────────────────────────────────────────────
|
||||
# Console-wide alerts, served by the unifiedalerts GraphQL endpoint rather than
|
||||
# the REST v2.1 tree. A different object from the cloud-detection alert of
|
||||
# get_alerts: one alert per detection across every surface SentinelOne watches.
|
||||
- id: get_unified_alerts
|
||||
name: sentinelone-get-unified-alerts
|
||||
description: "Retrieve unified (UAM) alerts across endpoint, cloud, identity and third-party surfaces. Walks the cursor until the limit is reached."
|
||||
risk: read
|
||||
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)." }
|
||||
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." }
|
||||
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." }
|
||||
required: []
|
||||
outputs_schema: { properties: {} }
|
||||
ingest:
|
||||
results_path: data
|
||||
dedup_key: id
|
||||
incremental_field: created_after
|
||||
|
||||
- id: update_uam_alert_status
|
||||
name: sentinelone-update-uam-alert-status
|
||||
description: "Update the status of a unified (UAM) alert."
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
name: "SentinelOne Unified Alerts → OCSF"
|
||||
description: "Map of a SentinelOne unified (UAM) alert node (data[]) to OCSF. The console-wide alert model, distinct from the cloud-detection alert of get_alerts: one alert per detection across endpoint, cloud, identity and third-party surfaces. The detected process is the actor (actor.*); the file it ran is the target (file.*)."
|
||||
field_mappings:
|
||||
title: "name"
|
||||
description: "description"
|
||||
source: "detectionSource.product"
|
||||
# The last branch yields severity itself, not a number: an alert with no
|
||||
# severity (or one SentinelOne adds later) then falls through to the rule's
|
||||
# default rather than being asserted Informational on data that never said so.
|
||||
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,
|
||||
# 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.
|
||||
ocsf:
|
||||
# ── Alert identity ────────────────────────────────────────────────
|
||||
- { source_path: "id", ocsf_field: "finding_info.uid" }
|
||||
- { source_path: "name", ocsf_field: "finding_info.title" }
|
||||
- { source_path: "description", ocsf_field: "finding_info.desc" }
|
||||
- { source_path: "externalId", ocsf_field: "finding_info.uid_alt" }
|
||||
- { source_path: "classification", ocsf_field: "finding_info.types" }
|
||||
- { source_path: "createdAt", ocsf_field: "finding_info.created_time" }
|
||||
- { source_path: "updatedAt", ocsf_field: "finding_info.modified_time" }
|
||||
- { source_path: "firstSeenAt", ocsf_field: "finding_info.first_seen_time" }
|
||||
- { source_path: "lastSeenAt", ocsf_field: "finding_info.last_seen_time" }
|
||||
- { source_path: "detectedAt", ocsf_field: "metadata.original_time" }
|
||||
- { source_path: "storylineId", ocsf_field: "metadata.correlation_uid" }
|
||||
- { source_path: "attackSurfaces", ocsf_field: "metadata.labels" }
|
||||
- { source_path: "dataSources", ocsf_field: "metadata.log_source" }
|
||||
# ── Triage state ──────────────────────────────────────────────────
|
||||
- { source_path: "severity", ocsf_field: "severity" }
|
||||
- { source_path: "status", ocsf_field: "status" }
|
||||
- { source_path: "analystVerdict", ocsf_field: "status_detail" }
|
||||
- { source_path: "result", ocsf_field: "action" }
|
||||
- { source_path: "confidenceLevel", ocsf_field: "confidence" }
|
||||
# ── Detecting analytic / rule ─────────────────────────────────────
|
||||
- { source_path: "analytics.uid", ocsf_field: "finding_info.analytic.uid" }
|
||||
- { source_path: "analytics.name", ocsf_field: "finding_info.analytic.name" }
|
||||
- { source_path: "analytics.type", ocsf_field: "finding_info.analytic.type" }
|
||||
- { source_path: "analytics.category", ocsf_field: "finding_info.analytic.category" }
|
||||
- { source_path: "analytics.name", ocsf_field: "rule.name" }
|
||||
- { source_path: "analytics.uid", ocsf_field: "rule.uid" }
|
||||
- { source_path: "analytics.typeValue", ocsf_field: "rule.type" }
|
||||
# ── Detecting product ─────────────────────────────────────────────
|
||||
- { source_path: "detectionSource.product", ocsf_field: "metadata.product.name" }
|
||||
- { source_path: "detectionSource.vendor", ocsf_field: "metadata.product.vendor_name" }
|
||||
- { source_path: "detectionSource.engine", ocsf_field: "metadata.product.feature.name" }
|
||||
- { source_path: "asset.agentVersion", ocsf_field: "metadata.product.version" }
|
||||
# ── Asset (current state) ─────────────────────────────────────────
|
||||
- { source_path: "asset.name", ocsf_field: "src_endpoint.hostname" }
|
||||
- { 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" }
|
||||
- { source_path: "asset.assetTypeClassifier", ocsf_field: "src_endpoint.type" }
|
||||
- { source_path: "asset.osType", ocsf_field: "src_endpoint.os.type" }
|
||||
- { source_path: "asset.osVersion", ocsf_field: "src_endpoint.os.version" }
|
||||
- { source_path: "asset.lastLoggedInUser", ocsf_field: "device.owner.name" }
|
||||
# ── Asset (state at detection) ────────────────────────────────────
|
||||
- { source_path: "detectionTime.asset.ipV4", ocsf_field: "src_endpoint.ip" }
|
||||
- { source_path: "detectionTime.asset.ipV6", ocsf_field: "device.ip" }
|
||||
- { source_path: "detectionTime.asset.domain", ocsf_field: "src_endpoint.domain" }
|
||||
- { source_path: "detectionTime.asset.osName", ocsf_field: "src_endpoint.os.name" }
|
||||
- { source_path: "detectionTime.asset.osRevision", ocsf_field: "device.os.build" }
|
||||
- { source_path: "detectionTime.asset.subscriptionTime", ocsf_field: "device.first_seen_time" }
|
||||
# ── Attacker (the other party) ────────────────────────────────────
|
||||
- { source_path: "detectionTime.attacker.host", ocsf_field: "dst_endpoint.hostname" }
|
||||
- { source_path: "detectionTime.attacker.ip", ocsf_field: "dst_endpoint.ip" }
|
||||
# ── Targeted user ─────────────────────────────────────────────────
|
||||
- { source_path: "detectionTime.targetUser.name", ocsf_field: "user.name" }
|
||||
- { source_path: "detectionTime.targetUser.emailAddress", ocsf_field: "user.email_addr" }
|
||||
- { source_path: "detectionTime.targetUser.domain", ocsf_field: "user.domain" }
|
||||
# ── Cloud workload ────────────────────────────────────────────────
|
||||
- { source_path: "detectionTime.cloud.accountId", ocsf_field: "cloud.account.uid" }
|
||||
- { source_path: "detectionTime.cloud.cloudProvider", ocsf_field: "cloud.provider" }
|
||||
- { source_path: "detectionTime.cloud.location", ocsf_field: "cloud.region" }
|
||||
- { source_path: "detectionTime.cloud.instanceId", ocsf_field: "device.instance_uid" }
|
||||
- { source_path: "detectionTime.cloud.network", ocsf_field: "device.vpc_uid" }
|
||||
# ── SentinelOne tenancy (as scoped at detection) ──────────────────
|
||||
- { source_path: "detectionTime.scope.accountName", ocsf_field: "cloud.org.name" }
|
||||
- { source_path: "detectionTime.scope.accountId", ocsf_field: "cloud.org.uid" }
|
||||
- { source_path: "detectionTime.scope.siteName", ocsf_field: "device.zone" }
|
||||
- { source_path: "realTime.scope.site.id", ocsf_field: "metadata.tenant_uid" }
|
||||
# ── Container ─────────────────────────────────────────────────────
|
||||
- { source_path: "detectionTime.kubernetes.containerName", ocsf_field: "container.name" }
|
||||
- { source_path: "detectionTime.kubernetes.containerId", ocsf_field: "container.uid" }
|
||||
- { source_path: "detectionTime.kubernetes.containerImageName", ocsf_field: "container.image.name" }
|
||||
- { source_path: "detectionTime.kubernetes.podName", ocsf_field: "container.pod_uuid" }
|
||||
# ── Detected process — the actor ──────────────────────────────────
|
||||
- { source_path: "process.cmdLine", ocsf_field: "actor.process.cmd_line" }
|
||||
- { source_path: "process.parentName", ocsf_field: "actor.process.parent_process.name" }
|
||||
- { 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" }
|
||||
# ── The file it ran — the target ──────────────────────────────────
|
||||
- { source_path: "process.file.name", ocsf_field: "file.name" }
|
||||
- { source_path: "process.file.path", ocsf_field: "file.path" }
|
||||
- { source_path: "process.file.sha256", ocsf_field: "file.hashes.sha256" }
|
||||
- { 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" }
|
||||
# ── 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,184 @@
|
||||
import json, os, sys, 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
|
||||
# from /cloud-detection/alerts (see get_alerts): one alert per detection across
|
||||
# endpoint, cloud, identity and third-party surfaces.
|
||||
VIEW_TYPES = ("ALL", "CLOUD", "CUSTOM_ALERTS", "ENDPOINT", "IDENTITY", "STAR", "THIRD_PARTY")
|
||||
|
||||
# Fields requested per alert node. Kept in one place so the mapper and this
|
||||
# query cannot drift apart.
|
||||
NODE_FIELDS = """
|
||||
id name description severity status result analystVerdict attackSurfaces
|
||||
classification confidenceLevel externalId createdAt updatedAt firstSeenAt
|
||||
lastSeenAt detectedAt noteExists dataSources storylineId ticketId
|
||||
fileName fileHash
|
||||
analytics { uid name type typeValue category }
|
||||
assignee { userId fullName email }
|
||||
detectionSource { product vendor engine }
|
||||
asset {
|
||||
id name agentUuid agentVersion assetTypeClassifier category subcategory type
|
||||
connectivityToConsole osType osVersion pendingReboot lastLoggedInUser policy
|
||||
}
|
||||
detectionTime {
|
||||
asset {
|
||||
agentVersion consoleIpAddress domain ipV4 ipV6 lastLoggedInUser
|
||||
osName osRevision osType policy subscriptionTime
|
||||
}
|
||||
attacker { host ip }
|
||||
cloud { accountId cloudProvider image instanceId instanceSize location network tags }
|
||||
kubernetes {
|
||||
clusterName namespaceName nodeName podName containerId containerImageName
|
||||
containerName containerNetworkStatus controllerName controllerType
|
||||
}
|
||||
scope { accountId accountName groupName siteName }
|
||||
targetUser { name emailAddress domain }
|
||||
}
|
||||
process { cmdLine parentName file { certSubject md5 name path sha1 sha256 } }
|
||||
realTime { scope { account { id name } group { id name } site { 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 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.
|
||||
"""
|
||||
s = str(v or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
if s.isdigit():
|
||||
n = int(s)
|
||||
return n * 1000 if n < 100000000000 else n # seconds vs milliseconds
|
||||
import datetime
|
||||
t = s.replace("Z", "+00:00")
|
||||
try:
|
||||
dt = datetime.datetime.fromisoformat(t)
|
||||
except ValueError:
|
||||
raise ValueError("cannot read timestamp %r: expected ISO8601 or an epoch" % s)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=datetime.timezone.utc)
|
||||
return int(dt.timestamp() * 1000)
|
||||
|
||||
|
||||
def gql_string(v):
|
||||
"""Quote a value for inline use in the query (json.dumps escapes to GraphQL's rules)."""
|
||||
return json.dumps(str(v))
|
||||
|
||||
|
||||
def build_filters(inputs):
|
||||
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))
|
||||
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)))
|
||||
return out
|
||||
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
view_type = str(inputs.get("view_type") or "ALL").strip().upper()
|
||||
if view_type not in VIEW_TYPES:
|
||||
raise ValueError("view_type must be one of %s" % ", ".join(VIEW_TYPES))
|
||||
|
||||
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 ""
|
||||
|
||||
# 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)
|
||||
query = """query Alerts {
|
||||
alerts(
|
||||
first: %d
|
||||
viewType: %s
|
||||
after: %s
|
||||
sort: { by: "%s", order: ASC }
|
||||
%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)
|
||||
|
||||
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"):
|
||||
print(json.dumps({"error": "GraphQL error", "detail": resp["errors"]}))
|
||||
sys.exit(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:
|
||||
node = edge.get("node")
|
||||
if node:
|
||||
nodes.append(node)
|
||||
page_info = alerts.get("pageInfo") or {}
|
||||
cursor = page_info.get("endCursor")
|
||||
# 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
|
||||
|
||||
# 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": total,
|
||||
"returned": len(nodes),
|
||||
"hasNextPage": bool(page_info.get("hasNextPage")),
|
||||
"endCursor": page_info.get("endCursor"),
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user