Query OCSF data
Learn how to query OCSF data in Axiom with APL, including typed columns, the unmapped map field, and arrays of objects such as observables and attacks.
This page shows how to query an OCSF dataset with APL. It covers the end of the OCSF flow in Axiom: the dataset is prepared and receives conformed events, as described in Send OCSF data to Axiom. To search the same data from Splunk, see Search OCSF data from Splunk.
The examples run against the 100 synthetic events in the OCSF for Axiom pack’s axiom_normalize_post_processor sample after conforming. The outputs show what each query returns for that sample. Replace ocsf with the name of your dataset.
Choose the right reference
Every OCSF attribute lives in exactly one place. The place determines how you reference it in APL:
| Attribute | Stored as | APL reference |
|---|---|---|
class_uid, severity_id | Promoted column | class_uid |
user.name, src_endpoint.ip | Promoted column | ['user.name'] |
attacks, observables | Map field, array of objects | ['attacks'][0]['technique']['uid'] |
device.os.name | Under unmapped | ['unmapped']['device']['os']['name'] |
Column names that contain dots must be quoted with brackets, for example ['src_endpoint.ip']. For more information, see Entity names. Inside map fields, use index notation for each level. For more information, see Map fields.
To see which columns your dataset actually contains, use getschema. If an attribute isn’t a column, it’s either inside a map field or under unmapped:
Filter and group on numeric *_id columns rather than their string siblings. For example, use status_id == 2 rather than status == 'Failure'. Producers spell the strings differently, but the IDs are fixed by OCSF. The OCSF schema browser lists the meaning of every ID.
Count events by class
Start with an inventory of the classes in the dataset:
| class_uid | class_name | events |
|---|---|---|
| 4001 | Network Activity | 40 |
| 4003 | DNS Activity | 15 |
| 3002 | Authentication | 15 |
| 1007 | Process Activity | 10 |
| 4002 | HTTP Activity | 10 |
| 2004 | Detection Finding | 5 |
| 6003 | API Activity | 5 |
Filter on promoted columns
Failed logons by user and source address. In the Authentication class (3002), activity_id 1 is a logon, status_id 2 is a failure, and user is the account that tried to log on:
| user.name | src_endpoint.ip | status_detail | failures |
|---|---|---|---|
| heidi | 10.24.111.174 | Unknown user name or bad password. | 1 |
| dave | 10.30.177.45 | Unknown user name or bad password. | 1 |
| alice | 10.25.217.127 | Unknown user name or bad password. | 1 |
Promoted columns are typed, so numeric comparisons and sums need no conversion. Top destinations by bytes in Network Activity (4001):
| dst_endpoint.ip | bytes |
|---|---|
| 198.51.100.182 | 441038 |
| 192.0.2.51 | 405202 |
| 198.51.100.193 | 403470 |
Read fields from unmapped
unmapped keeps the original OCSF nesting, so you read a field with one index per level. Values inside a map field are dynamic, so convert them with tostring, toint, or a similar function before you group or compare. The operating system of the device that recorded each logon is under unmapped because device.os is an object inside an object:
| os | events |
|---|---|
| Windows Server 2022 | 15 |
To find which keys each class carries under unmapped, list the top-level keys with bag_keys and expand them into rows:
| class_name | key | events |
|---|---|---|
| API Activity | actor | 5 |
| API Activity | api | 5 |
| API Activity | cloud | 5 |
| API Activity | metadata | 5 |
| Authentication | device | 15 |
| Authentication | metadata | 15 |
| Detection Finding | evidences | 5 |
| Detection Finding | finding_info | 5 |
| Network Activity | metadata | 40 |
| Process Activity | device | 10 |
The Detection Finding events also show an array of objects under unmapped. Index into it like any other array:
| device.hostname | rule | process |
|---|---|---|
| wks-014.corp.example.com | Rare destination | rundll32.exe |
| srv-db-02.corp.example.com | Rare destination | rundll32.exe |
| … | … | … |
Querying a map field uses more query-hours than querying a column. If you query an unmapped field often, define a virtual field for it so your team can reuse it by name.
Query arrays of objects
The six promoted arrays, observables, vulnerabilities, answers, attacks, file.hashes, and process.file.hashes, are each stored whole in their own map field. Every element keeps its own attributes, so an observable’s value stays next to its type and a technique stays next to its tactic.
To read one element, index it:
| finding_info.title | device.hostname | technique | tactic |
|---|---|---|---|
| Suspicious outbound connection | wks-014.corp.example.com | T1071 | Command and Control |
| Suspicious outbound connection | srv-db-02.corp.example.com | T1071 | Command and Control |
| … | … | … | … |
To work with every element, expand the array into rows with mv-expand. For example, list every IP address observable in Detection Findings. In observables, type_id 2 is an IP address:
| ip | findings |
|---|---|
| 192.0.2.145 | 1 |
| 198.51.100.15 | 1 |
| 192.0.2.221 | 1 |
| 192.0.2.199 | 1 |
| 203.0.113.35 | 1 |
To pull one attribute from every element without expanding rows, use array_extract. It returns an array per event:
Investigate an entity across classes
Because all classes share one dataset and one vocabulary, one query can follow an entity across event types. Everything the sample recorded about one workstation:
| class_name | events |
|---|---|
| Detection Finding | 2 |
| Process Activity | 2 |
Events are sparse: each class fills only its own columns. In cross-class queries, expect empty values in columns that a class doesn’t use, and guard with isnotnull or isnotempty where it matters.