Every security team eventually writes the same function three different ways: one to parse the identity provider’s login event, one for the cloud audit log, one for the endpoint agent. The function does the same job each time — extract who, what, and from where — and each time it’s bespoke.
The problem: every vendor ships its own event shape
A failed login from your identity provider might have the actor’s name in actor.alternateId. The same concept from a cloud audit log might live in userIdentity.principalId. An endpoint agent might call it process.user.name. None of these are wrong — they’re just three incompatible answers to the same question: “who did this?”
Multiply that by every log source a mid-size security team ingests — identity, cloud audit trail, endpoint, network, SaaS admin logs — and you get a detection engineering team that spends more time writing field-mapping glue than writing detection logic. Every new source is a new parser. Every detection rule that needs to correlate across sources has to know the field names of both.
The Open Cybersecurity Schema Framework (OCSF) is an open-source, vendor-neutral schema for security events, originally published by AWS and Splunk and now maintained under the Linux Foundation with contributors from most major security vendors. The pitch is simple: normalize every event into one of a fixed set of well-defined event classes, and write your detection logic once against that schema instead of once per source.
The short version
| Point | Why it matters |
|---|---|
| OCSF defines ~60 event classes (Authentication, API Activity, File System Activity, etc.) | Most log sources map to an existing class instead of inventing a new shape |
Every event carries a numeric class_uid, category_uid, and activity_id | Detection logic can filter on stable integers instead of vendor-specific string values |
| Normalization is a mapping problem, not a rewrite | You write one small mapper per source; the schema and downstream logic stay fixed |
| It doesn’t replace your raw logs | Keep the original event for forensics; OCSF is the normalized layer detection runs against |
Anatomy of an OCSF event
Here’s a login event normalized into OCSF’s Authentication class (class_uid: 3002):
{
"class_uid": 3002,
"class_name": "Authentication",
"category_uid": 3,
"category_name": "Identity & Access Management",
"activity_id": 1,
"activity_name": "Logon",
"severity_id": 1,
"time": 1785859200000,
"status_id": 2,
"status": "Failure",
"user": {
"name": "j.rivera",
"uid": "usr-88213"
},
"src_endpoint": {
"ip": "203.0.113.44",
"location": {
"country": "Unknown",
"coordinates": [0.0, 0.0]
}
},
"metadata": {
"product": {
"name": "generic-idp",
"vendor_name": "example-corp"
},
"version": "1.3.0"
},
"unmapped": {
"raw_event_id": "evt-9f21ab"
}
}
Three things worth calling out:
class_uidandactivity_idare integers, not strings.3002always means Authentication,1under that class always means Logon, regardless of which vendor produced the event. That’s what makes cross-source correlation queries stable — you’re filtering on a schema-defined constant, not hoping every vendor spells “login” the same way.unmappedis where source-specific fields that don’t fit the schema live. You don’t lose data forcing it into OCSF — you keep it, just off to the side, so a mapping gap doesn’t mean throwing away a field a future rule might need.metadata.productrecords provenance. When ten sources normalize into one schema, you still need to know which one produced a given event.
Writing a normalizer: mapping, not rewriting
A normalizer for one log source is a small, mostly mechanical function: pull fields out of the vendor’s native shape, drop them into OCSF’s shape, and pick the right activity_id from a lookup table.
# Pseudocode — normalizes a generic identity-provider login event into OCSF Authentication (3002)
ACTIVITY_MAP = {
"user.login.success": 1, # Logon
"user.login.failure": 1, # Logon (status_id distinguishes outcome)
"user.logout": 2, # Logoff
}
STATUS_MAP = {
"success": 1,
"failure": 2,
}
def normalize_idp_login(raw_event: dict) -> dict:
event_type = raw_event["eventType"]
outcome = raw_event["outcome"]["result"].lower()
return {
"class_uid": 3002,
"class_name": "Authentication",
"category_uid": 3,
"category_name": "Identity & Access Management",
"activity_id": ACTIVITY_MAP.get(event_type, 0),
"status_id": STATUS_MAP.get(outcome, 0),
"status": outcome.capitalize(),
"time": to_epoch_ms(raw_event["published"]),
"user": {
"name": raw_event["actor"]["alternateId"],
"uid": raw_event["actor"]["id"],
},
"src_endpoint": {
"ip": raw_event["client"]["ipAddress"],
},
"metadata": {
"product": {"name": "generic-idp", "vendor_name": raw_event.get("vendor", "unknown")},
"version": "1.3.0",
},
"unmapped": {
"raw_event_id": raw_event["uuid"],
},
}
This is the entire cost of adding a new source: one mapping function, maybe 30-50 lines, that translates field names and picks the right class/activity IDs. Everything downstream — detection rules, dashboards, retention policy — is written once against the OCSF shape and never touches this function again.
Why detection logic gets simpler
Without normalization, a rule that needs to catch “a login failure followed by a success from a different country within five minutes” has to know the field names for every identity source you run. With OCSF, it’s one rule:
-- Pseudocode: works identically regardless of which IdP produced the events
SELECT a.user.uid, a.src_endpoint.ip AS fail_ip, b.src_endpoint.ip AS success_ip
FROM ocsf_events a
JOIN ocsf_events b
ON a.user.uid = b.user.uid
AND b.time BETWEEN a.time AND a.time + INTERVAL '5 minutes'
WHERE a.class_uid = 3002 AND a.status_id = 2 -- Authentication, Failure
AND b.class_uid = 3002 AND b.status_id = 1 -- Authentication, Success
AND a.src_endpoint.location.country != b.src_endpoint.location.country
Add a second identity provider next quarter, and this query doesn’t change. Only the normalizer for the new source does.
Where OCSF adoption gets messy
OCSF isn’t a free lunch. Three friction points show up in practice:
| Challenge | What actually happens |
|---|---|
| Not every event fits a clean class | Some vendor-specific events (a niche SaaS admin action, say) don’t map cleanly to an existing class — you either force a rough fit or lean on unmapped and accept partial normalization |
| Schema versioning | OCSF is still evolving; a field renamed between schema versions means updating every normalizer that touches it |
| It’s a normalization layer, not a storage format | OCSF defines the shape of an event, not how you store or query it — you still need a real backend and query engine behind it |
None of these are reasons to skip it. They’re reasons to treat OCSF adoption as incremental — normalize your highest-volume, highest-value sources first (identity, cloud audit, endpoint), and let lower-priority sources stay in their raw shape until the mapping is worth writing.
Final thought
The value of OCSF isn’t the schema itself — it’s what stops happening once you adopt it: no more detection rule rewritten per vendor, no more onboarding a new log source by hand-mapping every field a correlation query touches. Normalize once at ingestion, and every rule, dashboard, and retention policy downstream gets to assume one consistent shape. That’s less an architecture decision than a tax you stop paying every time a new source shows up.
If you’re weighing how much of your log pipeline to normalize first, contact us.