Back to all articles
Engineering

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.

Amirol AhmadAmirol Ahmad
March 11, 2026
8 min read
Share on X
Custom Threat Models: Build Detection Rules That Actually Fit Your Application

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:

ComponentWhat it does
Event TypeThe event to watch. Supports wildcards like auth.*
ConditionA JSONB filter applied to matching events
ThresholdHow many matches within the window trigger a detection
Time WindowThe rolling window in minutes to count matches
SeverityAlert 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:

PrefixResolves toExample
metadata.<key>event.metadata[key] (arbitrary depth)metadata.network_intelligence.is_vpn
actor.<key>event.actor[key]actor.email
Root fieldsTop-level event propertiesuser_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

OperatorAliasesUse for
equalseqExact string / number match
not_equalsneq, neExclusion
containsSubstring search (great for metadata.raw_log)
not_containsNegative substring
starts_withPrefix match
ends_withSuffix match
gt / ltgreater_than / less_thanNumeric comparisons
gte / ltegreater_than_or_equal / less_than_or_equalNumeric range
inValue is one of an array
not_inValue is not in an array
existsField is present and non-null
not_existsField is absent or null
regexCase-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:

  1. Go to Dashboard → Threat Models → New Rule
  2. Choose an event type (use the wildcard auth.* to catch all auth events)
  3. Set a threshold and time window
  4. Optionally add a condition using the visual Rule Builder
  5. 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.