Bring your own context.
A detection is only as smart as the context around the event. Enrichments attach your business knowledge — who owns this host, how critical it is, whether this employee is mid-offboarding — to every event as additive ctx fields, joined on entity_id at ingest. Nothing is re-ingested, and the raw event is never touched.
How enrichment works
Every event already carries threatDefendr's own enrichments under ctx — asset, geo, identity, intel. A custom enrichment adds your fields to that same namespace. Because it is additive and keyed on entity_id, it never rewrites the event and never forces a re-normalization pass; a detection written next month reads it the same way it reads a native field.
- Additive — your fields land under
ctxalongside ours; the original payload underrawis untouched. - Joined on identity — the same
entity_idthat ties events across sources ties your context to them. - Evaluated once — enrichment happens on the way in, so detection and search read a precomputed field, not a runtime lookup.
Define an enrichment source
An enrichment source is declarative. Point it at a table or an API, name the join key, and map source columns to ctx fields. The example syncs your CMDB every six hours and decorates host events with owner, tier, and environment.
YAMLenrichments/cmdb-assets.yml version: 1 id: cmdb-assets kind: lookup_table join: on: entity.target # the host / workload entity_id key: hostname source: type: jdbc refresh: 6h # re-sync every 6 hours query: > SELECT hostname, owner_team, tier, environment FROM cmdb.assets map: ctx.asset.owner: owner_team ctx.asset.tier: tier # crown-jewel | standard | sandbox ctx.asset.env: environment
Real-time vs batch
Stable facts — who owns an asset, what tier it is — belong in a batch table that syncs on an interval. Volatile facts — an employee's HR status, a ticket's state — belong behind a real-time API lookup with a strict latency budget, so a slow upstream never backs up ingest.
YAMLenrichments/hr-status.yml version: 1 id: hr-status kind: api join: on: entity.actor key: email source: type: https url: https://hr.acme.io/v1/employees/by-email cache_ttl: 30m budget_ms: 40 # fail-open past the latency budget map: ctx.identity.status: status # active | offboarding | terminated
| Mode | Freshness | Best for | Cost control |
|---|---|---|---|
| Batch table | Up to the refresh interval | Owner, tier, environment, CMDB facts | One query per interval |
| Real-time API | Per event (cached) | HR status, ticket state, risk score | Cache TTL + latency budget |
Use enrichments in detections
Once mapped, a custom field is indistinguishable from a native one. Match on it, branch on it in a playbook, or pivot on it in search. The detection below only fires for unsigned execution on a crown-jewel asset — noise everywhere else, signal where it counts.
YAMLscoping a detection by tier when: stream: endpoint.process match: ctx.asset.tier: crown-jewel signed: false window: 1m
entity_id, a back-test or investigation over historical events resolves ctx.asset.tier from the current table — so you can hunt “every crown-jewel touch last quarter” the day after you define the source.Mind cardinality & cost
Enrichment is cheap until it isn't. Two things keep it cheap: cache real-time lookups aggressively, and keep the join key low-cardinality. A per-event API call with no cache and a unique key is how you turn a 40 ms budget into a backlog.
- Cache and set a TTL — match the TTL to how fast the field actually changes; 30 minutes for HR status, seconds for nothing.
- Fail open — past
budget_ms, the event flows un-enriched rather than stalling the stream; the field simply isn't present. - Watch cardinality — the data fabric caps distinct
ctxvalues per source; high-cardinality free text belongs inraw, not an enrichment.
Where to go next
- Data fabric — the event model and the
entity_idjoin these build on. - Detection-as-code — put your new fields to work in a rule.
- API reference — push enrichments programmatically.