Building vex — An IOC Enrichment Tool Built with Claude Code
vex is a command-line tool for enriching Indicators of Compromise (IOCs) via the VirusTotal API. It automatically detects the IOC type — hash, IP, domain, or URL — and returns structured, actionable results directly in the terminal. This post covers what the tool does, how it was built, and what working with Claude Code as a development environment actually looked like in practice.
| Field | Details |
|---|---|
| Type | Python CLI Project |
| Repository | github.com/duathron/vex |
| PyPI | pypi.org/project/vex-ioc |
| Version | 1.1.0 |
| Stack | Python, Typer, Rich, httpx, Pydantic v2, SQLite |
| Built with | Claude Code |
The Problem
When analysing incidents, one of the more tedious parts of the workflow is querying threat intelligence APIs manually. You have a list of hashes or IP addresses, you open VirusTotal, paste one in, wait, read the result, note it down, then repeat. For a handful of indicators that’s manageable. With dozens or hundreds it becomes mechanical work that pulls attention away from the actual analysis.
Free tooling exists for this, but most of it has trade-offs: raw JSON output without interpretation, no rate limiting for free API tiers, no way to filter by severity or plug the result into a shell script. I wanted something that could handle the repetitive part automatically and give me results I could actually read at a glance.
That became the starting point for vex.
Building with Claude Code
vex was built using Claude Code, Anthropic’s agentic coding tool that runs in the terminal. This is different from asking Claude a question in a chat interface. Claude Code has direct access to the filesystem, can read and write files, run commands, and maintain context across an entire codebase. For a project like this — multiple modules, interdependencies, an installable package — that made a real difference.
My Python experience at this point covers reading and understanding code, and writing small scripts. A project with this scope — async clients, SQLite databases, a plugin architecture, a proper CLI framework — would have taken me much longer to design and implement on my own. What Claude Code gave me was the ability to work at a higher abstraction level: describe what I wanted a component to do, iterate on the design, and understand each decision rather than just accepting the output.
That last part was the constraint I kept coming back to: if something was written that I couldn’t explain myself, I didn’t move forward until I understood it. The typing.Protocol-based plugin system is a good example. The concept of structural subtyping — a class doesn’t need to explicitly inherit from a base class to be compatible, it just needs to implement the right methods — wasn’t something I had used before. Working through that decision, and understanding why it was the right call here, is more durable than if I’d accepted a class hierarchy without questioning it.
Agents and MeetUps as Project Structure
What made the development process structurally interesting was the use of specialised agents and a practice I started calling MeetUps — sessions where multiple agents with distinct roles reviewed the same codebase and contributed from their respective perspectives.
For vex, the roles involved at different stages were: Architect (overall design and module boundaries), SOC Analyst (use-case validation and workflow realism), DFIR Agent (deep investigation feature requirements), Code Debug Agent (bug identification and fix verification), Code Security Agent (input validation, path handling, permissions), UX Design Agent (output readability and CLI ergonomics), and QM Agent (quality management and pre-release validation).
None of these are separate AI models. They’re Claude Code operating with a specific context and focus for a given session. The value is in the structured framing: asking “review this from a UX perspective” versus “review this for security issues” surfaces different problems, and combining both surfaces more than either alone.
v1.0.1 — The UX and QM Review
After the initial 1.0.0 release, the UX Design Agent and the QM Agent each reviewed the tool independently. Five concrete improvements came out of the UX review.
Colour-coded verdicts in console output. Verdicts were plain text in --output console mode — CLEAN, MALICIOUS — with no visual differentiation. Rich already had colour mapping internally; the fix was making console output use it too, while still stripping ANSI codes automatically when piping to a non-TTY.
Batch failure summary. When processing multiple IOCs, individual failures were logged to stderr, but there was no total count. If two of ten lookups failed, you had to count the error lines yourself. A failed_count variable and a post-loop summary fixed this.
Alert filter “no matches” message. Running --alert MALICIOUS on a clean batch produced no output and exit code 0. Silence as a signal is fragile. The fix: a stderr message when the pre-filter count was non-zero but the post-filter result was empty — "No IOCs matched alert threshold MALICIOUS (8 below threshold)".
Truncation indicator. Long lists in Rich output — malware families, categories, tags — were silently cut to 5–8 items. A _truncated() helper now appends (+N more) consistently across both Rich and console modes.
The QM Agent found a separate issue: config.yaml.example was significantly incomplete compared to the actual config.yaml. Entire sections were missing, one key was named incorrectly and silently ignored by Pydantic, and the API key was set to an empty string instead of being commented out. A broken example file is a reliability problem for anyone trying to set up the tool from scratch.
The QM process that started with v1.0.1 runs twelve checks before each release: syntax validation, module integrity, CLI smoke tests, dependency checks, and config file validation. All twelve passed for the v1.0.1 release.
v1.1.0 — MeetUp VEX-2026-003
Version 1.1.0 was shaped by a full MeetUp — a session where all seven agents reviewed the codebase simultaneously and contributed toward a single release. The target was the four known limitations documented since v1.0.0.
Batch processing activation. batch.py had fully implemented parallel batch functions using ThreadPoolExecutor with Rich progress bars, but neither subcommand actually called them — everything ran sequentially. There were also three bugs in batch.py itself: detect() returns a tuple but the code assigned it to a single variable, _process_single_investigate() didn’t call map_to_attack(), and the SQLite cache wasn’t thread-safe for concurrent access. All three were fixed, then the batch functions were wired into both subcommands for multi-IOC input.
Premium endpoint graceful degradation. Only hash.py checked config.is_premium before calling premium VT endpoints. The IP, domain, and URL enrichers called premium endpoints unconditionally — free-tier users hit HTTP 403 errors. The fix was two-pronged: client.py now accepts a premium_optional parameter that converts 403 responses to empty dicts with a logged message, and the enrichers gate premium calls behind the is_premium check consistently.
Entry-point plugin discovery. The plugin loader had importlib.metadata.entry_points discovery code present but commented out. Implementing the discovery with a Python 3.11 compatibility fallback and wrapping each load in a try/except means broken third-party plugins log a warning but never crash the CLI. vex version now shows loaded plugins.
IPv6 detection upgraded. The regex-based IPv6 detection couldn’t handle IPv4-mapped addresses or zone-scoped link-local addresses. Replacing the regex with Python’s ipaddress.ip_address() stdlib function handles all RFC 4291 forms natively.
Passive version check. A new version_check.py module queries the PyPI JSON API, caches the result for 24 hours, and displays a notice when a newer release is available. The MeetUp explicitly decided against an auto-update command — supply chain risk and SOC change management were the reasons. The check fails silently on network errors and never blocks the tool.
What vex Does
IOC Type Detection
The first thing vex does when it receives input is determine what kind of IOC it is. MD5, SHA1, SHA256, IPv4, IPv6, domain, and URL are handled via ioc_detector.py. One detail worth getting right: defanged IOCs. Threat intelligence reports often write IPs and domains in modified form — 8[.]8[.]8[.]8 or hxxps[://]evil[.]com — to prevent accidental clicks. vex normalises these before querying the API, so you can paste directly from a report without manual editing first.
Two Query Modes
Triage is for speed. One API call, one answer: verdict, detection ratio, flagging engines, and identified malware families.
1
vex triage 275a021bbfb6489e54d471899f7db9d1663fc695ef2bb4b0b5b9a9b7b9a5c8a4
Investigate goes deeper. Multiple API calls return sandbox behaviour, Passive DNS history, WHOIS, PE header information, dropped files, and contacted infrastructure. The MITRE ATT&CK mapping runs after the enricher call and maps sandbox behaviour fields and VirusTotal tags to ATT&CK techniques — 80+ keywords covering process names, registry keys, and API calls like VirtualAlloc or CreateRemoteThread.
Both modes support --file input and stdin piping:
1
2
strings malware.exe | grep -E '^[a-f0-9]{64}$' | vex triage -o rich
vex triage -f daily_iocs.txt --alert SUSPICIOUS --summary
Verdicts and Exit Codes
Four verdict levels: CLEAN, UNKNOWN, SUSPICIOUS, MALICIOUS. The important one is UNKNOWN — returned when too few engines have scanned a file to say anything reliable. Zero detections is not the same as clean, especially for new or rare samples. This comes up in the TryHackMe — Invite Only writeup too, where VirusTotal community data was more useful than the automated detection count alone.
Exit codes map directly: CLEAN/UNKNOWN exit 0, SUSPICIOUS exits 1, MALICIOUS exits 2. That makes vex usable in shell scripts and SOAR playbooks where the next step depends on the verdict.
Output and Knowledge Base
Three output modes: console (readable plaintext, the default), rich (colour-coded panels for interactive use), json (machine-readable, for SIEM integration). --csv for batch exports, --stix for STIX 2.1 bundle output — generated without the stix2 library, using only Python’s json and uuid modules.
The local knowledge base stores analyst annotations persistently: tags, free-text notes, and watchlists keyed by IOC value. These survive sessions and are independent of the API cache.
Technical Architecture
1
2
3
4
5
6
7
8
9
10
11
12
13
vex/
├── main.py # CLI entrypoint, all subcommands
├── client.py # VirusTotal API client with rate limiting
├── async_client.py # Async client for parallel batch processing
├── ioc_detector.py # IOC type detection and defang/refang
├── models.py # Pydantic v2 data models
├── cache.py # SQLite cache with TTL
├── batch.py # Parallel batch processing (ThreadPoolExecutor)
├── enrichers/ # Per-IOC-type enricher modules
├── plugins/ # Plugin registry (Protocol-based)
├── mitre/ # ATT&CK mapping table and mapper
├── knowledge/ # Knowledge base (tags, notes, watchlists)
└── output/ # Formatters, CSV/JSON export, STIX 2.1
Rate limiting is a first-class concern: the VirusTotal free tier allows 4 requests per minute. The VTClient uses a token-bucket rate limiter and backs off 60 seconds on HTTP 429. The config priority chain follows the Twelve-Factor App principle: --api-key flag overrides everything, then VT_API_KEY environment variable, then ~/.vex/config.yaml, then defaults.
Bugs Found in Testing
The first end-to-end test after installation surfaced three issues immediately.
The build backend path in pyproject.toml was wrong — an internal setuptools path not intended as a public interface. pip install -e . failed with an import error. Fix: setuptools.build_meta, the correct PEP 517 backend.
The authors field had a GitHub URL in the email field — a PEP 621 schema violation that would have caused an error on PyPI publishing.
The --quiet flag wasn’t reaching the subcommands. It was on the global app callback, but in a Typer CLI with subcommands, global callback options don’t automatically propagate. Fix: add the flag explicitly to each subcommand signature.
None of these were visible from reading the code. They only showed up when the package was actually installed and run. That phase — install it like a user, run it like a user, see what breaks — turned out to be as important as writing the code itself.
Lessons Learned
Structured AI collaboration is a different skill from prompting. Using Claude Code for a project this size meant thinking about how to frame problems, not just what to ask. Bringing in a UX Agent for output review and a QM Agent for pre-release validation produced concrete improvements that a single-perspective review wouldn’t have found. The MeetUp format — multiple roles contributing to a single decision — is something I’ll keep using.
Understanding what’s built matters more than speed. The plugin architecture and the verdict system both took time to work through properly. Accepting working code without understanding it would have made the codebase opaque to me for any future changes. That’s not a trade-off worth making.
Error handling separates scripts from tools. HTTP 429, missing API keys, malformed input, premium endpoints on a free tier — all of these need explicit handling. Getting there required thinking through failure cases before they happened.
Packaging is its own domain. pyproject.toml, PEP 517 build backends, PEP 621 schema — these aren’t visible when you’re running scripts directly. Building an installable package that works correctly after pip install involves details that only become apparent when you actually install it.
What’s Next
Both async batch processing and plugin discovery are live in v1.1.0. Possible next steps: vex config --show for active configuration display, integration of VT Graph relationships, and an optional ATT&CK Navigator layer export from the attack_mappings data.
Try It
vex is available on PyPI. You need a free VirusTotal API key to get started.
1
2
3
pip install vex-ioc
vex config --set-api-key YOUR_VT_API_KEY
vex triage 275a021bbfb6489e54d471899f7db9d1663fc695ef2bb4b0b5b9a9b7b9a5c8a4
Source code and documentation: github.com/duathron/vex
