diff --git a/integrations/sentinelone/manifest.yaml b/integrations/sentinelone/manifest.yaml index 312632e..0d667f4 100644 --- a/integrations/sentinelone/manifest.yaml +++ b/integrations/sentinelone/manifest.yaml @@ -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 /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 diff --git a/integrations/sentinelone/mappers/get_unified_alerts.yaml b/integrations/sentinelone/mappers/get_unified_alerts.yaml index 841db9c..a366f64 100644 --- a/integrations/sentinelone/mappers/get_unified_alerts.yaml +++ b/integrations/sentinelone/mappers/get_unified_alerts.yaml @@ -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" } diff --git a/integrations/sentinelone/scripts/add_uam_alert_note.py b/integrations/sentinelone/scripts/add_uam_alert_note.py new file mode 100644 index 0000000..2214a3f --- /dev/null +++ b/integrations/sentinelone/scripts/add_uam_alert_note.py @@ -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) diff --git a/integrations/sentinelone/scripts/get_unified_alert_details.py b/integrations/sentinelone/scripts/get_unified_alert_details.py new file mode 100644 index 0000000..7dae5f8 --- /dev/null +++ b/integrations/sentinelone/scripts/get_unified_alert_details.py @@ -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) diff --git a/integrations/sentinelone/scripts/get_unified_alerts.py b/integrations/sentinelone/scripts/get_unified_alerts.py index e6b6250..1316735 100644 --- a/integrations/sentinelone/scripts/get_unified_alerts.py +++ b/integrations/sentinelone/scripts/get_unified_alerts.py @@ -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, }, })) diff --git a/integrations/sentinelone/scripts/trigger_uam_alert_action.py b/integrations/sentinelone/scripts/trigger_uam_alert_action.py new file mode 100644 index 0000000..9701953 --- /dev/null +++ b/integrations/sentinelone/scripts/trigger_uam_alert_action.py @@ -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) diff --git a/integrations/sentinelone/scripts/update_uam_alert_assignee.py b/integrations/sentinelone/scripts/update_uam_alert_assignee.py new file mode 100644 index 0000000..91f2bb7 --- /dev/null +++ b/integrations/sentinelone/scripts/update_uam_alert_assignee.py @@ -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) diff --git a/integrations/sentinelone/scripts/update_uam_alert_status.py b/integrations/sentinelone/scripts/update_uam_alert_status.py index c6d8aae..5f48d41 100644 --- a/integrations/sentinelone/scripts/update_uam_alert_status.py +++ b/integrations/sentinelone/scripts/update_uam_alert_status.py @@ -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: diff --git a/integrations/sentinelone/scripts/update_uam_alert_verdict.py b/integrations/sentinelone/scripts/update_uam_alert_verdict.py index 482be04..68e750f 100644 --- a/integrations/sentinelone/scripts/update_uam_alert_verdict.py +++ b/integrations/sentinelone/scripts/update_uam_alert_verdict.py @@ -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: