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.*). 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" 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. # # 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: "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" } - { 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" } - { 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: "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" } # 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) ──────────────────────────────────── # 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" } - { 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" } - { 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" } # 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 # actor.process.name together with the parent's, and a chain of two named # processes is exactly what this alert carries. - { source_path: "process.file.name", ocsf_field: "actor.process.name" } - { 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" } - { 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" } - { 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 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" } - { 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. - { 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 # real STAR alert process.username is null while src.process.user is set, # and the parent process is named but never hashed outside this list. # [0] takes the first indicator to name each one; they agree in practice. - { source_path: '(indicators.observables[name="src.process.user"].value)[0]', ocsf_field: "actor.user.name" } - { source_path: '(indicators.observables[name="src.parent.process.file.path"].value)[0]', ocsf_field: "actor.process.parent_process.file.path" } - { source_path: '(indicators.observables[name="src.parent.process.file.sha256"].value)[0]', ocsf_field: "actor.process.parent_process.file.hashes.sha256" } - { source_path: '(indicators.observables[name="src.parent.process.file.sha1"].value)[0]', ocsf_field: "actor.process.parent_process.file.hashes.sha1" } - { source_path: '(indicators.observables[name="src.parent.process.file.md5"].value)[0]', ocsf_field: "actor.process.parent_process.file.hashes.md5" } # Last, so the alert's own field wins when it is filled in. - { source_path: "process.username", ocsf_field: "actor.user.name" } # [ ] forces a list of one to stay a list; the $exists guard keeps an empty # or absent value undefined, so a plain list fetch skips these entries # 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" }