In part one I built a KVM malware lab: PFSense routing everything through Mullvad, Remnux doing static triage and transparent TLS interception, a FLARE-VM Windows box for detonation, and Wazuh agents on every VM reporting to a manager on the home network.

That post ended with a working pipeline. This one starts with a problem the pipeline creates: Wazuh captures everything, and “everything” is a lot. Process creations, file integrity events, registry changes, network connections — across every VM, continuously. Most of it is noise. Buried in it are the indicators that actually matter: the hash of a dropped payload, the IP a sample beaconed to, the domain a loader resolved.

The lab is very good at generating that data. It does nothing to help you act on it. So I built the missing piece: Threat Meister, a terminal tool that pulls indicators out of Wazuh, enriches them against VirusTotal, cross-references them against my own malware catalog, scores them on combined reputation and behaviour, and writes a report — on a monthly schedule, unattended.

The whole thing is stdlib-only Python 3, MIT-licensed, and on GitHub.


The gap between a SIEM and an answer

A SIEM tells you what happened: host X wrote file Y, process Z opened a connection to 203.0.113.10. What it doesn’t tell you is whether any of that matters. For that you need reputation — is 203.0.113.10 known-bad? — and context — is that hash one I already analysed last month?

Answering those questions by hand is tedious and doesn’t scale. You’d be copy-pasting IPs into VirusTotal’s web UI one at a time, forgetting which ones you’d already checked, and never correlating a network indicator back to a sample sitting in your notes. It’s exactly the kind of repetitive, rule-based work that should be a script.

The design principle I settled on: combine two signals that are individually weak but jointly strong. VirusTotal reputation on its own misses novel infrastructure — a freshly-registered C2 domain has zero detections. Behavioural signal on its own (a strong Rita beacon, a high-severity Wazuh rule) is suggestive but not damning. Put them together and score them as one number, and the interesting things float to the top.


What it does

Three surfaces, all built around a single SQLite catalog as the source of truth.

Catalog + triage. Ingest a sample and it’s hashed (MD5/SHA-1/SHA-256 plus ssdeep and TLSH fuzzy hashes), scored for entropy, typed, and stored inert — renamed to its SHA-256, made non-executable, optionally zip-encrypted with the standard infected password so it can never run by accident. Static triage extracts strings and surfaces candidate URLs, IPs, and domains without ever executing the file.

Detection engineering. From a catalogued sample, scaffold a structured YARA rule seeded from its strongest strings, test it against the sample and the rest of the store for false positives, and bundle everything into a single .yar that drops straight into the Wazuh Active Response path from part one. It’ll also emit ClamAV hash signatures.

Threat intelligence. This is the part that closes the loop with the lab. Two directions:

  • Sample → intel: a sample’s own hash and its extracted C2 IOCs go out to VirusTotal; the resulting risk score reflects back onto the catalog record as a vt_score, a band tag, and a note.
  • Hunt → catalog: a hunt over Wazuh alerts that turns up a hash or host already in the catalog recognises it as known lab infrastructure and scores it higher — “this beacon destination matches the agenttesla sample you triaged in March.”

The monthly hunt

The core command reads indicators from the Wazuh alerts file, enriches them, and writes a Markdown report:

threat_meister hunt \
  --wazuh alerts.json \
  --rita beacons.csv \
  --unifi threats.csv \
  --report hunt-$(date +%F).md \
  --min-score 40

Indicators are de-duplicated across every source, so a host that shows up in a Wazuh alert, a Rita beacon record, and a UniFi CyberSecure alert is enriched once and tagged with all three. That multi-source agreement is itself a scoring signal.

Pulling from a remote manager

In the lab, the Wazuh manager runs on a separate box (192.168.2.137 in part one). The agents forward to it; it writes everything to one alerts.json. Threat Meister pulls that file over SSH rather than making you copy it by hand:

threat_meister hunt \
  --wazuh-ssh analyst@192.168.2.137:/var/ossec/logs/alerts/alerts.json \
  --report hunt-$(date +%F).md \
  --min-score 40

It shells out to the system ssh, so it inherits your keys, agent, and ~/.ssh/config. The alerts file is owned wazuh:wazuh (mode 640), so the SSH user needs to be in the wazuh group — or you pass --wazuh-sudo to read it with a narrowly-scoped sudo.

Respecting the free tier

VirusTotal’s free API key allows four lookups a minute and five hundred a day. Threat Meister takes that seriously:

  • A 7-day cache in SQLite means re-running a hunt within a week costs zero API calls. Month over month, most indicators repeat and come back free; the budget is only spent on genuinely new infrastructure.
  • When a hunt has more new indicators than the daily cap, nothing is silently dropped. Indicators are triaged by local signal first — multi-source agreement, Wazuh severity, beacon strength — and enriched highest-priority first, so if the budget runs out, the indicators you skip are the least interesting ones. The overflow is saved to a resume queue that the next run drains automatically. A 1,500-indicator hunt just spreads over a few days, with the scariest indicators checked on day one.

The real constraint turns out to be the rate limit, not the daily cap: at four lookups a minute, five hundred indicators takes about two hours of wall-clock. Fine for a job that runs at 6am on the first of the month.


How the scoring works

The score is a 0–100 number combining a reputation half (VirusTotal) with a behavioural half (Wazuh, Rita, UniFi, and the local catalog). The weighting is deliberately the most readable function in the codebase, because it’s the part worth tuning for your own environment.

Here’s a real finding from my lab that shows why the combination matters. The IP 1.1.1.1 — Cloudflare’s public DNS, zero VirusTotal detections, unambiguously clean — scored 58 (Elevated):

[ELEVATED]  58   1.1.1.1  (ip)
           VT: 0 malicious  Cloudflare, Inc. · AS13335
           • high-severity Wazuh rule (level 12)
           • strong Rita beacon score (0.92)
           • seen by BOTH Wazuh and Rita
           • UniFi CyberSecure severity: high
           • flagged by 3 independent sources
           • matches known lab sample (family=testfam)

A pure-reputation tool scores that zero and you never look at it. But three independent sensors flagged traffic to it, the beacon score is high, and — critically — it matched an IOC I’d previously extracted from a sample in my catalog. That’s the signal worth surfacing: not “VirusTotal says bad,” but “my own network’s behaviour says look closer.”

(To be clear, this was a synthetic test indicator, not Cloudflare doing anything wrong — it’s illustrating the scoring logic, which is exactly the point of running it against known-benign infrastructure first.)


Automating it

Arch and Pop!_OS both use systemd, so the monthly run is a user timer rather than cron. A small wrapper script:

#!/usr/bin/env bash
set -euo pipefail
export THREAT_MEISTER_ROOT="$HOME/threat_meister"
python3 "$HOME/githubrepos/threat_meister/src/threat_meister.py" hunt \
  --wazuh-ssh analyst@192.168.2.137:/var/ossec/logs/alerts/alerts.json \
  --report "$THREAT_MEISTER_ROOT/reports/hunt-$(date +%F).md" \
  --min-score 40

And the timer:

# ~/.config/systemd/user/threat-hunt.timer
[Timer]
OnCalendar=*-*-01 06:00:00
Persistent=true

[Install]
WantedBy=timers.target

Persistent=true matters — if the machine is off at 6am on the first, the hunt runs as soon as it’s back, rather than silently skipping the month. loginctl enable-linger lets it fire even when you’re not logged in.

One habit worth building: because the timer is silent by design, the report and the journal are your only signal that it ran. Reading journalctl --user -u threat-hunt.service after the first of the month becomes part of the routine.


What the lab actually produced

The first real run against my lab’s manager was quietly instructive. It collected fifteen unique indicators — and every one was a file hash. No IPs, no domains. That told me something about my own setup: the alerts were overwhelmingly FIM/file-integrity events, not network events, and the private-IP filter had correctly dropped all the internal 10.0.2.x and 192.168.x traffic before it ever reached VirusTotal. All fifteen came back clean.

That’s an anticlimactic result and exactly the right one. A monthly hunt that screams on day one is a lab on fire; a monthly hunt that comes back quiet, with a report you can skim in thirty seconds and file, is the whole point. The value isn’t in the dramatic month — it’s in having a cheap, repeatable, low-effort process that would catch the dramatic month if it came.


Design notes

A few decisions that shaped the tool, in case they’re useful for anything you’re building:

Private IPs never leave the network. RFC1918, loopback, and link-local addresses are filtered before any lookup. There’s no reason to tell a third party about your internal addressing, and on a lab where traffic exits via Mullvad, leaking 10.0.2.x to VirusTotal would be a small but real deanonymisation footgun.

The catalog is the single source of truth. YARA bundles, ClamAV signatures, and IOC exports are all generated from it. You edit the catalog and re-export; you never hand-edit a deployed artifact. That’s the same discipline that keeps the whole lab reproducible.

Read-only on its sources. The hunt never modifies Wazuh, Rita, or UniFi data. It reads, enriches, and writes its own report and its own store. Nothing it does can damage the pipeline that feeds it.

It degrades gracefully. Stdlib-only, with optional tools (radare2, ssdeep, TLSH, yara, clamscan) auto-detected and used when present. Missing one is a warning, not a failure.


Where this fits

Part one built the environment. This is the analysis layer that sits on top of it — the thing that turns a month of captured events into a signed report and a scored catalog. Neither replaces the real-time defences (the PFSense rules, the Wazuh Active Response, the ClamAV daemon); it’s a forensic and hunting layer that runs after the fact and surfaces what the signature-based defences didn’t already catch.

The code, setup scripts for both Arch and Debian/Ubuntu, the Wazuh wiring, and a command cheatsheet are all in the repository. It’s built as coursework and a portfolio piece, so it’s documented to be read as much as run.


Part two of a series. Part one covers building the KVM lab itself. Threat Meister is MIT-licensed and lives on GitHub.