TL;DR ๐
I shipped iocflow to PyPI โ an open-source Python library for the entire indicator-of-compromise lifecycle, built as six independently-useful layers behind pip extras. The headline isn't "another IOC parser." It's the shape: every layer is a deterministic, boring, testable primitive โ and the top layer is a small LangGraph multi-agent team that orchestrates those primitives, with a human-in-the-loop gate standing between the AI and anything destructive.
6 layersextract ยท enrich ยท comment ยท hunt ยท block ยท agent โ each its own pip extra |
1 importinvestigate(text) runs the whole chain as a multi-agent team
|
0 rogue blocksLLM proposes ยท human authorizes ยท a guard vetoes |
This is the OSS sibling of SOC-in-a-Box, the AI SOC I wrote about last week. SOC-in-a-Box proved the pattern against real systems; iocflow packages the lesson so anyone can pip-install it. ๐งฐ

One call: extract IOCs from a report โ enrich โ suggest hunts โ propose blocks โ wait for a human โ block at the firewall. The benign 8.8.8.8 never even gets proposed.
The lesson I was carrying over
SOC-in-a-Box was eight analyst roles played by one local LLM over a message bus, read-only against production, with a human approving every containment action. The thing that actually made it trustworthy wasn't the agents โ an LLM-with-tools loop is not novel. It was two architectural commitments:
- The model orchestrates; it doesn't do. The irreversible work โ query a SIEM, write a denylist, isolate a host โ is done by plain, deterministic code the model merely calls. The LLM picks what and when; the tool decides how, the same way every time.
- No single authority for a destructive action. The AI can propose containment all day long. A human clicks the button, and a dumb safety check sits underneath both of them refusing to touch anything on an allowlist.
Those two ideas aren't SOC-specific. They're how you make any AI system that touches production safe enough to actually deploy. So I pulled them out of the SOC and built a clean, public library around them. ๐งฑ
Deterministic primitives first, agents last
iocflow grows in layers, each behind its own extra so import iocflow stays a one-dependency install and pulls in nothing you didn't ask for:
-
L1 โ extract (
iocflow): pull IPs, domains, URLs, hashes, CVEs, MITRE technique IDs, threat actors, and malware families out of unstructured text, with the false-positive defenses you'd otherwise hand-write (Public Suffix List validation, benign allowlists, re-fanging ofevil-domain[.]ru). -
L2 โ enrich (
iocflow[enrich]): look each indicator up against VirusTotal / AbuseIPDB / abuse.ch and return a worst-wins verdict. -
L3 โ comment (
iocflow[ai]): an LLM turns the enrichment report into a structured assessment โ and falls back to a deterministic, report-derived summary when no model is configured. It never raises. -
L4 โ hunt (
iocflow[hunt]): render ready-to-run hunt queries โ CrowdStrike CQL, Cortex XQL, and Sigma โ straight from the indicators, offline and stdlib-only. An LLM can add behavioral hunts, but the deterministic queries are always there. -
L5 โ block (
iocflow[block]): push malicious indicators to the control points you operate โ Palo Alto (EDL feed + live User-ID API), Zscaler, CrowdStrike, Abnormal โ withdry_run=Trueas the default everywhere and an authoritative allowlist guard. -
L6 โ agent (
iocflow[agent]): the capstone. ๐ค
Notice that L1โL5 have no idea an agent exists. They're just functions with stable input/output types: ExtractedEntities โ enrich() โ EnrichmentReport โ comment() โ Commentary โ suggest() โ HuntPlan โ block() โ BlockReport. You can use any one of them on its own. That's deliberate โ the agent is a consumer of the primitives, not a replacement for them.
The capstone: a small multi-agent team
Layer 6 hands a report to a supervisor that routes to specialist agents โ extractor, enricher, hunter, responder โ each using L1โL5 as tools, then loops back until the case is done.
flowchart TB
START([report text]) --> SUP{supervisor<br/>routes next step}
SUP -->|extract| EX[extractor<br/>L1 entities]
SUP -->|enrich| EN[enricher<br/>L2 + L3 assessment]
SUP -->|hunt| HU[hunter<br/>L4 queries]
SUP -->|respond| RE[responder<br/>L5 dry-run โ propose]
EX --> SUP
EN --> SUP
HU --> SUP
RE -.proposal.-> GATE{{ApprovalGate<br/>human authorizes}}
GATE -.approved.-> RE
RE -->|live block| SUP
SUP -->|all done| END([Case])
from iocflow.agent import investigate
case = investigate(report_text) # safe: nothing is blocked by default
print(case.commentary.severity.value, "โ", case.commentary.summary)
for line in case.trace: # the agents' reasoning, replayable
print(" โข", line)
The model is any LangChain chat model. The bundled default_agent_model() builds a FailoverChatModel โ primary with an automatic secondary โ which is the same failover model I extracted from the SOC and published earlier. iocflow eating its own dog food. ๐ And here's the part that makes it robust: with no model configured at all, the graph runs the layers in a fixed deterministic order and still produces a complete Case. The LLM is an enhancement, not a dependency.
Three-layer authority (the part that matters) ๐
Blocking is the only step that can hurt you, so it gets the full treatment from SOC-in-a-Box:
- The agent proposes. The responder does a dry run of L5 โ full audit, zero changes โ and turns it into a proposal.
-
A human authorizes. An
ApprovalGatereviews the proposal and returns the approved subset. The default isDenyAllGateโ an unattended run blocks nothing. -
A guard vetoes. Underneath both of them, the Layer 5 allowlist guard refuses to touch public resolvers, private ranges, and well-known domains โ even if the report mislabeled them malicious. You cannot block
8.8.8.8through this library. The LLM is never the sole authority for a destructive action.
For the gate, I wired a real one to Slack โ no inbound webhook server, just post-and-poll:
from iocflow.agent import investigate
from iocflow.agent.chat_gate import SlackApprovalGate
gate = SlackApprovalGate(approvers=["U_ANALYST"], timeout=600)
case = investigate(report_text, gate=gate)
# Bot posts the proposed blocks to your channel.
# โ
from an allowlisted analyst authorizes the plan; โ or no reply = denied.
It posts the proposed blocks to a channel and polls for a reaction from an allowlisted approver โ โ
approves the plan, โ or silence denies it, and a timeout defaults to deny. The whole thing is a ChatApprovalGate over a two-method ChatTransport (post, reactions), so the same flow drops onto Webex, Teams, or a web UI by writing two functions. The transport is a thin seam, which means the gate logic is unit-tested without a single network call.
Why build it this way
The temptation, when you want to "show AI in your work," is to make everything an LLM call. That reads as junior. The stronger story โ the one a security team will actually run โ is deterministic primitives plus agentic orchestration: the boring parts are boring and tested, the AI adds judgment where judgment helps, and a human holds the keys to anything irreversible. ๐ฏ
Everything but the agent layer runs on Python 3.9+; import iocflow stays dependency-light; every layer is independently useful; and the whole agent runs offline in tests because the enrichers, blockers, and model are all injectable.
- ๐ฆ PyPI:
pip install iocflow - ๐ ๏ธ Source: github.com/vinayvobbili/iocflow
- ๐ง The SOC it grew out of: SOC-in-a-Box
If you try it, I'd love to hear what control points you'd plug in. ๐
Top comments (0)