Skip to content

This guide shows you how to enrich OCSF events with threat intelligence from lookup tables. Use this pattern when you ingest indicators of compromise, reputation scores, malware names, campaign context, or OSINT from external feeds.

Start with the OCSF enrichments array when you want to attach the source record as inline context. For production mappings, prefer one lookup table per OCSF object and place the result in semantic fields such as observables, osint, or nested reputation objects. This keeps threat intelligence queryable without requiring every downstream consumer to understand a provider-specific enrichment payload.

Pick the enrichment target based on what the lookup table value represents:

Lookup table valueOCSF target
Raw or provider-specific indicator contextenrichments
Observable with reputation for a hostname, IP address, URL, or hashobservables
OSINT indicator detailsosint
OSINT indicator details with related malwareosint

Create separate lookup tables in setup pipelines for the OCSF objects you plan to enrich:

Lookup tableOCSF target
domain_indicator_enrichmentsenrichments
domain_reputationobservables with reputation
domain_osintosint
domain_malwareosint with malware

Separate tables let each value match a specific OCSF target. They also let you refresh high-churn reputation data more frequently than slower-moving malware or campaign metadata.

Populate these tables from feed ingestion pipelines, package pipelines, or internal intelligence exports. Keep each table value close to the OCSF object it will produce, then use the enrichment pipeline only to look up and place the value.

Use format="ocsf" with mode="append" to attach a match as an OCSF enrichment object:

from {
time: 2024-08-22T09:13:01,
category_uid: 4,
class_uid: 4003,
activity_id: 2,
type_uid: 400302,
severity_id: 1,
metadata: {
version: "1.8.0",
},
query: {
hostname: "malware.example",
},
rcode_id: 0,
rcode: "NoError",
dst_endpoint: {
ip: 192.0.2.53,
port: 53,
},
enrichments: [],
}
context::enrich "domain_indicator_enrichments",
key=query.hostname,
into=enrichments,
mode="append",
format="ocsf"
{
time: 2024-08-22T09:13:01,
category_uid: 4,
class_uid: 4003,
activity_id: 2,
type_uid: 400302,
severity_id: 1,
metadata: {
version: "1.8.0",
},
query: {
hostname: "malware.example",
},
rcode_id: 0,
rcode: "NoError",
dst_endpoint: {
ip: 192.0.2.53,
port: 53,
},
enrichments: [{
created_time: 2024-08-22T09:13:02.069981,
data: {
provider: "threat-intel",
indicator: "malware.example",
indicator_type: "domain",
threat_type: "payload_delivery",
confidence_level: 95,
tags: ["malware"],
},
name: "query.hostname",
provider: "domain_indicator_enrichments",
value: "malware.example",
}],
}

This is the safest default when you don’t yet know where the feed belongs in the OCSF event. The result stays attached to the event, with the lookup result in data and the context name in provider, but it remains a generic enrichment object because Tenzir does not infer an enrichment type.

For detection and hunting workflows, reputation belongs with the observable that matched. Populate a table whose values are OCSF observables with nested reputation objects:

from {
indicator: "malware.example",
observable: {
name: "query.hostname",
type_id: 1,
type: "Hostname",
value: "malware.example",
reputation: {
provider: "threat-intel",
base_score: 95,
score_id: 10,
score: "Malicious",
},
},
}
context::update "domain_reputation", key=indicator, value=observable

Attach the reputation to an OCSF observable for query.hostname:

from {
time: 2024-08-22T09:13:01,
category_uid: 4,
class_uid: 4003,
activity_id: 2,
type_uid: 400302,
severity_id: 1,
metadata: {
version: "1.8.0",
},
query: {
hostname: "malware.example",
},
rcode_id: 0,
rcode: "NoError",
observables: [],
}
context::enrich "domain_reputation",
key=query.hostname,
into=observables,
mode="append"
{
time: 2024-08-22T09:13:01,
category_uid: 4,
class_uid: 4003,
activity_id: 2,
type_uid: 400302,
severity_id: 1,
metadata: {
version: "1.8.0",
},
query: {
hostname: "malware.example",
},
rcode_id: 0,
rcode: "NoError",
observables: [{
name: "query.hostname",
type_id: 1,
type: "Hostname",
value: "malware.example",
reputation: {
provider: "threat-intel",
base_score: 95,
score_id: 10,
score: "Malicious",
},
}],
}

This makes reputation available through the standard OCSF observable model instead of burying it inside enrichments[].data.

When a feed provides indicator details, store values as OCSF OSINT objects and append them to the event’s osint list:

from {
indicator: "malware.example",
osint: {
type_id: 2,
type: "Domain",
value: "malware.example",
name: "query.hostname",
vendor_name: "threat-intel",
category: "malware_delivery",
desc: "Domain associated with malware delivery.",
confidence_id: 3,
confidence: "High",
risk_score: 95,
labels: ["malware"],
reputation: {
provider: "threat-intel",
base_score: 95,
score_id: 10,
score: "Malicious",
},
},
}
context::update "domain_osint", key=indicator, value=osint

Append the OSINT object directly:

from {
time: 2024-08-22T09:13:01,
category_uid: 4,
class_uid: 4003,
activity_id: 2,
type_uid: 400302,
severity_id: 1,
metadata: {
version: "1.8.0",
profiles: ["osint"],
},
query: {
hostname: "malware.example",
},
rcode_id: 0,
rcode: "NoError",
osint: [],
}
context::enrich "domain_osint",
key=query.hostname,
into=osint,
mode="append"
{
time: 2024-08-22T09:13:01,
category_uid: 4,
class_uid: 4003,
activity_id: 2,
type_uid: 400302,
severity_id: 1,
metadata: {
version: "1.8.0",
profiles: ["osint"],
},
query: {
hostname: "malware.example",
},
rcode_id: 0,
rcode: "NoError",
osint: [{
type_id: 2,
type: "Domain",
value: "malware.example",
name: "query.hostname",
vendor_name: "threat-intel",
category: "malware_delivery",
desc: "Domain associated with malware delivery.",
confidence_id: 3,
confidence: "High",
risk_score: 95,
labels: ["malware"],
reputation: {
provider: "threat-intel",
base_score: 95,
score_id: 10,
score: "Malicious",
},
}],
}

Use this shape when the threat intelligence is valuable in its own right, not only as a note about one event field.

If analysts frequently query by malware family, keep malware details in their own lookup table and then place them under the OSINT object:

from {
indicator: "malware.example",
osint: {
type_id: 2,
type: "Domain",
value: "malware.example",
name: "query.hostname",
vendor_name: "threat-intel",
malware: [{
name: "ExampleBot",
classification_ids: [3],
classifications: ["Bot"],
severity_id: 4,
severity: "High",
}],
},
}
context::update "domain_malware", key=indicator, value=osint

Append the OSINT record with the malware object:

from {
time: 2024-08-22T09:13:01,
category_uid: 4,
class_uid: 4003,
activity_id: 2,
type_uid: 400302,
severity_id: 1,
metadata: {
version: "1.8.0",
profiles: ["osint"],
},
query: {
hostname: "malware.example",
},
rcode_id: 0,
rcode: "NoError",
osint: [],
}
context::enrich "domain_malware",
key=query.hostname,
into=osint,
mode="append"
{
time: 2024-08-22T09:13:01,
category_uid: 4,
class_uid: 4003,
activity_id: 2,
type_uid: 400302,
severity_id: 1,
metadata: {
version: "1.8.0",
profiles: ["osint"],
},
query: {
hostname: "malware.example",
},
rcode_id: 0,
rcode: "NoError",
osint: [{
type_id: 2,
type: "Domain",
value: "malware.example",
name: "query.hostname",
vendor_name: "threat-intel",
malware: [{
name: "ExampleBot",
classification_ids: [3],
classifications: ["Bot"],
severity_id: 4,
severity: "High",
}],
}],
}

This keeps malware-specific fields in an OCSF malware object, while the OSINT object ties that malware context back to the indicator that matched the event.

Use separate tables for different indicator types instead of mixing unrelated keys into one catch-all table:

Indicator typeExample OCSF keyExample target
Domain or hostnamequery.hostnameobservables[].reputation, osint
IP addresssrc_endpoint.ip or dst_endpoint.ipobservables[].reputation
URLhttp_request.url.url_stringobservables[].reputation, osint
File hashfile.hashes[].valueobservables[].reputation, osint

Keeping one table per OCSF object and indicator type prevents ambiguous values, lets you set different expiration policies, and makes each enrichment pipeline state exactly which semantic field it changes.

Last updated: