The difference between a case an analyst has to research and one that already says “this IP is a known Cobalt Strike C2, last seen yesterday” is a single automated enrichment step. This guide builds that step — attaching threat-intel verdicts to a case and lifting its confidence — as one precise technique within case enrichment pipelines and the broader SOAR playbook & response automation discipline.
Root-Cause Context
A correlated case arrives with entity indicators — a source IP, a file hash, a domain — but no verdict on whether those indicators are known-bad. Threat-intel enrichment answers that, and the answer changes the case’s whole trajectory: a confirmed-malicious verdict can lift confidence past the threshold for automated containment, while a clean verdict keeps the case at analyst-review priority. Skipping enrichment forces the analyst to pivot into a separate intel console for every case, which is exactly the manual toil automation exists to remove.
The step has two hazards. First, it is a network call, so it must be cached and bounded — an un-cached lookup per case will exhaust the feed’s rate limit during a storm, and an un-bounded one will stall the pipeline. Second, its output is advisory: a missing or slow verdict must never block the case from progressing, because a delayed containment is worse than an un-enriched one. Enrichment lifts confidence when a verdict is present and leaves the base confidence untouched when it is not — it never lowers confidence or gates the case on its own success.
Prerequisites
The step targets Python 3.11+ with an async intel client. It reuses the budgeted, cached lookup discipline from enrichment latency budgets and plugs into the parent case enrichment pipelines fan-out.
pip install "pydantic>=2.6,<3.0"
Production-Ready Implementation
The step extracts indicators from the case, looks each up under a deadline, and applies a bounded confidence lift when any resolves malicious — always fail-open.
from __future__ import annotations
import asyncio
import logging
from typing import Awaitable, Callable, Optional
from pydantic import BaseModel, Field
logger = logging.getLogger("soc.soar.enrich.intel")
class Case(BaseModel):
case_id: str = Field(min_length=1)
confidence: float = Field(ge=0.0, le=1.0)
src_ip: Optional[str] = None
file_hash: Optional[str] = None
domain: Optional[str] = None
class IntelResult(BaseModel):
verdict: str = "unknown" # malicious | suspicious | clean | unknown
source: str = ""
last_seen: Optional[str] = None
IntelLookup = Callable[[str], Awaitable[Optional[IntelResult]]]
# Bounded confidence lift per verdict; never lowers confidence.
_LIFT = {"malicious": 0.3, "suspicious": 0.1}
async def enrich_case(
case: Case, lookup: IntelLookup, deadline: float = 0.05,
) -> tuple[Case, dict]:
"""Return (possibly-lifted case, enrichment context). Fail open."""
indicators = [v for v in (case.src_ip, case.file_hash, case.domain) if v]
context: dict = {"intel": [], "coverage": "none"}
if not indicators:
return case, context
async def _one(ind: str) -> Optional[IntelResult]:
try:
return await asyncio.wait_for(lookup(ind), deadline)
except asyncio.TimeoutError:
logger.info("ERR_ENRICH_004 intel timeout indicator=%s", ind)
return None
except Exception as exc:
logger.warning("ERR_ENRICH_005 intel error indicator=%s: %s", ind, exc)
return None
results = await asyncio.gather(*(_one(i) for i in indicators))
got = [r for r in results if r is not None]
context["coverage"] = "full" if len(got) == len(indicators) else "partial" if got else "none"
best_lift = 0.0
for r in got:
context["intel"].append(r.model_dump())
best_lift = max(best_lift, _LIFT.get(r.verdict, 0.0))
if best_lift > 0:
lifted = min(1.0, case.confidence + best_lift) # clamp, never exceed 1.0
return case.model_copy(update={"confidence": lifted}), context
return case, context # unchanged, still proceeds
async def _demo() -> None:
async def lookup(ind: str) -> Optional[IntelResult]:
if ind == "203.0.113.9":
return IntelResult(verdict="malicious", source="feed-x", last_seen="2026-05-18")
return IntelResult(verdict="clean", source="feed-x")
case = Case(case_id="C-1", confidence=0.6, src_ip="203.0.113.9")
enriched, ctx = await enrich_case(case, lookup)
assert enriched.confidence == 0.9 # 0.6 + 0.3 malicious lift
assert ctx["intel"][0]["verdict"] == "malicious"
if __name__ == "__main__":
asyncio.run(_demo())
The lift is bounded per verdict and clamped at 1.0, so a malicious verdict raises confidence toward the auto-act threshold without ever exceeding the ceiling, and a clean or absent verdict leaves the case exactly as it arrived. Every lookup is deadline-bounded and returns None on timeout or error, so one slow feed degrades this case’s coverage to partial rather than blocking it — the fail-open contract the enrichment layer depends on.
Error-Code Reference
| Code | Meaning | Action |
|---|---|---|
ERR_ENRICH_004 |
Intel lookup exceeded its deadline | Record coverage gap; proceed fail-open |
ERR_ENRICH_005 |
Intel source returned an error | Record coverage gap; circuit-break on repeated failures |
ERR_ENRICH_011 |
Case has no usable indicators | Skip enrichment; the case proceeds unchanged |
ERR_ENRICH_031 |
Intel source returned a malformed verdict | Discard that verdict; keep other sources’ results |
Operational Notes
- Lift, never lower. Enrichment can raise confidence on a malicious verdict but must never reduce it; a clean verdict is context, not a reason to de-prioritize a scored case.
- Bound every lookup. A per-indicator deadline plus caching keeps enrichment off the critical path and within the feed’s rate limit.
- Fail open on coverage gaps. A timeout or error records a partial-coverage marker and the case still proceeds — enrichment refines the decision but never gates it.
- Attach provenance. Carry the source and last-seen with each verdict so an analyst can judge freshness and the ticket cites its evidence.
Verification Checklist
FAQ
Should a threat-intel verdict be able to lower a case's confidence?
No. Enrichment is additive: a malicious or suspicious verdict lifts confidence toward the auto-act threshold, but a clean verdict is treated as context, not as a reason to de-prioritize. A case earned its base confidence from correlation and scoring, and a “clean” intel result on one indicator does not negate that — the activity may still be malicious in a way the feed has not catalogued. Lifting only keeps enrichment from ever suppressing a real signal.
What happens if the intel feed is down when a case arrives?
The case proceeds with unchanged confidence and a recorded coverage gap. Every lookup is deadline-bounded and fails open, so a down or slow feed simply means no verdict is attached and the coverage marker reads partial or none. The decision gate then acts on the case’s base confidence, and the missing verdict can be back-filled as a later case update once the feed recovers — enrichment never holds a case hostage to feed availability.