OCSF: Stop Writing a New Parser for Every Log Source

How the Open Cybersecurity Schema Framework (OCSF) lets you normalize logs from any vendor into one schema — and where a detection engineer should and shouldn't use it.

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

PointWhy 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_idDetection logic can filter on stable integers instead of vendor-specific string values
Normalization is a mapping problem, not a rewriteYou write one small mapper per source; the schema and downstream logic stay fixed
It doesn’t replace your raw logsKeep 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_uid and activity_id are integers, not strings. 3002 always means Authentication, 1 under 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.
  • unmapped is 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.product records 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:

ChallengeWhat actually happens
Not every event fits a clean classSome 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 versioningOCSF is still evolving; a field renamed between schema versions means updating every normalizer that touches it
It’s a normalization layer, not a storage formatOCSF 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.