diff --git a/integrations/sentinelone/manifest.yaml b/integrations/sentinelone/manifest.yaml index 7590f67..8543c3d 100644 --- a/integrations/sentinelone/manifest.yaml +++ b/integrations/sentinelone/manifest.yaml @@ -1,8 +1,8 @@ id: sentinelone name: SentinelOne -version: 1.16.0 +version: 1.17.0 description: "SentinelOne Singularity (API v2.1) — endpoint detection & response: triage threats, enrich, isolate/reconnect hosts, mitigate, scan." -changelog: "1.16.0 — count_agents takes the three version comparators missing beside the agentVersion__gt it already had: agentVersion__gte, agentVersion__lt and agentVersion__lte. Sent together they bound a range, so a fleet can be counted on a band of builds rather than only above one; alone, __lte or __lt answers what is still to be upgraded to a target build, which __gt could not ask. Each is left out of the query when empty. 1.15.0 — The incident's process-tree block now draws itself for a unified alert. It rendered the chain already, reading actor.process.file.name, but it places itself automatically only on a mapper declaring actor.process.name next to the parent's, which this one did not — so a generated layout left the block out and the chain went unseen. Mapping the process name to both, plus certSubject to the node's signer badge. Checked by running the block's own reader over the mapper's output on a real alert: explorer.exe to powershell.exe, with the host, the user, and paths and hashes on both nodes when the fetch ran with_details. 1.14.0 — OCSF mapper corrected against a real STAR alert. Its alert-level observables list was empty while every observable sat under the indicators, so the two are now appended instead of reading the alert level alone — 12 observables where the mapper saw none. The same alert had process.username null while its indicators named the user, and named its parent process without hashing it anywhere else, so actor.user.name, actor.process.parent_process.file.path and its three hashes are read from the indicator observables, with the alert's own field winning when filled in. attacks and relatedAlerts are guarded on $count rather than $exists: that alert carries attacks: null, which exists in JSONata and would have written an empty array. Verified end to end through the scheduler's own jsonata-go build, with and without with_details. 1.13.0 — get_unified_alerts takes with_details, which enriches every alert it returns with what only the per-alert view carries: the indicators behind the detection, their MITRE ATT&CK tactic and technique, the observables, the related alerts and the process user. The list nodes alone were too thin to triage on. Read in aliased batches of ten, so a default-limit fetch costs ten extra requests rather than a hundred, and merged under the list node so the richer list fields win on the fields both carry. Best effort: an alert the console will not detail keeps its list fields and the shortfall is reported in pagination.details, rather than failing a fetch that already succeeded. Off by default — a rule that does not set it sends the same queries as before. The OCSF mapper maps the new fields: evidences, finding_info.attacks, finding_info.related_events, the full observables list and the process user. 1.12.0 — get_unified_alerts takes an account_ids filter, so a multi-tenant console can be ingested one account at a time, as get_threats and get_alerts already allowed. Filtered on the scope the alert sits in now (realTime.scope.account.id), falling back to the scope it was detected in and then to a filter over the fetched alerts, the same ladder as the category; pagination.accountFilter reports which ran. 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." +changelog: "1.17.0 — The unified-alert OCSF mapper, corrected and completed against a real SentinelOne Identity alert. It read asset.type for the device type, and asset.type is UNKNOWN on every alert whose surface is not the endpoint: a domain controller the same alert called a Server was ingested as an unknown device. asset.category is read first now, with a numeric device.type_id beside it. device.ip took the IPv6 address ahead of the IPv4, so every Windows host carried a link-local fe80:: address nothing can pivot on — and which the indicator extractor mined as an IOC all the same; the v4 address wins and the v6 is kept beside it. The alert's flat file summary was written straight into observables, and an alert with no file — identity, cloud — ended up with one observable whose name and value were both the literal string N/A: the summary now fills file.name, and its hash the file.hashes entry its digest length names, only when the process block did not already describe the file. src_endpoint and dst_endpoint are oriented by the detection instead of by habit: SentinelOne fills detectionTime.attacker only when a party distinct from the asset is involved, so its presence now decides which way the pair points — attacker as the source and the asset as the destination when it is filled, the asset as the source and no destination when it is not. Mapping the attacker to dst_endpoint unconditionally announced a domain controller as the source of the Active Directory enumeration it had in fact suffered. device.* is the invariant across both branches, and the asset's full OS version moved onto it, its cloud classifier under unmapped, so the flip costs nothing. finding_info.types is a list, as OCSF and the layout generator both expect. The base event had no clock of its own — time, start_time and end_time are set from detectedAt, firstSeenAt and lastSeenAt, so a timeline no longer falls back to the moment Riposte polled. 47 entries added, 97 to 144, covering every one of the 120 fields the query fetches: severity_id and status_id, the console scope's group, the alert as its own metadata.uid, finding_info.product, rule.category, container.orchestrator, the assignee's id, and the detection-time OS name, domain and logged-in user. 23 of them sit under unmapped.* — the Azure image, instance size and resource tags, the console-side IP, the Kubernetes cluster, namespace, node and controller, the agent's connectivity, pending reboot and policy, the ticket and whether a note exists — surfaced for the page instead of being left in the raw payload. Checked by running the platform's own jsonata-go mapping engine over the real Identity alert, over a STAR alert fetched with_details, over an alert naming only the attacker's address, and over a bare alert: no expression errors on any of them, and the process chain, evidences, attacks and observables come out unchanged. 1.16.0 — count_agents takes the three version comparators missing beside the agentVersion__gt it already had: agentVersion__gte, agentVersion__lt and agentVersion__lte. Sent together they bound a range, so a fleet can be counted on a band of builds rather than only above one; alone, __lte or __lt answers what is still to be upgraded to a target build, which __gt could not ask. Each is left out of the query when empty. 1.15.0 — The incident's process-tree block now draws itself for a unified alert. It rendered the chain already, reading actor.process.file.name, but it places itself automatically only on a mapper declaring actor.process.name next to the parent's, which this one did not — so a generated layout left the block out and the chain went unseen. Mapping the process name to both, plus certSubject to the node's signer badge. Checked by running the block's own reader over the mapper's output on a real alert: explorer.exe to powershell.exe, with the host, the user, and paths and hashes on both nodes when the fetch ran with_details. 1.14.0 — OCSF mapper corrected against a real STAR alert. Its alert-level observables list was empty while every observable sat under the indicators, so the two are now appended instead of reading the alert level alone — 12 observables where the mapper saw none. The same alert had process.username null while its indicators named the user, and named its parent process without hashing it anywhere else, so actor.user.name, actor.process.parent_process.file.path and its three hashes are read from the indicator observables, with the alert's own field winning when filled in. attacks and relatedAlerts are guarded on $count rather than $exists: that alert carries attacks: null, which exists in JSONata and would have written an empty array. Verified end to end through the scheduler's own jsonata-go build, with and without with_details. 1.13.0 — get_unified_alerts takes with_details, which enriches every alert it returns with what only the per-alert view carries: the indicators behind the detection, their MITRE ATT&CK tactic and technique, the observables, the related alerts and the process user. The list nodes alone were too thin to triage on. Read in aliased batches of ten, so a default-limit fetch costs ten extra requests rather than a hundred, and merged under the list node so the richer list fields win on the fields both carry. Best effort: an alert the console will not detail keeps its list fields and the shortfall is reported in pagination.details, rather than failing a fetch that already succeeded. Off by default — a rule that does not set it sends the same queries as before. The OCSF mapper maps the new fields: evidences, finding_info.attacks, finding_info.related_events, the full observables list and the process user. 1.12.0 — get_unified_alerts takes an account_ids filter, so a multi-tenant console can be ingested one account at a time, as get_threats and get_alerts already allowed. Filtered on the scope the alert sits in now (realTime.scope.account.id), falling back to the scope it was detected in and then to a filter over the fetched alerts, the same ladder as the category; pagination.accountFilter reports which ran. 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. diff --git a/integrations/sentinelone/mappers/get_unified_alerts.yaml b/integrations/sentinelone/mappers/get_unified_alerts.yaml index 059a227..c69e312 100644 --- a/integrations/sentinelone/mappers/get_unified_alerts.yaml +++ b/integrations/sentinelone/mappers/get_unified_alerts.yaml @@ -1,5 +1,5 @@ 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.*)." +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.*). Every field get_unified_alerts fetches is mapped: what OCSF has no home for is kept under unmapped.* rather than dropped or forced into an unrelated field." field_mappings: title: "name" description: "description" @@ -10,33 +10,53 @@ 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 — 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. +# +# An identity or cloud alert carries no process at all, and a UAM alert routinely +# fills a field with the literal string "N/A" rather than leaving it null — so a +# value is guarded on being real before it is written, not merely on existing. ocsf: # ── Alert identity ──────────────────────────────────────────────── - { source_path: "id", ocsf_field: "finding_info.uid" } + - { source_path: "id", ocsf_field: "metadata.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: "externalId", ocsf_field: "metadata.original_event_uid" } + # finding_info.types is a list in OCSF, and the layout generator sizes a + # `types` field as one; classification is a single string, so it is wrapped. + - { 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" } + # The base event's own clock. Without it every timeline widget reading `time` + # falls back to the ingestion time, which is when Riposte polled, not when + # SentinelOne detected. + - { source_path: "detectedAt", ocsf_field: "time" } + - { source_path: "firstSeenAt", ocsf_field: "start_time" } + - { source_path: "lastSeenAt", ocsf_field: "end_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" } + # severity_id is the sortable half of the pair: "LOW" < "MEDIUM" is a string + # comparison that orders alphabetically, which puts CRITICAL first and HIGH + # third. Same ladder as field_mappings.severity, minus the passthrough — an + # unknown label leaves the id undefined rather than inventing a rank. + - { source_path: "severity = 'CRITICAL' ? 5 : (severity = 'HIGH' ? 4 : (severity = 'MEDIUM' ? 3 : (severity = 'LOW' ? 2 : (severity = 'INFO' ? 1))))", ocsf_field: "severity_id" } - { source_path: "status", ocsf_field: "status" } + - { source_path: "status = 'NEW' ? 1 : (status = 'IN_PROGRESS' ? 2 : (status = 'SUPPRESSED' ? 3 : (status = 'RESOLVED' ? 4)))", ocsf_field: "status_id" } - { 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" } + # Whether an analyst already wrote on the alert, and the ticket it was pushed + # to. Neither has an OCSF home, and both change what an operator does next. + - { source_path: "noteExists", ocsf_field: "unmapped.note_exists" } + - { source_path: "ticketId != 'N/A' ? ticketId", ocsf_field: "unmapped.ticket_uid" } # ── Detecting analytic / rule ───────────────────────────────────── - { source_path: "analytics.uid", ocsf_field: "finding_info.analytic.uid" } - { source_path: "analytics.name", ocsf_field: "finding_info.analytic.name" } @@ -45,33 +65,97 @@ ocsf: - { source_path: "analytics.name", ocsf_field: "rule.name" } - { source_path: "analytics.uid", ocsf_field: "rule.uid" } - { source_path: "analytics.typeValue", ocsf_field: "rule.type" } + - { source_path: "analytics.category", ocsf_field: "rule.category" } # ── 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" } + - { source_path: "detectionSource.product", ocsf_field: "finding_info.product.name" } + - { source_path: "detectionSource.vendor", ocsf_field: "finding_info.product.vendor_name" } # ── 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" } - - { 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" } + # asset.type is UNKNOWN on every alert whose surface is not the endpoint, + # while asset.category names the machine (Server, Workstation) — so the + # category is what device.type reads, with asset.type left as the fallback. + # Reading asset.type alone printed "UNKNOWN" as the device type of a domain + # controller the same alert described as a Server. + - { source_path: "(asset.category and asset.category != 'N/A') ? asset.category : asset.type", ocsf_field: "device.type" } + - { source_path: "$lowercase(asset.category) = 'server' ? 1 : ($lowercase(asset.category) = 'workstation' or $lowercase(asset.category) = 'desktop' ? 2 : ($lowercase(asset.category) = 'laptop' ? 3 : ($lowercase(asset.category) = 'tablet' ? 4 : ($lowercase(asset.category) = 'mobile' ? 5 : ($lowercase(asset.category) = 'iot' ? 7)))))", ocsf_field: "device.type_id" } + - { source_path: "asset.subcategory", ocsf_field: "unmapped.asset_subcategory" } + # device.* is what carries the asset on both branches of the src/dst flip + # below, so the two labels that describe it and have no device slot of their + # own are written unconditionally here. Without them, an alert with an + # attacker loses the asset's full OS string and its cloud classifier along + # with the src_endpoint they used to ride on. + - { source_path: "asset.osVersion", ocsf_field: "device.os.version" } + - { source_path: "asset.assetTypeClassifier", ocsf_field: "unmapped.asset_type_classifier" } - { source_path: "asset.lastLoggedInUser", ocsf_field: "device.owner.name" } + # Whether the agent can still be reached, whether it is waiting on a reboot, + # and the policy it runs — the three things that decide whether a containment + # action will actually land. No OCSF field carries any of them. + - { source_path: "asset.connectivityToConsole", ocsf_field: "unmapped.connectivity_to_console" } + - { source_path: "asset.pendingReboot", ocsf_field: "unmapped.pending_reboot" } + - { source_path: "asset.policy", ocsf_field: "unmapped.agent_policy" } # ── 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" } + # device.ip takes the v4 address and only falls back to v6. Reading ipV6 + # first put a link-local fe80:: address on the device of every Windows + # server — useless to pivot on, and mined as an indicator all the same. + - { source_path: "detectionTime.asset.ipV4 ? detectionTime.asset.ipV4 : detectionTime.asset.ipV6", ocsf_field: "device.ip" } + - { source_path: "detectionTime.asset.ipV6", ocsf_field: "unmapped.asset_ipv6" } + # The address the console sees the agent from — the egress or NAT address, + # which is what a firewall or proxy log will show, and never the same field + # as the host's own. + - { source_path: "detectionTime.asset.consoleIpAddress", ocsf_field: "unmapped.console_ip" } + - { source_path: "detectionTime.asset.domain", ocsf_field: "device.domain" } + - { source_path: "detectionTime.asset.osName", ocsf_field: "device.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" } + - { source_path: "detectionTime.asset.policy", ocsf_field: "unmapped.agent_policy_at_detection" } + # After asset.lastLoggedInUser: who was on the host when it fired is what the + # triage turns on; the console's current value is only a later snapshot. + - { source_path: "detectionTime.asset.lastLoggedInUser", ocsf_field: "device.owner.name" } + # ── Endpoints, oriented by the attacker ─────────────────────────── + # OCSF reads src_endpoint as the origin of the activity and dst_endpoint as + # its target. SentinelOne fills detectionTime.attacker only when a party + # distinct from the asset is involved — on an identity alert, the host that + # ran the reconnaissance against the monitored domain — so its presence is + # what says which way the pair points: + # + # attacker filled → src = the attacker, dst = the asset + # attacker empty → src = the asset, no dst (an endpoint or STAR alert + # happens on the asset, with no second party) + # + # Mapping the attacker to dst_endpoint unconditionally, as this did before, + # announced a domain controller as the SOURCE of the enumeration it had in + # fact suffered. device.* is the invariant through both branches: it is the + # SentinelOne asset either way, so the flip never costs the host's identity. + # + # The guard is `a or b` rather than $exists: the fields are present and null + # on an alert with no attacker, and `or` reads a null as false where $exists + # would not have to. + - { source_path: "(detectionTime.attacker.host or detectionTime.attacker.ip) ? detectionTime.attacker.host : asset.name", ocsf_field: "src_endpoint.hostname" } + - { source_path: "(detectionTime.attacker.host or detectionTime.attacker.ip) ? detectionTime.attacker.ip : detectionTime.asset.ipV4", ocsf_field: "src_endpoint.ip" } + # The asset's domain, OS and classifier describe the asset, so they follow it + # onto src_endpoint only on the branch where the asset IS the source. On the + # other branch they stay on device.*, which carries them in both cases — + # writing them beside an attacker's hostname would attribute the asset's + # operating system to the machine that attacked it. + - { source_path: "$not(detectionTime.attacker.host or detectionTime.attacker.ip) ? detectionTime.asset.domain", ocsf_field: "src_endpoint.domain" } + - { source_path: "$not(detectionTime.attacker.host or detectionTime.attacker.ip) ? detectionTime.asset.osName", ocsf_field: "src_endpoint.os.name" } + - { source_path: "$not(detectionTime.attacker.host or detectionTime.attacker.ip) ? asset.osType", ocsf_field: "src_endpoint.os.type" } + - { source_path: "$not(detectionTime.attacker.host or detectionTime.attacker.ip) ? asset.osVersion", ocsf_field: "src_endpoint.os.version" } + - { source_path: "$not(detectionTime.attacker.host or detectionTime.attacker.ip) ? asset.assetTypeClassifier", ocsf_field: "src_endpoint.type" } + # The asset as the target. Kept to what identifies it in the exchange — the + # rest of its detail is on device.*, and duplicating it here would print the + # same host twice, once under ENDPOINT and once under NETWORK. + - { source_path: "(detectionTime.attacker.host or detectionTime.attacker.ip) ? asset.name", ocsf_field: "dst_endpoint.hostname" } + - { source_path: "(detectionTime.attacker.host or detectionTime.attacker.ip) ? detectionTime.asset.ipV4", ocsf_field: "dst_endpoint.ip" } + - { source_path: "(detectionTime.attacker.host or detectionTime.attacker.ip) ? detectionTime.asset.domain", ocsf_field: "dst_endpoint.domain" } # ── Targeted user ───────────────────────────────────────────────── - { source_path: "detectionTime.targetUser.name", ocsf_field: "user.name" } - { source_path: "detectionTime.targetUser.emailAddress", ocsf_field: "user.email_addr" } @@ -82,16 +166,46 @@ ocsf: - { 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) ────────────────── + # The image the VM booted and the size it runs on: what the workload is and + # what it costs to rebuild it. OCSF has neither field outside a container. + - { source_path: "detectionTime.cloud.image", ocsf_field: "unmapped.cloud_image" } + - { source_path: "detectionTime.cloud.instanceSize", ocsf_field: "unmapped.cloud_instance_size" } + # Resource tags name the owner and the application — the fastest route to the + # team to call. Kept apart from metadata.labels, which carries the surfaces. + - { source_path: "detectionTime.cloud.tags", ocsf_field: "unmapped.cloud_tags" } + # ── SentinelOne tenancy ─────────────────────────────────────────── + # realTime is the scope the alert sits in now, detectionTime the one it was + # detected in. The pair is mapped realTime first so detectionTime wins where + # both are filled, and the console scope still lands when detectionTime is + # empty — which it is on a third-party alert. + - { source_path: "realTime.scope.account.name", ocsf_field: "cloud.org.name" } + - { source_path: "realTime.scope.account.id", ocsf_field: "cloud.org.uid" } + - { source_path: "realTime.scope.site.name", ocsf_field: "device.zone" } - { 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" } + # The group is the third level of the console's scope, below account and + # site, and the one an exclusion or a policy is usually scoped to. + - { source_path: "realTime.scope.group.name ? realTime.scope.group.name : detectionTime.scope.groupName", ocsf_field: "unmapped.s1_group" } + - { source_path: "realTime.scope.group.id", ocsf_field: "unmapped.s1_group_uid" } + - { source_path: "realTime.scope.site.id", ocsf_field: "unmapped.s1_site_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" } + # The block is a Kubernetes block by construction, so naming the orchestrator + # states what its presence already means rather than guessing at it. + - { source_path: "$exists(detectionTime.kubernetes) ? 'Kubernetes'", ocsf_field: "container.orchestrator" } + # Cluster, namespace and node place the pod; the controller says what will + # recreate it after a kill. OCSF's container object carries none of them. + - { source_path: "detectionTime.kubernetes.clusterName", ocsf_field: "unmapped.k8s_cluster" } + - { source_path: "detectionTime.kubernetes.namespaceName", ocsf_field: "unmapped.k8s_namespace" } + - { source_path: "detectionTime.kubernetes.nodeName", ocsf_field: "unmapped.k8s_node" } + - { source_path: "detectionTime.kubernetes.containerNetworkStatus", ocsf_field: "unmapped.k8s_container_network_status" } + - { source_path: "detectionTime.kubernetes.controllerName", ocsf_field: "unmapped.k8s_controller" } + - { source_path: "detectionTime.kubernetes.controllerType", ocsf_field: "unmapped.k8s_controller_type" } # ── Detected process — the actor ────────────────────────────────── # actor.process.name alongside the file name: the process-tree block reads # either, but places itself automatically only on a mapper that declares @@ -115,16 +229,28 @@ ocsf: # The signer badge on the process node reads company_name; certSubject is the # only signing identity a unified alert carries. - { source_path: "process.file.certSubject", ocsf_field: "actor.process.file.company_name" } + # ── The alert's own file summary ────────────────────────────────── + # fileName/fileHash are the alert's flat summary of the file, filled with the + # literal "N/A" when there is none — an identity or cloud alert carries no + # file at all. They are read only when the process block did not already name + # the file, so the richer object always wins. + - { source_path: "(fileName != 'N/A' and $exists(fileName) and $not($exists(process.file.name))) ? fileName", ocsf_field: "file.name" } + # The summary never says which algorithm hashed the file; its length does. + # Writing it under an assumed algorithm would make a sha1 searchable as a + # sha256 and match nothing. + - { source_path: "(fileHash != 'N/A' and $length(fileHash) = 32) ? fileHash", ocsf_field: "file.hashes.md5" } + - { source_path: "(fileHash != 'N/A' and $length(fileHash) = 40) ? fileHash", ocsf_field: "file.hashes.sha1" } + - { source_path: "(fileHash != 'N/A' and $length(fileHash) = 64) ? fileHash", ocsf_field: "file.hashes.sha256" } + # A digest of any other length is still a real value; it is kept whole rather + # than dropped for not matching one of the three. + - { source_path: "(fileHash != 'N/A' and $exists(fileHash) and $length(fileHash) != 32 and $length(fileHash) != 40 and $length(fileHash) != 64) ? fileHash", ocsf_field: "unmapped.file_hash" } # ── 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" } + - { source_path: "assignee.userId", ocsf_field: "assignee.uid" } # ── Only present when the fetch ran with_details ────────────────── # Absent paths are skipped at ingestion, so these stay inert on a plain - # list fetch. observables comes last on purpose: with the detail read, the - # full list replaces the single file summary set just above. + # list fetch. - { source_path: "process.file.size", ocsf_field: "actor.process.file.size" } - { source_path: "process.file.size", ocsf_field: "file.size" } # An indicator's observables carry what the alert body leaves null — on a @@ -143,7 +269,13 @@ ocsf: # instead of writing an empty array over the file summary above. - { source_path: '$exists(indicators) ? [indicators]', ocsf_field: "evidences" } - { source_path: '$count(indicators.attacks) > 0 ? [indicators.attacks.{"tactic": {"name": tactic.name, "uid": tactic.uid}, "technique": {"name": technique.name, "uid": technique.uid}}]', ocsf_field: "finding_info.attacks" } + # The same list at the base event's own `attacks`, which is where the field + # catalog and the ATT&CK matrix widget look for it. + - { source_path: '$count(indicators.attacks) > 0 ? [indicators.attacks.{"tactic": {"name": tactic.name, "uid": tactic.uid}, "technique": {"name": technique.name, "uid": technique.uid}}]', ocsf_field: "attacks" } - { source_path: '$count(relatedAlerts) > 0 ? [relatedAlerts.{"uid": id, "title": name}]', ocsf_field: "finding_info.related_events" } # The alert-level list is empty on a STAR alert while every observable sits # under the indicators, so the two are appended rather than one preferred. + # An alert with neither leaves observables undefined — the previous version + # wrote the file summary here instead, which on an identity alert meant a + # single observable reading {"name": "N/A", "value": "N/A"}. - { source_path: '$exists(observables) or $exists(indicators.observables) ? [($append($boolean(observables) ? observables : [], $boolean(indicators.observables) ? indicators.observables : [])).{"name": name, "value": value, "type": type}]', ocsf_field: "observables" }