Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Detections

Patterns for writing detection rules where the logic lives in Python and the data reduction stays in SPL.

Every example runs against the babysoarus_demo index from scripts/seed-demo-data.sh, and every one ships with the app: they are the built-in entries under detections/ in the editor's file tree, there to read, run and copy.

Classify, Do Not Just Threshold

A threshold tells you something crossed a line. A classification tells you what happened, which is what the analyst needs.

index=babysoarus_demo sourcetype=babysoarus:auth action=failure
| stats dc(user) as users_targeted count as attempts values(user) as targets by src_ip
| exec inline="for e in events:
    users = int(e['users_targeted'])
    attempts = int(e['attempts'])
    ratio = attempts / users if users else attempts

    e['attempts_per_user'] = round(ratio, 2)
    if users >= 8 and ratio < 6:
        e['pattern'] = 'password spray'
        e['severity'] = 'high'
        e['action'] = 'block source, force reset for targeted accounts'
    elif ratio >= 6:
        e['pattern'] = 'brute force'
        e['severity'] = 'medium'
        e['action'] = 'lock the targeted account, check for success events'
    else:
        e['pattern'] = 'noise'

events = [e for e in events if e['pattern'] != 'noise']"
| table src_ip pattern severity users_targeted attempts attempts_per_user action

Password spray separated from brute force

Many accounts with few attempts each is spraying. One account with many attempts is brute force. Same source data, different response, and the rule says which.

Score with Reasons Attached

Numeric scores age badly because nobody remembers what went into them. Emit the reasons alongside.

index=babysoarus_demo sourcetype=babysoarus:proxy
| stats sum(bytes_out) as bytes_out count as requests
        values(http_user_agent) as agents by host, user, dest_host
| exec inline="import math

def entropy(text):
    counts = {}
    for ch in text:
        counts[ch] = counts.get(ch, 0) + 1
    return -sum((c/len(text)) * math.log2(c/len(text)) for c in counts.values())

SUSPICIOUS_AGENTS = ('python-requests', 'curl/', 'powershell')

out = []
for e in events:
    score, reasons = 0, []
    domain = e['dest_host']

    h = entropy(domain)
    if h > 3.6:
        score += 30; reasons.append(f'entropy {h:.2f}')

    longest = max(len(l) for l in domain.split('.'))
    if longest > 15:
        score += 25; reasons.append(f'{longest}-character label')

    mb = int(e['bytes_out']) / 1048576
    if mb > 50:
        score += 30; reasons.append(f'{mb:.0f} MB outbound')

    agents = e['agents'] if isinstance(e['agents'], list) else [e['agents']]
    if any(a.lower().startswith(SUSPICIOUS_AGENTS) for a in agents):
        score += 20; reasons.append('scripted user agent')

    if score >= 50:
        e.update(risk_score=score, reasons=reasons,
                 entropy=round(h, 2), megabytes_out=round(mb, 1))
        out.append(e)

events = out"
| sort - risk_score
| table host user dest_host risk_score reasons megabytes_out entropy

The reasons field arrives in Splunk as a multivalue field, so it renders as a readable list in the results and in the alert email.

Enrich While You Detect

Fetch context in the same search rather than maintaining a separate lookup refresh job. Call the API once per batch, never once per event.

index=babysoarus_demo sourcetype=babysoarus:proxy
| stats count by dest_host
| exec inline="
domains = [e['dest_host'] for e in events]

# One request for the whole batch.
r = http_request(
    'POST',
    'https://intel.example.internal/api/v2/bulk',
    headers={'Authorization': 'Bearer ' + context.get('intel_token', ''),
             'Content-Type': 'application/json'},
    body={'indicators': domains},
    timeout=15,
)

verdicts = {}
if r['status'] == 200:
    verdicts = {v['indicator']: v for v in json.loads(r['body'])['results']}

for e in events:
    verdict = verdicts.get(e['dest_host'], {})
    e['intel_verdict'] = verdict.get('verdict', 'unknown')
    e['intel_confidence'] = verdict.get('confidence', 0)
    e['first_seen'] = verdict.get('first_seen', '')

events = [e for e in events if e['intel_verdict'] in ('malicious', 'suspicious')]"

If the API is down, r['status'] is not 200, verdicts stays empty, and everything is marked unknown rather than the search failing. Decide deliberately whether an enrichment outage should fail the detection or degrade it.

Sequences and State

Some detections need to remember what came before. The namespace persists across micro-batches within a search, so it can.

index=babysoarus_demo sourcetype=babysoarus:auth
| sort 0 _time
| exec inline="
try:
    history
except NameError:
    history = {}
    findings = []

for e in events:
    user = e.get('user', '')
    state = history.setdefault(user, {'failures': 0, 'sources': set()})

    if e.get('action') == 'failure':
        state['failures'] += 1
        state['sources'].add(e.get('src_ip', ''))
    elif e.get('action') == 'success' and state['failures'] >= 10:
        findings.append({
            'user': user,
            'src_ip': e.get('src_ip', ''),
            'preceding_failures': state['failures'],
            'distinct_sources': len(state['sources']),
            'pattern': 'successful login after sustained failures',
            'severity': 'critical',
        })
        state['failures'] = 0

events = findings"

sort 0 _time matters: the events must reach the snippet in order for the sequence to mean anything.

Under distributed search this state is per indexer. When the sequence must span the whole estate, aggregate with stats first and run the snippet on the search head.

Suppression That Survives Review

Allow-lists written as SPL NOT clauses become unreadable. Written as data, they stay legible.

| exec inline="
SUPPRESSIONS = [
    # Vulnerability scanner, expected to touch everything.
    {'field': 'src_ip', 'equals': '10.20.0.51', 'owner': 'infra', 'expires': '2026-12-31'},
    # Backup service, high volume by design.
    {'field': 'user', 'equals': 'svc_backup', 'owner': 'platform', 'expires': '2026-09-30'},
    # First-party CDN.
    {'field': 'dest_host', 'endswith': '.cdn.example.com', 'owner': 'web', 'expires': '2027-01-31'},
]

import datetime
today = datetime.date.today().isoformat()

def suppressed(event):
    for rule in SUPPRESSIONS:
        if rule['expires'] < today:
            continue
        value = event.get(rule['field'], '')
        if 'equals' in rule and value == rule['equals']:
            return rule
        if 'endswith' in rule and value.endswith(rule['endswith']):
            return rule
    return None

kept = []
for e in events:
    rule = suppressed(e)
    if rule is None:
        kept.append(e)
    else:
        e['suppressed_by'] = rule['owner']
events = kept"

Each suppression has an owner and an expiry, and expired ones stop applying on their own. That is difficult to express in SPL and trivial here.

Decode What Is Hiding

Attackers encode. Base64 in a command line, hex in a DNS label, URL encoding in a proxy log.

index=babysoarus_demo sourcetype=babysoarus:proxy
| exec inline="import base64, binascii, urllib.parse, re

B64 = re.compile(r'[A-Za-z0-9+/]{20,}={0,2}')
KEYWORDS = ('powershell', 'invoke-', 'downloadstring', '/bin/sh', 'certutil')

out = []
for e in events:
    url = urllib.parse.unquote(e.get('uri', ''))
    decoded = []
    for candidate in B64.findall(url):
        try:
            text = base64.b64decode(candidate + '===').decode('utf-8', 'ignore')
        except (binascii.Error, ValueError):
            continue
        if any(k in text.lower() for k in KEYWORDS):
            decoded.append(text[:200])
    if decoded:
        e['decoded_payloads'] = decoded
        e['severity'] = 'critical'
        out.append(e)

events = out"

Turning It into an Alert

Save the search, schedule it, and attach the alert action if you want code to run over the results too:

[Exfiltration to high-entropy domain]
search = index=proxy | stats ... | exec inline="..."
cron_schedule = */15 * * * *
dispatch.earliest_time = -15m
dispatch.latest_time = now
enableSched = 1
counttype = number of events
relation = greater than
quantity = 0
action.execalert = 1
action.execalert.param.inline = for e in events: e['case_priority'] = 'P1' if int(e['risk_score']) > 70 else 'P2'

Testing a Detection

Feed it known-bad data and assert on the verdict:

| makeresults
| eval dest_host="kq3n8vhs2wpxl4td.exfil-node.top", bytes_out="119681422", requests="18"
| exec inline="..."
| eval test_passed = if(risk_score >= 50, "pass", "FAIL")

Put a handful of those in a saved search and you have a regression test that runs on a schedule and tells you when a detection has stopped working.