How your logs are normalised
Every source is mapped to one schema before a rule or a search sees it. What that buys you, which field names you can actually use, and why OCSF is nested.
Every event that reaches Qorionix is rewritten into a single shared schema before anything reads it. This article explains what that schema is, which field names you can use when you write a search or tune a rule, and a few decisions inside the mapping that will otherwise look strange the first time you open a raw event.
It is written for whoever runs IT. You do not need to know the schema to use the product, but you do need to know the field names the moment you want to search for something specific or narrow a noisy rule.
Why one schema matters more than it sounds
A failed sign-in looks completely different depending on who reported it. Microsoft 365 calls the person UserId and the outcome an error number. A Windows domain controller calls the same person TargetUserName and reports event 4625 with a SubStatus code. AWS calls them userIdentity.arn. A firewall might not name them at all and give you only an address.
If the platform stored those as they arrived, every detection rule would have to be written once per product. A rule for failed sign-ins from one address would become five rules that drift apart, and the sixth source you connect next year would be invisible to all of them until somebody wrote a sixth. That is the ordinary state of an untended log collection, and it is the reason so many of them collect without detecting.
So normalisation happens once, at the front door. One rule then covers every source that can express the concept, and a source you add later is covered on the day it starts sending.
The schema: OCSF 1.6.0
The target schema is the Open Cybersecurity Schema Framework, an open, vendor-neutral standard for describing security events. Qorionix maps to version 1.6.0, and the attribute names and enumerated values are taken from that published version rather than invented. The version is recorded on every stored row, so a future upgrade does not leave you guessing which shape a given event is in.
OCSF has many event classes. Qorionix maps to five of them, and that is the whole set — a short true list rather than a long claim.
| Class | OCSF class | What lands here |
|---|---|---|
3002 | Authentication | Sign-ins and sign-outs, ticket requests, multi-factor challenges — from Entra ID, Okta, Windows, Google Workspace or a syslog appliance. |
3001 | Account Change | Accounts created, enabled, disabled or deleted; passwords changed or reset; policies attached or detached; multi-factor factors added or removed. |
1007 | Process Activity | Process launches, terminations, one process opening another, and injection — from Windows Security, Sysmon and the endpoint sensor. |
4001 | Network Activity | Connections opened, closed, refused or reset; traffic volumes; a service starting to listen. |
6003 | API Activity | Everything else that is an administrative call: mailbox operations, file sharing, registry writes, DNS queries and cloud control-plane actions. |
Five sources have dedicated mappers written against their exact record shapes: Microsoft 365 and Entra, Windows Event Log, AWS CloudTrail, Google Workspace and Okta. Everything else — syslog lines, arbitrary JSON posted to the collector, network appliances — goes through a generic fallback, so every stored event is OCSF rather than only the ones somebody wrote a mapper for.
Where the original record is still in hand, the source-specific mapper runs on the raw bytes first, because nothing has been flattened yet: a Windows 4625 keeps its SubStatus, and a Microsoft 365 sign-in keeps its RiskLevelDuringSignIn. Only if that fails does the pipeline fall back to mapping the already-parsed version, which cannot recover detail the parser dropped.
One rescue is worth naming because it looks like a bug when you first see it. An Authentication event has to name a principal. When a source names none — a malformed sshd line, an appliance reporting only auth failed from this address — the event is not discarded. The user is recorded as the literal unknown and flagged as unresolved, because dropping it would lose the source address that brute-force-by-address rules need. The flag is there so nobody mistakes unknown for a real account.
Every event is written twice, on purpose
One arriving event produces two database rows inside one transaction, sharing the same identifier so they always join:
- The OCSF document. The complete, unflattened OCSF event as a JSON document, plus a set of typed columns — time, class, severity, actor, target, addresses, hostname, operation — that exist only because a database cannot index a path inside a JSON document as cheaply as a real column. Those columns are projections, not a second version of the truth.
- A flat projection. A single wide row in the platform's own event table, with a fixed set of columns and a
metadatadocument. This is what the detection engine evaluates, what search reads, and what every console view displays.
That is not duplication for its own sake. Writing only the OCSF document would have normalised your data and simultaneously disconnected it from everything that consumes it. Writing only the flat row would have thrown away the detail that makes the raw event worth keeping for an investigation.
The field names you will actually type
A small number of fields sit at the top level of an event: category, action, outcome, severity, user_id, source_ip, dest_ip, host, actor_type, raw_log and classification. Everything more specific lives under metadata., and that set is not open-ended — it is a declared contract of named fields, each with a stated availability. You can read your own copy at any time from the field-contract endpoint, which the rule-authoring interface and the ingest side both read, so the two cannot drift apart.
A representative sample, to show the shape rather than the whole list:
| Area | Fields you can search and write rules against |
|---|---|
| Identity sign-ins | metadata.identity_provider, metadata.auth_protocol, metadata.client_app, metadata.ca_status, metadata.mfa_result, metadata.risk_level, metadata.geo_country, metadata.geo_city, metadata.error_code, metadata.token_issuer_type, metadata.user_agent, metadata.device_compliant |
| Directory and mail | metadata.consent_scope, metadata.consent_type, metadata.publisher_verified, metadata.credential_type, metadata.policy_name, metadata.role_name, metadata.mailbox_rule_name, metadata.mailbox_rule_actions, metadata.forwarding_target, metadata.mail_subject |
| Endpoint | metadata.process_name, metadata.command_line, metadata.parent_process, metadata.target_process, metadata.script_block, metadata.event_id, metadata.logon_type, metadata.privilege_list, metadata.signature_status |
| Files, registry, network | metadata.file_path, metadata.file_operation, metadata.registry_path, metadata.registry_value, metadata.service_name, metadata.bytes_out, metadata.dest_host, metadata.dest_port, metadata.dns_query, metadata.protocol |
| Cloud control plane | metadata.cloud_provider, metadata.cloud_service, metadata.cloud_event_name, metadata.resource_name, metadata.user_identity_type, metadata.mfa_authenticated, metadata.error_message |
| Behavioural verdicts | metadata.ueba_off_hours, metadata.ueba_unusual_geo, metadata.ueba_volume_spike, metadata.ueba_deviation_sigma |
A handful of contract fields are declared but marked pending: the pipeline does not populate them yet. Two are geographic coordinates, which no connected source reports. The rest are computed inside the privileged-access, identity and AI modules, none of which currently writes an event into the SIEM. The consequence — that the rules reading those fields stay quiet — is covered in Shadow detections, and why some rules stay quiet.
Where each field's value comes from
There are exactly three ways a metadata field gets its value, and each site in the code is marked with which one it used. Knowing the difference tells you how much to trust a field on an unfamiliar source.
- Read from the standard
- OCSF has an attribute that means precisely this, so the value is read from it.
metadata.command_linecomes from the process command line,metadata.geo_countryfrom the source endpoint's country,metadata.bytes_outfrom the traffic counters. These are the most trustworthy, because two different sources both had to satisfy the same published definition. - Lifted from the unmapped block
- OCSF has no attribute for the concept at all, so the value is taken out of the event's
unmappedblock — which is exactly what the standard reserves that block for. Authentication in OCSF 1.6.0 defines nothing for which client application was used, whether Conditional Access applied, or how a multi-factor challenge ended, sometadata.client_app,metadata.ca_statusandmetadata.mfa_resultare lifted rather than forced onto a standard attribute that means something else. - Computed
- Derived from OCSF attributes plus what the pipeline knows about the event.
metadata.auth_requirementis computed from whether multi-factor was involved;metadata.file_operationfrom the Windows event code;metadata.file_extensionfrom the file path;metadata.dns_query_lengthfrom the length of the query. A computed field is only as good as its inputs, which is why the derivation is recorded rather than assumed.
Why the OCSF document is nested inside metadata
The metadata document holds the flat contract fields at its top level, and the entire OCSF event nested underneath a single ocsf key. That looks like an odd shape until you meet the reason, which is a genuine collision between two vocabularies that use the same word for different things.
{
'identity_provider': 'entra',
'auth_protocol': 'device_code',
'risk_level': 'high',
'geo_country': 'RU',
'ocsf': { 'class_uid': 3002, 'auth_protocol': 'OAUTH 2.0', 'user': {...} }
}Both keys are called auth_protocol and they mean different things. OCSF's is a protocol caption from a fixed enumeration — OAUTH 2.0, Kerberos, SAML. The detection contract's is the OAuth grant flow: device_code, ropc, saml, oauth2, wsfed. That distinction is not a nicety. It is the whole basis of the device-code phishing rule NES-SIEM-100 and the ROPC rule NES-SIEM-101, and the OCSF 1.6.0 enumeration simply cannot express it.
Flattening the OCSF document into the same namespace would have made one of those two meanings win silently. Nesting keeps them apart, still lets you address any OCSF attribute directly as metadata.ocsf.<path>, and still lets a rule that matches on the whole metadata document with a substring or pattern see everything.
The category vocabulary the projection had to invent
Rules and searches scope themselves with a category, and that vocabulary has members OCSF has no category for — because, as above, mail, files, the registry, DNS and cloud control-plane calls all arrive as one class. Leaving them all labelled api is what made whole rule families unable to match. So the projection resolves a category from the workload the source itself states, in this order:
- A registry key on the event makes it
registry. - The stated workload or service name decides next: anything containing
exchangebecomesemail;sharepointoronedrivebecomesfile; Azure Active Directory or Entra becomesiam. This is checked first because it is the one label the source itself asserts — the Microsoft 365 audit log genuinely saysExchange, and CloudTrail genuinely names the service. - A recognisable cloud product key becomes
cloud. This test is deliberately narrow, so that a cloud-scoped rule cannot be satisfied by a Teams audit record that merely happened to come from Microsoft. - A file path with nothing better to go on becomes
file. - Anything left over stays
api.
The full set of values you can use is authentication, iam, process, network, email, file, registry, cloud, api and other. Alongside it, action carries the specific verb — login_failed, login_success, mfa_failed, logout, user_created, password_reset, api_key_created, role_assigned, process_launch, connection_refused, dns_query, and so on.
One small fix in that path is worth knowing about if you ever compare a raw record with its normalised form. The Microsoft 365 audit log spells directory operations with a trailing full stop — Consent to application. — while Microsoft's own documentation, which the rule library follows, has none. An untrimmed operation matched no rule at all, so the trailing stop is stripped during projection.
An absent field is not an empty one
A metadata key is written only when the event genuinely carries evidence for it. Empty strings and nulls are dropped rather than stored.
This is the same principle that governs posture checks in When a check cannot run, applied at the level of individual fields. Rules use exists and not in conditions, so writing a guessed or blank value would turn we did not observe this into we observed nothing — and that is precisely how a rule ends up firing on the absence of telemetry rather than on an attack. The behavioural verdicts follow the same discipline: a verdict is stamped only when it is anomalous, because writing false on every event would make an exists condition on it meaningless.
The practical consequence when you search: do not assume a field is absent because the value was negative. It is absent because there was nothing to say.
What to read next
- Searching your logs — the field names above, put to work.
- The detection library — how a rule is built on top of this vocabulary, and the contract that stops a rule reading a field nothing populates.
- Connecting your first log source — including the honest account of which sources alert today and which only collect.