18ae4a24ff
Corrected and completed against a real Identity alert. Wrong data, not just missing: - device.type read asset.type, which 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 flat file summary went straight into observables, so an alert with no file — identity, cloud — carried one observable whose name and value were both the literal string N/A. It now fills file.name and the file.hashes entry its digest length names, and only when the process block did not already describe the file. - finding_info.types is a list, as OCSF and the layout generator expect. - src_endpoint and dst_endpoint are oriented by the detection rather than by habit. SentinelOne fills detectionTime.attacker only when a party distinct from the asset is involved, so its presence decides which way the pair points: attacker as source and asset as destination when it is filled, asset as source and no destination when it is not. Mapping the attacker to dst_endpoint unconditionally announced a domain controller as the source of the enumeration it had in fact suffered. device.* is the invariant across both branches, and the asset's full OS version moved onto it so the flip costs nothing. - The base event had no clock of its own. time, start_time and end_time come 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. 23 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. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
282 lines
25 KiB
YAML
282 lines
25 KiB
YAML
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" }
|