Custom Threat Models: Build Detection Rules That Actually Fit Your Application
A deep dive into LiteSOC's Enterprise Custom Threat Models — how FOLLOWED BY chains, metadata filters, and the condition engine let you detect threats that generic SIEMs miss entirely.

Every application has a unique threat surface. A fintech app fears large fund transfers to new accounts. A dev-tools platform fears bulk secret exports. A healthcare SaaS fears bulk patient record downloads from odd hours or unexpected geographies. Generic SIEM rules — written to cover every application — can't know any of that.
That's the problem Custom Threat Models solve.
What Are Custom Threat Models?
Custom Threat Models are Enterprise-tier detection rules you define in LiteSOC. Instead of relying only on LiteSOC's built-in detectors (impossible travel, brute force, etc.), you describe exactly what a threat looks like for your application. LiteSOC's worker evaluates every incoming event batch against your rules in real time — no query language to learn, no YAML to deploy.
Every rule has five core components:
| Component | What it does |
|---|---|
| Event Type | The event to watch. Supports wildcards like auth.* |
| Condition | A JSONB filter applied to matching events |
| Threshold | How many matches within the window trigger a detection |
| Time Window | The rolling window in minutes to count matches |
| Severity | Alert severity: critical, high, medium, or low |
The FOLLOWED BY Chain
The most powerful feature is the chained event. You can say:
"Fire an alert if I see 10 failed logins within 10 minutes, followed by a successful login within 15 minutes."
This is fundamentally different from a simple count threshold. It detects the narrative arc of a brute-force-to-account-takeover attack — not just the failed attempts, which could be a user forgetting their password.
When a chain fires, LiteSOC automatically upgrades the severity to CRITICAL and names the alert "Account Compromise Detected after Brute Force", regardless of what severity you configured. The system correlates the chained event to the same actor or IP that crossed the primary threshold. You don't have to do that join yourself.
time window (10 min)
◄─────────────────────────────►
10:00 auth.login_failed × 10 → threshold crossed
10:12 auth.login_success → chain matched ← CRITICAL alert fires
10:16 chain window expires (15 min TTL in Redis)
How the Redis Sentinel Works
When the primary threshold is crossed, LiteSOC writes a sentinel key to Redis:
custom_model_sentinel:{orgId}:{modelId}:{actorOrIp}
TTL = chain_time_window_minutes × 60 seconds
The next worker batch (runs every 30 seconds) checks for this sentinel when it sees a chained event type. If the key exists and the chained event matches the same actor or IP — the alert fires. The sentinel's TTL guarantees no state persists beyond the chain window. There is no cleanup job, no cron — expiry is atomic and guaranteed by Redis.
The Condition Engine
The condition is a JSONB object stored alongside your rule. Three formats are supported:
1. No condition — match everything
{}
Use this when the event type is enough signal. Every data.bulk_delete should be investigated regardless of context? Set threshold to 1, condition to {}.
2. Single filter
{
"field": "metadata.service",
"operator": "equals",
"value": "ssh"
}
3. AND / OR / NOT tree
{
"logical_operator": "AND",
"filters": [
{ "field": "metadata.network_intelligence.is_datacenter", "operator": "equals", "value": "true" },
{ "field": "metadata.service", "operator": "equals", "value": "ssh" }
]
}
The engine walks this tree recursively. AND requires all children to match. OR requires at least one. NOT negates the first child. There's no depth limit — you can nest OR groups inside AND groups for arbitrarily complex logic.
Field Paths
Condition filters reference fields using dot-notation paths:
| Prefix | Resolves to | Example |
|---|---|---|
metadata.<key> | event.metadata[key] (arbitrary depth) | metadata.network_intelligence.is_vpn |
actor.<key> | event.actor[key] | actor.email |
| Root fields | Top-level event properties | user_ip, event |
The metadata.* namespace is yours to populate with whatever context your application has. LiteSOC enriches it with network intelligence (is_vpn, is_tor, is_datacenter, network_type) and geolocation (country_code, city, isp) automatically during ingestion.
All Supported Operators
| Operator | Aliases | Use for |
|---|---|---|
equals | eq | Exact string / number match |
not_equals | neq, ne | Exclusion |
contains | — | Substring search (great for metadata.raw_log) |
not_contains | — | Negative substring |
starts_with | — | Prefix match |
ends_with | — | Suffix match |
gt / lt | greater_than / less_than | Numeric comparisons |
gte / lte | greater_than_or_equal / less_than_or_equal | Numeric range |
in | — | Value is one of an array |
not_in | — | Value is not in an array |
exists | — | Field is present and non-null |
not_exists | — | Field is absent or null |
regex | — | Case-insensitive regex (cached, never recompiled per event) |
A quick note on the regex operator: patterns are compiled once and cached in a module-level Map<string, RegExp>. Even if you're processing 10,000 events per batch, the same /(sqlmap|nikto|masscan)/i is never recompiled twice. This was a deliberate performance decision.
Performance: How Rules Scale
The naive implementation of "for every rule, scan every event" is O(rules × events). At 50 rules and 100 events per batch, that's 5,000 iterations — before any condition evaluation.
LiteSOC's worker builds a pre-filter index once per org batch, before the rule loop:
// Built once — O(events)
const exactTypeIndex = new Map<string, IngestionEvent[]>();
const prefixTypeIndex = new Map<string, IngestionEvent[]>();
for (const ev of orgEvents) {
const et = ev.event.toLowerCase();
exactTypeIndex.set(et, [...(exactTypeIndex.get(et) ?? []), ev]);
const prefix = et.slice(0, et.indexOf("."));
prefixTypeIndex.set(prefix, [...(prefixTypeIndex.get(prefix) ?? []), ev]);
}
// Rule loop — O(1) type lookup, then condition eval only on candidates
const candidates = rule.event_type.endsWith(".*")
? prefixTypeIndex.get(rule.event_type.slice(0, -2)) ?? []
: exactTypeIndex.get(rule.event_type) ?? [];
const matches = candidates.filter(ev => evaluateCondition(ev, rule.condition));
Instead of checking every event against every rule, each rule immediately gets only the events of the right type. Condition evaluation — the expensive part — only runs on actual candidates. In practice, most rules will evaluate against a handful of events even in a busy batch.
Six Rules Worth Adding Today
Here are production-ready rules you can copy directly into the Custom Threat Models builder.
1 — Compromised Account (FOLLOWED BY)
The canonical account takeover pattern.
{
"event_type": "auth.login_failed",
"condition": {},
"threshold": 10,
"time_window_minutes": 10,
"chained_event_type": "auth.login_success",
"chain_time_window_minutes": 15,
"severity": "high"
}
2 — Data Exfiltration from Non-Approved Country
{
"event_type": "data.*",
"condition": {
"logical_operator": "AND",
"filters": [
{ "field": "event", "operator": "contains", "value": "export" },
{
"field": "metadata.country_code",
"operator": "not_in",
"value": ["US", "GB", "DE", "CA", "AU"]
}
],
"group_by": "user_ip"
},
"threshold": 1,
"time_window_minutes": 60,
"severity": "critical"
}
3 — SSH Botnet (Datacenter Source)
Residential users don't SSH from AWS IP blocks. Automated attack tools do.
{
"event_type": "auth.login_failed",
"condition": {
"logical_operator": "AND",
"filters": [
{ "field": "metadata.service", "operator": "equals", "value": "ssh" },
{ "field": "metadata.network_intelligence.is_datacenter", "operator": "equals", "value": "true" }
],
"group_by": "user_ip"
},
"threshold": 5,
"time_window_minutes": 5,
"severity": "critical"
}
4 — VPN Access to Admin Endpoints
{
"event_type": "data.*",
"condition": {
"logical_operator": "AND",
"filters": [
{ "field": "metadata.endpoint", "operator": "starts_with", "value": "/api/v1/admin" },
{
"logical_operator": "OR",
"filters": [
{ "field": "metadata.network_intelligence.is_vpn", "operator": "equals", "value": "true" },
{ "field": "metadata.network_intelligence.is_tor", "operator": "equals", "value": "true" },
{ "field": "metadata.network_intelligence.is_proxy", "operator": "equals", "value": "true" }
]
}
]
},
"threshold": 1,
"time_window_minutes": 30,
"severity": "high"
}
5 — Privilege Escalation Spike
{
"event_type": "admin.*",
"condition": {
"logical_operator": "OR",
"filters": [
{ "field": "event", "operator": "contains", "value": "privilege_escalation" },
{ "field": "event", "operator": "contains", "value": "role.changed" },
{ "field": "event", "operator": "contains", "value": "permission.changed" }
],
"group_by": "actor.id"
},
"threshold": 3,
"time_window_minutes": 15,
"severity": "critical"
}
6 — Known Attack Tool Scanner
{
"event_type": "security.*",
"condition": {
"filters": [
{
"field": "metadata.user_agent",
"operator": "regex",
"value": "(sqlmap|nikto|masscan|zgrab|nuclei|nmap|burpsuite|zap)"
}
]
},
"threshold": 1,
"time_window_minutes": 60,
"severity": "high"
}
Common Pitfalls
Boolean values must be strings. The condition engine compares via String(actualValue) === String(expectedValue). Write "value": "true" not "value": true. This is a known quirk and is documented in the operator reference.
Empty not_in arrays always match. { "operator": "not_in", "value": [] } is trivially true — nothing is in an empty array. Add a guard condition or use exists instead.
regex with catastrophic backtracking. The engine silently returns false on a thrown RegExp error, so a malformed or pathological pattern won't crash the worker — but it also won't detect anything. Test regex patterns externally before deploying.
Enriched fields are always available. metadata.network_intelligence.* and geolocation fields are injected by LiteSOC during ingestion enrichment, before the worker runs. You don't need to send them — and you can't override them even if you try.
Getting Started
Custom Threat Models are available on the Enterprise plan. To create your first rule:
- Go to Dashboard → Threat Models → New Rule
- Choose an event type (use the wildcard
auth.*to catch all auth events) - Set a threshold and time window
- Optionally add a condition using the visual Rule Builder
- Save — the rule is active on the next worker cycle (within 30 seconds)
The full operator reference, field path guide, and more example rules are in the Enterprise documentation.
Stay Updated
Get the latest security insights and product updates delivered to your inbox. No spam, unsubscribe anytime.