Build a SIEM-style security event platform. Ingest network logs, detect threats, and investigate incidents with fast time-range queries.
|
Note
|
This tutorial assumes you have a running Ferrosa cluster. If you have not set one up yet, follow the Getting Started guide first. You should be able to connect with cqlsh before continuing.
|
Create the keyspace
Security event data is write-heavy and time-sensitive. You are going to be ingesting thousands of events per second and querying them by time range. A replication factor of 3 ensures no event is lost, even during node failures.
CREATE KEYSPACE IF NOT EXISTS security
WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 3};
USE security;
Design the schema
A security monitoring platform needs four core tables: a primary event log (partitioned by date for time-range queries), a secondary view by source IP (for investigating specific hosts), a threat intelligence lookup table, and an incident tracker. Let’s build them one at a time.
Events table
This is your primary log. Every firewall event, IDS alert, and authentication attempt lands here. Partitioning by event_date means each day’s events live in their own partition, which keeps time-range queries fast and gives you a natural data lifecycle — old partitions can be archived or dropped.
CREATE TABLE IF NOT EXISTS events (
event_date date,
event_time timestamp,
event_id uuid,
source_ip text,
dest_ip text,
source_port int,
dest_port int,
protocol text,
event_type text,
severity text,
rule_id text,
description text,
raw_payload blob,
PRIMARY KEY ((event_date), event_time, event_id)
) WITH CLUSTERING ORDER BY (event_time DESC);
|
Note
|
Why partition by date? Security analysts work in time windows — "show me everything from the last hour" or "what happened yesterday between 2am and 4am." Date partitioning makes these queries hit a single partition (or at most two, if the window spans midnight). |
Events by source IP
When investigating a specific host, you need to pull all events from that IP quickly. This table uses a compound partition key of (source_ip, event_date) to keep per-IP, per-day data together:
source_ip text,
event_date date,
event_time timestamp,
event_id uuid,
event_type text,
severity text,
dest_ip text,
dest_port int,
PRIMARY KEY ((source_ip, event_date), event_time, event_id)
) WITH CLUSTERING ORDER BY (event_time DESC);
This is denormalization in action — the same event data appears in both tables, optimized for different query patterns. You write to both tables when an event arrives.
Threat intelligence table
This stores known indicators of compromise (IOCs): malicious IP addresses, domain names, file hashes. Your detection engine checks incoming events against this table to flag known threats.
indicator_type text,
indicator_value text,
threat_name text,
severity text,
source text,
first_seen timestamp,
last_seen timestamp,
tags set<text>,
PRIMARY KEY (indicator_type, indicator_value)
);
Partitioning by indicator_type (like "ip", "domain", or "hash") groups similar indicators together. Lookups by type and value are O(1).
Incidents table
When events are correlated into a confirmed security incident, the details go here. Each incident tracks its lifecycle from creation through investigation to resolution.
incident_id uuid PRIMARY KEY,
title text,
severity text,
status text,
created_at timestamp,
updated_at timestamp,
assigned_to text,
related_events list<uuid>,
notes text
);
The related_events column uses a list to store references to the event UUIDs that make up this incident.
Here is how the tables relate to each other:
| Table | Partition Key | Clustering Key | Serves |
|---|---|---|---|
events |
|
|
Time-range event queries |
events_by_source |
|
|
Per-IP investigation |
threat_intel |
|
|
IOC lookups |
incidents |
|
— |
Incident lifecycle tracking |
Two tables for the same events (with different partition keys) is a deliberate choice. In Ferrosa, you optimize for the read path by duplicating writes. The cost of writing twice is trivial compared to the cost of a slow investigation query during an active breach.
Your schema is ready — let’s fill it with security events.
Populate with sample data
Insert normal network traffic
First, some baseline activity that looks completely routine — web browsing, DNS queries, and internal service communication:
-- ── Normal Network Traffic ──
-- Normal web traffic
INSERT INTO events (event_date, event_time, event_id, source_ip, dest_ip, source_port, dest_port, protocol, event_type, severity, rule_id, description)
VALUES ('2026-03-17', '2026-03-17 08:01:00+0000', e1000000-0000-0000-0000-000000000001, '10.0.1.50', '93.184.216.34', 52431, 443, 'TCP', 'connection', 'info', 'NET-001', 'HTTPS connection to example.com');
INSERT INTO events (event_date, event_time, event_id, source_ip, dest_ip, source_port, dest_port, protocol, event_type, severity, rule_id, description)
VALUES ('2026-03-17', '2026-03-17 08:01:05+0000', e1000000-0000-0000-0000-000000000002, '10.0.1.50', '10.0.0.2', 49152, 53, 'UDP', 'dns_query', 'info', 'DNS-001', 'DNS lookup for internal.corp.local');
INSERT INTO events (event_date, event_time, event_id, source_ip, dest_ip, source_port, dest_port, protocol, event_type, severity, rule_id, description)
VALUES ('2026-03-17', '2026-03-17 08:05:00+0000', e1000000-0000-0000-0000-000000000003, '10.0.2.10', '10.0.3.20', 55000, 5432, 'TCP', 'connection', 'info', 'NET-001', 'Internal database connection from app server');
INSERT INTO events (event_date, event_time, event_id, source_ip, dest_ip, source_port, dest_port, protocol, event_type, severity, rule_id, description)
VALUES ('2026-03-17', '2026-03-17 08:10:00+0000', e1000000-0000-0000-0000-000000000004, '10.0.1.100', '10.0.0.1', 60123, 389, 'TCP', 'authentication', 'info', 'AUTH-001', 'Successful LDAP authentication for user jdoe');
INSERT INTO events (event_date, event_time, event_id, source_ip, dest_ip, source_port, dest_port, protocol, event_type, severity, rule_id, description)
VALUES ('2026-03-17', '2026-03-17 09:00:00+0000', e1000000-0000-0000-0000-000000000005, '10.0.1.75', '172.217.14.206', 51888, 443, 'TCP', 'connection', 'info', 'NET-001', 'HTTPS connection to google.com');
Insert SSH brute force attack
Now let’s simulate an attacker trying to brute-force SSH credentials. Notice the rapid succession of failed login attempts from the same source IP:
-- ── SSH Brute Force Attack ──
INSERT INTO events (event_date, event_time, event_id, source_ip, dest_ip, source_port, dest_port, protocol, event_type, severity, rule_id, description)
VALUES ('2026-03-17', '2026-03-17 10:15:01+0000', e2000000-0000-0000-0000-000000000001, '203.0.113.42', '10.0.1.5', 44001, 22, 'TCP', 'auth_failure', 'medium', 'SSH-001', 'Failed SSH login attempt: user root');
INSERT INTO events (event_date, event_time, event_id, source_ip, dest_ip, source_port, dest_port, protocol, event_type, severity, rule_id, description)
VALUES ('2026-03-17', '2026-03-17 10:15:03+0000', e2000000-0000-0000-0000-000000000002, '203.0.113.42', '10.0.1.5', 44002, 22, 'TCP', 'auth_failure', 'medium', 'SSH-001', 'Failed SSH login attempt: user admin');
INSERT INTO events (event_date, event_time, event_id, source_ip, dest_ip, source_port, dest_port, protocol, event_type, severity, rule_id, description)
VALUES ('2026-03-17', '2026-03-17 10:15:05+0000', e2000000-0000-0000-0000-000000000003, '203.0.113.42', '10.0.1.5', 44003, 22, 'TCP', 'auth_failure', 'medium', 'SSH-001', 'Failed SSH login attempt: user ubuntu');
INSERT INTO events (event_date, event_time, event_id, source_ip, dest_ip, source_port, dest_port, protocol, event_type, severity, rule_id, description)
VALUES ('2026-03-17', '2026-03-17 10:15:07+0000', e2000000-0000-0000-0000-000000000004, '203.0.113.42', '10.0.1.5', 44004, 22, 'TCP', 'auth_failure', 'high', 'SSH-002', 'SSH brute force detected: 4 failures in 6 seconds from 203.0.113.42');
INSERT INTO events (event_date, event_time, event_id, source_ip, dest_ip, source_port, dest_port, protocol, event_type, severity, rule_id, description)
VALUES ('2026-03-17', '2026-03-17 10:15:08+0000', e2000000-0000-0000-0000-000000000005, '203.0.113.42', '10.0.1.5', 44005, 22, 'TCP', 'auth_failure', 'medium', 'SSH-001', 'Failed SSH login attempt: user deploy');
-- ── Port Scan Activity ──
Insert port scan activity
VALUES ('2026-03-17', '2026-03-17 11:30:00+0000', e3000000-0000-0000-0000-000000000001, '198.51.100.77', '10.0.1.5', 33000, 21, 'TCP', 'port_scan', 'medium', 'SCAN-001', 'SYN scan detected on port 21 (FTP)');
INSERT INTO events (event_date, event_time, event_id, source_ip, dest_ip, source_port, dest_port, protocol, event_type, severity, rule_id, description)
VALUES ('2026-03-17', '2026-03-17 11:30:01+0000', e3000000-0000-0000-0000-000000000002, '198.51.100.77', '10.0.1.5', 33001, 23, 'TCP', 'port_scan', 'medium', 'SCAN-001', 'SYN scan detected on port 23 (Telnet)');
INSERT INTO events (event_date, event_time, event_id, source_ip, dest_ip, source_port, dest_port, protocol, event_type, severity, rule_id, description)
VALUES ('2026-03-17', '2026-03-17 11:30:02+0000', e3000000-0000-0000-0000-000000000003, '198.51.100.77', '10.0.1.5', 33002, 80, 'TCP', 'port_scan', 'low', 'SCAN-001', 'SYN scan detected on port 80 (HTTP)');
INSERT INTO events (event_date, event_time, event_id, source_ip, dest_ip, source_port, dest_port, protocol, event_type, severity, rule_id, description)
VALUES ('2026-03-17', '2026-03-17 11:30:03+0000', e3000000-0000-0000-0000-000000000004, '198.51.100.77', '10.0.1.5', 33003, 3389, 'TCP', 'port_scan', 'high', 'SCAN-002', 'SYN scan on port 3389 (RDP) -- high-value target port');
-- ── Malware Beacon and Lateral Movement ──
INSERT INTO events (event_date, event_time, event_id, source_ip, dest_ip, source_port, dest_port, protocol, event_type, severity, rule_id, description)
VALUES ('2026-03-17', '2026-03-17 13:00:00+0000', e4000000-0000-0000-0000-000000000001, '10.0.1.200', '45.33.32.156', 49200, 443, 'TCP', 'c2_beacon', 'critical', 'MAL-001', 'Outbound connection to known C2 server (Cobalt Strike)');
Insert malware beacon
This is the most dangerous pattern — an internal host communicating with a known command-and-control server at regular intervals:
INSERT INTO events (event_date, event_time, event_id, source_ip, dest_ip, source_port, dest_port, protocol, event_type, severity, rule_id, description)
VALUES ('2026-03-17', '2026-03-17 13:05:00+0000', e4000000-0000-0000-0000-000000000002, '10.0.1.200', '45.33.32.156', 49201, 443, 'TCP', 'c2_beacon', 'critical', 'MAL-001', 'Repeated beacon to C2 server -- 5 minute interval detected');
INSERT INTO events (event_date, event_time, event_id, source_ip, dest_ip, source_port, dest_port, protocol, event_type, severity, rule_id, description)
VALUES ('2026-03-17', '2026-03-17 13:10:00+0000', e4000000-0000-0000-0000-000000000003, '10.0.1.200', '45.33.32.156', 49202, 443, 'TCP', 'c2_beacon', 'critical', 'MAL-001', 'Third C2 beacon in 10 minutes -- confirmed periodic callback');
INSERT INTO events (event_date, event_time, event_id, source_ip, dest_ip, source_port, dest_port, protocol, event_type, severity, rule_id, description)
VALUES ('2026-03-17', '2026-03-17 13:12:00+0000', e4000000-0000-0000-0000-000000000004, '10.0.1.200', '10.0.2.10', 49300, 445, 'TCP', 'lateral_movement', 'critical', 'LAT-001', 'SMB connection from compromised host to internal app server');
INSERT INTO events (event_date, event_time, event_id, source_ip, dest_ip, source_port, dest_port, protocol, event_type, severity, rule_id, description)
VALUES ('2026-03-17', '2026-03-17 13:14:00+0000', e4000000-0000-0000-0000-000000000005, '10.0.1.200', '10.0.3.20', 49301, 445, 'TCP', 'lateral_movement', 'critical', 'LAT-001', 'SMB connection from compromised host to database server');
INSERT INTO events (event_date, event_time, event_id, source_ip, dest_ip, source_port, dest_port, protocol, event_type, severity, rule_id, description)
VALUES ('2026-03-17', '2026-03-17 14:00:00+0000', e4000000-0000-0000-0000-000000000006, '10.0.1.200', '45.33.32.156', 49203, 443, 'TCP', 'data_exfiltration', 'critical', 'EXFIL-001', 'Large outbound data transfer (450MB) to C2 server');
-- ── Events by Source (denormalized for IP investigation) ──
-- SSH brute force attacker
INSERT INTO events_by_source (source_ip, event_date, event_time, event_id, event_type, severity, dest_ip, dest_port)
VALUES ('203.0.113.42', '2026-03-17', '2026-03-17 10:15:01+0000', e2000000-0000-0000-0000-000000000001, 'auth_failure', 'medium', '10.0.1.5', 22);
Populate events_by_source for investigation
In a production system, your ingestion pipeline would write to both tables simultaneously. For this tutorial, let’s insert the key attack events into the source-IP view:
VALUES ('203.0.113.42', '2026-03-17', '2026-03-17 10:15:03+0000', e2000000-0000-0000-0000-000000000002, 'auth_failure', 'medium', '10.0.1.5', 22);
INSERT INTO events_by_source (source_ip, event_date, event_time, event_id, event_type, severity, dest_ip, dest_port)
VALUES ('203.0.113.42', '2026-03-17', '2026-03-17 10:15:05+0000', e2000000-0000-0000-0000-000000000003, 'auth_failure', 'medium', '10.0.1.5', 22);
INSERT INTO events_by_source (source_ip, event_date, event_time, event_id, event_type, severity, dest_ip, dest_port)
VALUES ('203.0.113.42', '2026-03-17', '2026-03-17 10:15:07+0000', e2000000-0000-0000-0000-000000000004, 'auth_failure', 'high', '10.0.1.5', 22);
-- Compromised internal host
INSERT INTO events_by_source (source_ip, event_date, event_time, event_id, event_type, severity, dest_ip, dest_port)
VALUES ('10.0.1.200', '2026-03-17', '2026-03-17 13:00:00+0000', e4000000-0000-0000-0000-000000000001, 'c2_beacon', 'critical', '45.33.32.156', 443);
INSERT INTO events_by_source (source_ip, event_date, event_time, event_id, event_type, severity, dest_ip, dest_port)
VALUES ('10.0.1.200', '2026-03-17', '2026-03-17 13:12:00+0000', e4000000-0000-0000-0000-000000000004, 'lateral_movement', 'critical', '10.0.2.10', 445);
INSERT INTO events_by_source (source_ip, event_date, event_time, event_id, event_type, severity, dest_ip, dest_port)
VALUES ('10.0.1.200', '2026-03-17', '2026-03-17 14:00:00+0000', e4000000-0000-0000-0000-000000000006, 'data_exfiltration', 'critical', '45.33.32.156', 443);
-- ── Threat Intelligence ──
INSERT INTO threat_intel (indicator_type, indicator_value, threat_name, severity, source, first_seen, last_seen, tags)
VALUES ('ip', '45.33.32.156', 'Cobalt Strike C2', 'critical', 'AlienVault OTX',
'2026-01-15 00:00:00+0000', '2026-03-17 13:00:00+0000',
{'cobalt-strike', 'c2', 'apt', 'beacon'});
Add threat intelligence
VALUES ('ip', '203.0.113.42', 'SSH Brute Force Botnet', 'medium', 'AbuseIPDB',
'2026-02-20 00:00:00+0000', '2026-03-17 10:15:00+0000',
{'brute-force', 'ssh', 'botnet'});
INSERT INTO threat_intel (indicator_type, indicator_value, threat_name, severity, source, first_seen, last_seen, tags)
VALUES ('ip', '198.51.100.77', 'Reconnaissance Scanner', 'low', 'Shodan',
'2026-03-10 00:00:00+0000', '2026-03-17 11:30:00+0000',
{'scanner', 'recon', 'port-scan'});
INSERT INTO threat_intel (indicator_type, indicator_value, threat_name, severity, source, first_seen, last_seen, tags)
VALUES ('domain', 'evil-payload.example.net', 'Malware Distribution', 'high', 'VirusTotal',
'2026-03-01 00:00:00+0000', '2026-03-16 00:00:00+0000',
{'malware', 'dropper', 'phishing'});
-- ── Incidents ──
Create an incident
INSERT INTO incidents (incident_id, title, severity, status, created_at, updated_at, assigned_to, related_events, notes)
VALUES (
f5a10000-0000-0000-0000-000000000001,
'Active C2 Communication from Workstation 10.0.1.200',
'critical',
'investigating',
'2026-03-17 13:05:30+0000',
'2026-03-17 14:10:00+0000',
'analyst-rivera',
[e4000000-0000-0000-0000-000000000001,
e4000000-0000-0000-0000-000000000002,
e4000000-0000-0000-0000-000000000003,
e4000000-0000-0000-0000-000000000004,
e4000000-0000-0000-0000-000000000005,
e4000000-0000-0000-0000-000000000006],
'Workstation 10.0.1.200 is communicating with known Cobalt Strike C2 at 45.33.32.156 on a 5-minute interval. Lateral movement to app server and DB server observed. 450MB data exfiltration detected. Immediate containment required.'
);
Query your data
View events in a time window
The most common SIEM query: "show me everything that happened in the last hour." Because we partition by date and cluster by time, this is a fast range scan:
-- View events in a time window
SELECT event_time, source_ip, dest_ip, event_type, severity, description
FROM events
WHERE event_date = '2026-03-17'
AND event_time >= '2026-03-17 13:00:00+0000'
This returns only the events within that hour, in reverse chronological order. You will see the C2 beacons, lateral movement, and data exfiltration — a clear attack timeline.
Investigate a suspicious source IP
When an alert fires on a specific IP, you need all its activity fast. The events_by_source table makes this a single-partition read:
-- Investigate the SSH brute force source IP
SELECT event_time, event_type, severity, dest_ip, dest_port
FROM events_by_source
The results clearly show a pattern: four SSH login failures in rapid succession against the same target, all on port 22. Textbook brute force.
Find high-severity events
During an active incident, analysts need to focus on the most critical events. Pull today’s events and filter for high and critical severity:
AND event_date = '2026-03-17';
-- Find high-severity events (pull today's events, filter in application)
SELECT event_time, source_ip, dest_ip, event_type, severity, description
In your application layer, filter for severity IN ('high', 'critical'). You will see the SSH brute force detection event, the port scan on RDP, all three C2 beacons, the lateral movement, and the data exfiltration — a clear escalation from reconnaissance to compromise to data theft.
|
Note
|
Why filter in the application? The severity column is not part of the primary key, so filtering on it in CQL would require ALLOW FILTERING. For a day’s worth of events that easily fits in memory, application-side filtering is cleaner. For very high-volume environments, consider a separate events_by_severity table.
|
Check all activity from the compromised host
Now the critical investigation — what has 10.0.1.200 been doing?
WHERE event_date = '2026-03-17'
LIMIT 50;
-- Check all activity from the compromised host
The timeline tells the whole story: C2 beacons at regular intervals, lateral movement to internal servers, and a large data exfiltration. This host is compromised and actively being used by an attacker.
Look up threat intelligence
Cross-reference the C2 server’s IP against your threat intel database:
FROM events_by_source
WHERE source_ip = '10.0.1.200'
AND event_date = '2026-03-17';
This confirms the IP is a known Cobalt Strike C2 server, first seen in January, reported by AlienVault OTX. The tags tell you exactly what you are dealing with.
Review the incident
Pull up the full incident record to see the investigation status and all related events:
SELECT threat_name, severity, source, first_seen, last_seen, tags
FROM threat_intel
WHERE indicator_type = 'ip'
AND indicator_value = '45.33.32.156';
The related_events list gives you direct references back to the individual events in the events table. In your application, you would loop through these UUIDs to build a complete attack timeline.
Correlate: find all events targeting the same destination as a known threat
Let’s see if the SSH brute force attacker (203.0.113.42) was also targeting other hosts. First, confirm what we know about them:
-- Review the incident
SELECT title, severity, status, assigned_to, related_events, notes
FROM incidents
WHERE incident_id = f5a10000-0000-0000-0000-000000000001;
The result confirms this IP is part of a known SSH brute force botnet. This is valuable context — it tells you the attack is automated, not targeted, which changes your response posture.
Search threat intel by type
To see all known malicious IPs in your threat database:
-- Look up threat intel for the SSH brute force IP
SELECT threat_name, severity, tags
FROM threat_intel
This returns all IP-based indicators, sorted by the indicator value. You could feed these into your firewall rules or use them to enrich incoming events in real time.
Look up domain-based threats
AND indicator_value = '203.0.113.42';
-- Search all IP-based threat intel
SELECT indicator_value, threat_name, severity, tags
Your detection engine can check every outbound DNS query against this table. If a host resolves evil-payload.example.net, you know immediately that it is trying to reach a known malware distribution site.
Update the incident with correlation findings
Good investigations connect the dots. Let’s update the incident to document what we found:
WHERE indicator_type = 'ip';
-- Look up domain-based threats
SELECT indicator_value, threat_name, severity, source, tags
FROM threat_intel
WHERE indicator_type = 'domain';
What you learned
You built a complete security event monitoring platform. Here is what you practiced:
-
Date-partitioned event logs. By partitioning on
event_date, time-range queries stay fast regardless of how much historical data you have. Each day is its own partition, which also makes data retention easy — drop old partitions when they expire. -
Dual-write denormalization. The
eventsandevents_by_sourcetables store the same events optimized for different access patterns. One serves time-range queries, the other serves IP-based investigations. In Ferrosa, you model for the query, not the entity. -
Compound partition keys. The
(source_ip, event_date)partition key onevents_by_sourceprevents any single IP from creating an unbounded partition. Even if one IP generates millions of events over its lifetime, each day’s data is bounded. -
Threat intel as a lookup table. Partitioning by
indicator_typeand clustering byindicator_valuegives you O(1) lookups for any IOC. Your detection engine can check every incoming event against this table with minimal latency. -
Collection types for relationships. The
list<uuid>on incidents andset<text>on threat intel let you store structured relationships without separate join tables. This keeps your read path simple — one query gets the full incident with all its related event IDs. -
Incident lifecycle tracking. Using
UPDATEto modify incident status and notes shows how Ferrosa handles mutable state alongside append-only event data. The incident evolves while the underlying events are immutable.
From here, you could add a blocked_ips table for automated response, a user_activity table to track authentication patterns per user, or an asset_inventory table to enrich events with context about which hosts are critical.