Building a Mini-SIEM Lab: Sysmon, Vector, Redpanda, ClickHouse & Grafana
Daniel Jeczen 4 min read February 22, 2026 #SIEM #Cybersecurity #ClickHouse #Vector #Grafana #PodmanBuilding a Security Information and Event Management (SIEM) system from scratch is one of the best ways to understand how those enterprise system works.
In this lab, we will build a modern log pipeline using Windows host and WSL2 (Debian). Before diving into the code, let’s understand core architecture of our pipeline:
- Ingestion (Sysmon & Vector): Gathering logs from the operating system is crucial step. Usually we gather logs from different system so we need to transform them into a schema format (ECS/OCSF)
- Buffering (Redpanda): A messaging queue that temporarily hold logs. This prevent database from crashing if a sudden spike in log volume occurs.
- Storage (ClickHouse): An OLAP Database which is adjusted to handling Real-Time Analytics. It is column-oriented and optimized for analyzing massive amounts of log data.
- Visualization (Grafana): The dashboard where security analysts run queries and visualize threats.
Let’s build it step-by-step
Phase 1: Ingestion (Sysmon & Vector)
First we need something to generate high-quality security logs. For that we can use Sysmon Windows service that logs process creation, network connection and more. We can use some already existing config e.g. SwitftOnSecurity
Open an elevated PowerShell prompt on your Windows machine and run:
# Download config to temp
Invoke-WebRequest -Uri https://raw.githubusercontent.com/SwiftOnSecurity/sysmon-config/master/sysmonconfig-export.xml -OutFile C:\Windows\Temp\sysmonconfig.xml
# Install sysmon with config
sysmon64.exe -accepteula -i C:\Windows\Temp\sysmonconfig.xmlNext step is installing Vector by DataDog to read those logs and send them to our pipeline. Note: You need to compile Vector from source to include PR #24305. If you are compiling from Windows, ensure you have LLVM and follow the Vector manual installation guide
Create your Vector configuration file. This config maps raw Windows Event Logs to the Elastic Common Schema (ECS) format, making the data clean and structured.
# vector.yaml
data_dir: C:\Users\USERNAME\AppData\Local\Temp\vector-data
api:
enabled: true
address: 127.0.0.1:8686
sources:
sysmon_logs:
type: windows_event_log
channels:
- Microsoft-Windows-Sysmon/Operational
- Application
- System
- Security
transforms:
remap_ecs:
type: remap
inputs:
- sysmon_logs
source: |
# Metadata
.ecs.version = "8.11.0"
.event.kind = "event"
.event.code = .event_id
.event.provider = .provider_name
.event.dataset = .channel
# Map Log Level
.log.level = .level
# Timestamp
.timestamp = parse_timestamp!(.timestamp, format: "%Y-%m-%dT%H:%M:%S.%fZ")
.@timestamp = .timestamp
# Host
.host.name = .computer
.host.os.family = "windows"
.host.os.platform = "windows"
# Event_data as primary source of truth
ed = .event_data
if .channel == "Microsoft-Windows-Sysmon/Operational" && !is_null(ed) {
.event.module = "sysmon"
# Sysmon Event 1: Process Creation
if .event_id == 1 {
.event.category = "process"
.event.type = ["start"]
.process.pid = to_int!(ed.ProcessId)
.process.entity_id = ed.ProcessGuid
.process.executable = ed.Image
.process.command_line = ed.CommandLine
.process.working_directory = ed.CurrentDirectory
.process.integrity = ed.IntegrityLevel
if !is_null(ed.Image) {
path_parts = split!(ed.Image, "\\")
.process.name = path_parts[-1]
}
# User Parsing
if !is_null(ed.User) {
user_parts = split!(ed.User, "\\")
if length(user_parts) == 2 {
.user.domain = user_parts[0]
.user.name = user_parts[1]
} else {
.user.name = ed.User
}
}
# Hash Parsing
if !is_null(ed.Hashes) {
hashes = parse_key_value!(ed.Hashes, key_value_delimiter: "=", field_delimiter: ",")
if !is_null(hashes.MD5) { .process.hash.md5 = hashes.MD5 }
if !is_null(hashes.SHA1) { .process.hash.sha1 = hashes.SHA1 }
if !is_null(hashes.SHA256) { .process.hash.sha256 = hashes.SHA256 }
if !is_null(hashes.IMPHASH) { .process.hash.imphash = hashes.IMPHASH }
}
# Parent Process
.process.parent.pid = to_int!(ed.ParentProcessId)
.process.parent.entity_id = ed.ParentProcessGuid
.process.parent.executable = ed.ParentImage
.process.parent.command_line = ed.ParentCommandLine
if !is_null(ed.ParentImage) {
parent_parts = split!(ed.ParentImage, "\\")
.process.parent.name = parent_parts[-1]
}
}
# Sysmon Event 3: Network Connection
if .event_id == 3 {
.event.category = "network"
.event.type = ["connection", "start"]
.source.ip = ed.SourceIp
.source.port = to_int!(ed.SourcePort)
.source.domain = ed.SourceHostname
.destination.ip = ed.DestinationIp
.destination.port = to_int!(ed.DestinationPort)
.destination.domain = ed.DestinationHostname
.network.transport = ed.Protocol
.process.pid = to_int!(ed.ProcessId)
.process.entity_id = ed.ProcessGuid
.process.executable = ed.Image
if !is_null(ed.Image) {
img_parts = split!(ed.Image, "\\")
.process.name = img_parts[-1]
}
if !is_null(ed.User) {
user_parts = split!(ed.User, "\\")
if length(user_parts) == 2 {
.user.domain = user_parts[0]
.user.name = user_parts[1]
}
}
}
} else {
# Handle standard Windows logs (Security, System, App)
if .channel == "Security" {
.event.category = "authentication"
if .event_id == 4624 {
.event.type = ["start", "authentication_success"]
.event.outcome = "success"
}
if .event_id == 4625 {
.event.type = ["start", "authentication_failure"]
.event.outcome = "failure"
}
}
if .channel == "System" { .event.category = "host" }
if .channel == "Application" { .event.category = "process" }
if !is_null(ed) {
if !is_null(ed.TargetUserName) { .user.name = ed.TargetUserName }
if !is_null(ed.TargetDomainName) { .user.domain = ed.TargetDomainName }
if !is_null(ed.SubjectUserName) && ed.SubjectUserName != "-" { .user.effective.name = ed.SubjectUserName }
if !is_null(ed.IpAddress) && ed.IpAddress != "-" { .source.ip = ed.IpAddress }
if !is_null(ed.IpPort) && ed.IpPort != "-" { .source.port = to_int(ed.IpPort) ?? null }
if !is_null(ed.ProcessName) && ed.ProcessName != "-" {
.process.executable = ed.ProcessName
p_parts = split!(ed.ProcessName, "\\")
.process.name = p_parts[-1]
}
if !is_null(ed.ProcessId) { .process.pid = to_int(ed.ProcessId) ?? null }
}
}
# Clean up raw fields to save bandwidth
del(.event_data)
del(.message)
del(.task)
del(.level_value)
del(.record_id)
del(.version)
# Send to Redpanda (Kafka)
sinks:
to_redpanda:
type: kafka
inputs:
- remap_ecs
bootstrap_servers: 127.0.0.1:29092
topic: event-logs
encoding:
codec: jsonPhase 2, 3 & 4: Buffering, Storage, UI
We will run our backend services in WSL2 (Debian) using Podman. Save following as docker-compose.yml
# docker-compose.yml
version: '3.8'
services:
redpanda:
image: docker.redpanda.com/redpandadata/redpanda:latest
container_name: redpanda
command:
- redpanda
- start
- --kafka-addr internal://0.0.0.0:9092,external://0.0.0.0:29092
# Advertise external to localhost so Windows Vector can reach it. Internal to redpanda:9092 so Clickhouse can reach it.
- --advertise-kafka-addr internal://redpanda:9092,external://127.0.0.1:29092
- --smp 1
- --memory 1G
- --mode dev-container
ports:
- "29092:29092"
- "9092:9092"
- "8081:8081"
- "9644:9644"
volumes:
- redpanda_data:/var/lib/redpanda/data
networks:
- siem_net
clickhouse:
image: docker.io/clickhouse/clickhouse-server:latest
container_name: clickhouse
environment:
- CLICKHOUSE_PASSWORD=siemlab
ports:
- "8123:8123" # HTTP port (Grafana uses this)
- "9000:9000" # Native client port
volumes:
- clickhouse_data:/var/lib/clickhouse
- ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql # Auto-initializes tables
depends_on:
- redpanda
networks:
- siem_net
grafana:
image: docker.io/grafana/grafana:latest
container_name: grafana
ports:
- "3000:3000"
environment:
# Automatically install the ClickHouse plugin
- GF_INSTALL_PLUGINS=grafana-clickhouse-datasource
volumes:
- grafana_data:/var/lib/grafana
depends_on:
- clickhouse
networks:
- siem_net
networks:
siem_net:
driver: bridge
volumes:
redpanda_data:
clickhouse_data:
grafana_data:RedPanda does not require additional configuration but ClickHouse and Grafana does.
Initializing Database
ClickHouse needs to know how to read the logs from Redpanda and where to store them. We do this using a three-step architecture.
- Target Table: Where the data permanently lives.
- Kafka Engine Table: A temporary consumer that pulls data from Redpanda
- Materialized View: The pipeline connecting the consumer to the target table.
Let’s save this as init-db.sql in the same directory as previous file so it will be initialized when database is initialized. Here ingestion is smaller than our Event source, but we can extend that according to our use case.
-- init-db.sql
CREATE DATABASE IF NOT EXISTS siem;
-- 1. Storage Table: The actual storage for your ECS logs
CREATE TABLE IF NOT EXISTS siem.logs
(
`@timestamp` DateTime64(3),
`event.kind` String,
`event.category` String,
`event.type` Array(String),
`event.code` UInt32,
`host.name` String,
`process.name` String,
`process.pid` UInt32,
`source.ip` String,
`destination.ip` String,
`raw_message` String -- Storing the raw JSON is great for students to dig into missing fields
) ENGINE = MergeTree()
ORDER BY `@timestamp`;
-- 2. Consumer Table: Pulls raw JSON from Redpanda.
CREATE TABLE IF NOT EXISTS siem.logs_queue
(
message String
) ENGINE = Kafka
SETTINGS kafka_broker_list = 'redpanda:9092',
kafka_topic_list = 'event-logs',
kafka_group_name = 'clickhouse_consumer_group',
kafka_format = 'JSONAsString';
-- 3. Pipeline: The Materialized View translating the Queue into Storage
CREATE MATERIALIZED VIEW IF NOT EXISTS siem.logs_mv TO siem.logs AS
SELECT
-- Safely parse timestamp, default to current time if missing so the pipeline doesn't break
ifNull(parseDateTime64BestEffortOrNull(JSONExtractString(message, '@timestamp'), 3), now64(3)) AS `@timestamp`,
JSONExtractString(message, 'event', 'kind') AS `event.kind`,
JSONExtractString(message, 'event', 'category') AS `event.category`,
-- Convert raw JSON array strings to actual ClickHouse Arrays
arrayMap(x -> replaceAll(x, '"', ''), JSONExtractArrayRaw(message, 'event', 'type')) AS `event.type`,
JSONExtractUInt(message, 'event', 'code') AS `event.code`,
JSONExtractString(message, 'host', 'name') AS `host.name`,
JSONExtractString(message, 'process', 'name') AS `process.name`,
JSONExtractUInt(message, 'process', 'pid') AS `process.pid`,
JSONExtractString(message, 'source', 'ip') AS `source.ip`,
JSONExtractString(message, 'destination', 'ip') AS `destination.ip`,
message AS `raw_message`
FROM siem.logs_queue;Now we need to install podman and podman-compose then start the stack
sudo apt install podman podman-compose
sudo podman-compose up -dTroubleshooting WSL2 & Podman Issues
When running Podman on WSL2, you might encounter a few known issue. Here is how to fix them:
Error: WARN: The cgroupv2 manager is set to systemd but there is no systemd user session available Fix: You need to enable systemd inside WSL2. Edit or create /etc/wsl.conf
[boot]
systemd=trueError: netavark: nftables error: nft did not return successfully while applying ruleset Fix: Switch your firewall to legacy iptables:
sudo update-alternatives --set iptables /usr/sbin/iptables-legacy
sudo update-alternatives --set ip6tables /usr/sbin/ip6tables-legacyIf that fails, force Podman to use iptables via its config
sudo mkdir -p /etc/containers
echo -e "[network]\nfirewall_driver = \"iptables\"" | sudo tee -a /etc/containers/containers.confStart Fresh If you mess up your configuration and need to wipe your state clean, run:
# Powershell
wsl --shutdown
# WSL2
podman system reset -f
sudo podman network prune -f
sudo podman-compose downPhase 4: UI and Threat Hunting with Grafana
If all services are running, it’s time to connect Grafana to ClickHouse
- Open browser and go to
http://localhost:3000 - Navigate to Connections > Data Sources > Add data source
- Select ClickHouse and fill in the details:
- Server Address: clickhouse
- Protocol: HTTP
- Secure (TLS): Off
- Username: default
- Password: siemlab
- Default Database (Advanced): siem
Click “Save & Test”. If it fails, check the logs with sudo podman logs grafana
Example Queries
Create a new dashboard, add a visualization, and try these queries to analyze your ingested data.
Query 1: Find Noisy Processes Great for establishing a baseline of normal activity on host
SELECT
`process.name`,
count() AS total_events
FROM siem.logs
WHERE $__timeFilter(`@timestamp`) AND `process.name` != ''
GROUP BY `process.name`
ORDER BY total_events DESC
LIMIT 10
Query 2: Raw Event Log View A tabular view of recent events
SELECT
`@timestamp` AS Time,
`host.name` AS Host,
`event.code` AS EventID,
`process.name` AS Process,
`source.ip` AS SourceIP,
`destination.ip` AS DestIP
FROM siem.logs
WHERE $__timeFilter(`@timestamp`)
ORDER BY `@timestamp` DESC
LIMIT 100
Now you have local SIEM processing real-time Windows logs.