Splunk Search Head – SPL Tutorial

Search Processing Language · Schritt für Schritt erklärt

CSIRT Edition
🎓
So funktioniert SPL in 5 Minuten
Du liest eine Query immer von links nach rechts – jeder Schritt verarbeitet das Ergebnis des vorherigen.
1
Die Suchleiste
Oben im Splunk-UI gibst du deine Query ein. Was vor der ersten | (Pipe) steht, ist die Basis-Suche – hier bestimmst du, welche Daten durchsucht werden.
index=windows EventCode=4625 earliest=-7d
2
Basis-Suche: Index + Filter
Jede Query beginnt mit Index, Zeitraum und Feld-Filtern. Das ist die schnellste Methode, Daten einzugrenzen.
ElementBeispielErklärung
indexindex=windowsWelche Datenquelle?
sourcetypesourcetype=WinEventLog:SecurityWelcher Log-Typ?
earliest/latestearliest=-24hWelcher Zeitraum?
Feldfilterstatus=500Welche Werte?
3
Der Pipe-Operator |
Hinter der Basis-Suche kommen Pipes. Jede | leitet das Ergebnis an den nächsten Befehl weiter – wie in der Unix-Kommandozeile.
-- So denkst du eine Query: -- "Nimm alle Windows-Logons (4624)" | "zähle sie pro User" | "zeige nur User mit mehr als 10 Logons" | "sortiere absteigend"
4
Deine erste Query – von Rohdaten zur Erkenntnis
Das Ziel ist immer: Rohdaten → gefiltert → aggregiert → beantwortet. Hier der klassische Aufbau:
① Datenquelle
index=windows
|
② Filtern
search EventCode=4625
|
③ Aggregieren
stats count by src_ip
|
④ Entscheiden
where count > 10
index=windows EventCode=4625 | stats count by src_ip, Account_Name | where count > 10 | sort -count | table src_ip, Account_Name, count

Die Basis-Suche & das Pipe |

Merke: Alles vor der ersten Pipe ist die Basis-Suche. Danach kommt | + Befehl. Wie Unix-Pipes.
Nur Basis-Suche (Rohdaten)
index=firewall action=blocked
Basis-Suche + eine Pipe
index=firewall action=blocked | stats count by src_ip
Verkettete Pipes
index=firewall action=blocked | stats count by src_ip | sort -count | head 10
Tipp: Filtere so früh wie möglich in der Pipe-Kette → weniger Events zu verarbeiten = schneller.

Implizites AND zwischen Termen

Mehrere Begriffe ohne Operator werden mit AND verknüpft. Splunk fügt das AND implizit ein.
Diese Queries sind äquivalent
-- implizites AND -- index=windows EventCode=4625 User=admin
-- explizites AND -- index=windows EventCode=4625 AND User=admin
Logische Operatoren
OperatorBeispielBedeutung
ANDerror AND timeoutBeide müssen vorkommen
ORerror OR warningEines muss vorkommen
NOTNOT errorDarf nicht vorkommen
( )(A OR B) AND CGruppierung / Vorrang
Beispiel mit Gruppierung
index=auth (failed OR error) AND NOT src_ip=10.0.0.1

Index – Datenquelle bestimmen

Der Index ist der primäre Filter. Ohne index= durchsucht Splunk alle Indizes, auf die du Zugriff hast — teuer!
Syntax-Varianten
-- Einzelner Index -- index=windows -- Mehrere Indizes (OR-Logik) -- index=windows OR index=linux -- Wildcard -- index=prod_* -- Alle Indizes (vermeiden!) -- index=*
Typische CSIRT-Indizes
Windows
index=windows
Firewall / NSM
index=network
EDR / Endpoint
index=edr
Auth / AD
index=auth
Index + Sourcetype kombinieren
index=windows sourcetype=WinEventLog:Security index=network sourcetype=cisco:asa

Metadata – Indizes & Sourcetypes entdecken

Was ist metadata? Ein spezieller SPL-Befehl, der nicht Events durchsucht, sondern die Metadaten der Indizes abfragt – z.B. welche Sourcetypes, Hosts oder Sources existieren, wann zuletzt Daten kamen, und wie viele Events vorhanden sind. Extrem schnell, da keine Event-Daten gelesen werden.
Alle verfügbaren Indizes anzeigen
-- Methode 1: über metadata (empfohlen) -- | metadata type=indexes | table title, totalCount, recentTime -- Methode 2: über eventcount -- | eventcount summarize=false index=* | dedup index | table index
Alle Sourcetypes eines Index anzeigen
-- Methode 1: über metadata (schnell, kein Event-Scan) -- | metadata type=sourcetypes index=botsv1 -- Methode 2: über stats (langsamer, liest Events) -- index=botsv1 | stats count by sourcetype
Alle Indizes mit ihren Sourcetypes (Gesamtübersicht)
| metadata type=sourcetypes index=* | eval last_seen=strftime(recentTime, "%Y-%m-%d %H:%M:%S") | table index, sourcetype, totalCount, last_seen | sort index
Metadata-Typen
type=ZeigtTypischer Use Case
indexesAlle IndizesWelche Datenquellen existieren?
sourcetypesAlle SourcetypesWelche Log-Formate sind vorhanden?
sourcesAlle Source-PfadeWoher kommen die Daten?
hostsAlle HostsWelche Systeme liefern Logs?
Tipp: metadata ist ideal, um sich in einer unbekannten Splunk-Umgebung schnell zurechtzufinden – z.B. bei BOTSv1/v2/v3 Datasets.

Zeitraum & Time Handling

-- Relativ (empfohlen) -- index=windows earliest=-24h latest=now index=windows earliest=-7d@d latest=@d -- Absolut (ISO) -- index=windows earliest="2025-06-01T00:00:00" latest="2025-06-11T23:59:59"
KürzelBedeutung
-1hLetzte Stunde
-24hLetzte 24 Stunden
-7d@d7 Tage, auf Tagesbeginn gerundet
@w0Anfang dieser Woche (Sonntag)
@monAnfang dieses Monats
Zeitkonvertierung in der Pipeline
-- Unix-Timestamp → lesbares Datum -- | eval human = strftime(_time, "%Y-%m-%d %H:%M:%S %Z") -- Datum-String → Unix-Timestamp -- | eval ts = strptime(date, "%d/%b/%Y:%H:%M:%S") -- Zeitdifferenz berechnen -- | eval minuten = round((end-start)/60, 2) -- Zeitgruppierung für Trends -- | bin _time span=5m | stats count by _time

Wichtige SPL-Befehle

BefehlBeschreibungBeispiel
statsAggregation: count, sum, avg, dc, valuesstats count by host
tableFelder als Tabelle ausgebentable _time, host, status
fieldsFelder ein-/ausschließenfields + host, status
whereFiltert nach Ausdruck auf Feldernwhere len(user) > 3
searchVolltext-Filter in der Pipelinesearch status=500
evalNeue Felder berechnen / transformiereneval gb=bytes/1GB
rexRegex-Extraktion auf Felderrex "user=(?<u>\w+)"
dedupDuplikate entfernendedup src_ip
sortSortieren (- = absteigend)sort -count, +host
head/tailErste / letzte N Ergebnissehead limit=20
renameFelder umbenennenrename data.user as user
top/rareHäufigste / seltenste Wertetop limit=10 useragent
timechartZeitbasierte Aggregation für Chartstimechart count by status
chartStatistik-Tabelle mit Splitchart avg(cpu) over host
lookupExterne Tabelle joinen (IOC)lookup ioc.csv ip OUTPUT cat
transactionEvents zu Sessions gruppierentransaction session maxspan=30m
eventstatsStatistiken als Felder hinzufügeneventstats avg(rt) by host
streamstatsLaufende Summen / Kumulativstreamstats sum(bytes) as total
binWerte in Buckets gruppierenbin _time span=5m
tstatsHigh-Perf. auf indizierten Felderntstats count where index=web
appendpipeSummenzeile ans Ergebnis anhängenappendpipe [stats sum(count)]
fillnullNull-Werte ersetzenfillnull value="N/A"
mvexpandMultivalue in Einzel-Eventsmvexpand recipients
inputlookupCSV als Datenquelle ladeninputlookup employees.csv
outputlookupErgebnisse in CSV schreibenoutputlookup results.csv

Praxis-Queries (CSIRT)

Brute-Force Logins erkennen
index=windows sourcetype=WinEventLog:Security EventCode=4625 | stats count by src_ip, Account_Name | where count > 10 | sort -count
PowerShell Execution mit Encoded Command
index=edr process_name="powershell.exe" CommandLine=*-EncodedCommand* | table _time, host, user, CommandLine | sort -_time
Lateral Movement via Remote Services (4648)
index=windows EventCode=4648 earliest=-1h | eval pair=src_user." → ".dest_host | stats count values(pair) as targets by src_host | where count > 3
IOC-Lookup gegen Threat Intel Liste
index=network sourcetype=firewall | lookup malicious_ips.csv dest_ip OUTPUT threat_category | where isnotnull(threat_category) | table _time, src_ip, dest_ip, threat_category
Neues Gerät im Netz (first seen)
index=network earliest=-30d | stats min(_time) as first_seen by src_mac, src_ip | where first_seen > relative_time(now(), "-24h") | convert ctime(first_seen)

Wildcards & Feldsuche

-- Wildcard am Ende (effizient) -- process_name=powershell* -- Wildcard in der Mitte (teurer) -- CommandLine=*mimikatz* -- Feld existiert / ist befüllt -- src_ip=* -- Feld nicht vorhanden -- NOT error_code=*
Performance: Wildcards am Anfang (*xyz) erzwingen einen Full-Scan. Wenn möglich vermeiden oder mit Index-Filterung kombinieren.

eval – Felder berechnen

-- String-Konkatenation -- | eval asset=host.":".src_ip -- Bedingte Logik -- | eval severity=if(count>100, "HIGH", "LOW") -- case()-Ausdruck (sauberer als verschachteltes if) -- | eval risk=case( score>80, "Critical", score>50, "Medium", 1==1, "Low" ) -- Coalesce – ersten nicht-null Wert zurückgeben -- | eval user=coalesce(username, email, "anonymous") -- Unix-Zeit zu lesbarem Datum -- | eval human_time=strftime(_time, "%Y-%m-%d %H:%M:%S") -- Datum-String zu Unix-Timestamp -- | eval epoch=strptime(date_field, "%d/%b/%Y:%H:%M:%S") -- Math: Bytes in GB -- | eval gb=bytes/1024/1024/1024 -- Standardabweichung für Ausreisser -- | eventstats avg(rt) as avg_rt, stdev(rt) as stdev_rt | where rt > (avg_rt + 2*stdev_rt)

String-Operationen

Konkatenation
| eval full_name = first_name . " " . last_name
Substring
| eval short = substr(word, 1, 3)
Regex Replacement (rex mode=sed)
| rex mode=sed field=msg "s/\s+/ /g" | rex mode=sed field=email "s/@/ [at] /"
replace – einfache Ersetzung
| replace "localhost" with "127.0.0.1" in host
Whitespace ignorieren beim Match
-- Erst extrahieren, dann vergleichen -- | rex field=MessageStatus "(?<status>\w+)" | eval ok=if(status="delivered", "yes", "no")
Multivalue-Felder
-- In einzelne Events aufsplitten -- | mvexpand recipients -- Bestimmten Index aus Multivalue -- | eval first = mvindex(items, 0) -- Mit Trennzeichen joinen -- | eval userlist = mvjoin(users, ", ") -- Multivalue-Felder filtern -- | eval errors = mvfilter(match(msgs, "ERROR"))

Filterung: where vs search

where – Ausdrücke & Funktionen
-- Feldnamen OHNE Anführungszeichen, KEINE Wildcards -- | where isnotnull(user) AND len(user) > 3 | where like(url, "%/api/%")
search – Keyword-Matching mit Wildcards
-- Strings werden als Literale behandelt, Wildcards erlaubt -- | search user=admin* status!=404
NOT vs != (wichtiger Unterschied!)
-- != schliesst NULL/leere Felder NICHT aus -- -- NOT schliesst auch leere Felder aus -- status!=500 -- 500er werden gefiltert, NULL bleibt drin NOT status=500 -- auch Events OHNE status-Feld werden gefiltert
Merke: != filtert nur explizite Werte. Mit NOT werden auch Events ohne das Feld ausgeschlossen. Für "alles außer X mit Feld" nimm !=; für "garantiert keine X" nimm NOT.

Analyse: Perzentile & Ausreisser

Perzentile
index=web | stats avg(rt) as avg_rt, median(rt) as p50_rt, perc95(rt) as p95_rt, perc99(rt) as p99_rt by endpoint
Ausreisser mit Standardabweichung
index=web | eventstats avg(rt) as avg_rt, stdev(rt) as stdev_rt | where rt > (avg_rt + 2*stdev_rt)
Statistische Verteilung
| stats count, dc(src_ip) as unique_visitors, values(useragent) as ua_list by endpoint

Lookup-Tabellen

CSV-Lookup zur Anreicherung
index=network | lookup threat_intel.csv ip as src_ip OUTPUT threat_level, category
Lookup als Datenquelle nutzen
| inputlookup employees.csv | search department="IT"
Ergebnisse in Lookup schreiben
index=web status=500 | stats count by src_ip | where count > 100 | outputlookup suspicious_ips.csv
Use Case: IOC-Liste als CSV anlegen und per lookup gegen Firewall-Logs joinen → sofortige Trefferanzeige.

Transaktionen

Session Grouping (teuer)
index=web | transaction session_id maxspan=30m maxpause=5m | where duration > 60 | table session_id, duration, eventcount
Alternative mit stats (schneller)
| stats min(_time) as start, max(_time) as end, count as events by session_id | eval duration = end - start | where duration > 60
Performance: transaction ist ressourcenintensiv. Nutze stats + min(_time) / max(_time) wenn möglich.

Mehrere Queries kombinieren

Subsearch
-- Ergebnisse einer Suche als Input für die Hauptsuche -- index=web user=[search index=vpn | dedup user | fields user] | stats count by user
Join
index=main | join type=left user [ search index=hr | stats count by user ]
Append
index=web status=500 | stats count as errors by host | append [ search index=web status=200 | stats count as success by host ]
Tipp: Subsearch-Ergebnisse werden auf 10.000 Events begrenzt (einstellbar mit format). Subsearch immer in eckige Klammern [ ] setzen!

Performance-Tipps

-- ① Früh filtern: Index + Sourcetype zuerst -- index=web sourcetype="access_log" status=500 statt: index=* | search sourcetype="access_log" -- ② Keine Wildcards am Anfang -- *error ✗ (Full-Scan) error* ✓ (Index-gefiltert) -- ③ fields-Kommando nutzen -- | fields host, status, response_time | stats avg(response_time) by host -- ④ tstats für indizierte Felder (schneller) -- | tstats count where index=web by host, status -- ⑤ Möglichst kurzen Zeitraum wählen -- earliest=-7d statt earliest=-30d -- ⑥ stats statt transaction verwenden -- | stats min(_time) as start by session -- ✓ schnell | transaction session -- ✗ langsam

Reguläre Ausdrücke (rex)

Felder extrahieren
-- Einfache Extraktion -- | rex field=_raw "user=(?<username>\w+)" -- Mehrere Felder auf einmal -- | rex field=url "\/api\/(?<version>v\d+)\/(?<endpoint>\w+)"
Mehrere Treffer pro Event (max_match)
| rex field=_raw max_match=0 "error_code=(?<codes>\d+)"
Häufige Regex-Patterns
# IP-Adresse: (?<ip>\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}) # E-Mail: (?<email>[\w.+-]+@[\w-]+\.\w+) # UUID: (?<uuid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}) # URL-Pfad: (?<path>\/[\w\/.-]+) # Domain aus URL: (?<domain>(?:https?:\/\/)?([\w.-]+)\/)

Tips & Tricks

Debugging
fieldsummary
|
Alerting
error_rate > 5%
|
Macro
`get_errors(index)`
Schnelle Feldübersicht
| fieldsummary -- Alle Felder + Stats | top limit=20 useragent -- Häufigste Werte | highlight error, warning, critical -- Keywords hervorheben
Alerting – Error Rate überwachen
index=web | bin _time span=5m | stats count(eval(status>=500)) as errors, count as total by _time | eval error_rate = round(errors/total*100, 2) | where error_rate > 5
Nützliche SPL-Idiome
-- Null-Werte ersetzen -- | fillnull value="N/A" username, email -- Multivalue deduplizieren & joinen -- | eval users = mvjoin(mvdedup(user_list), ", ") -- Bedingte Aggregation -- | stats count(eval(status="success")) as successes, count(eval(status="failure")) as failures -- dedup mit Sortierung (jüngsten Treffer behalten) -- | sort -_time | dedup src_ip -- rename für verschachtelte Felder vor eval -- | rename signals.ip_address as ip_addr | eval ip_addr = if(isnull(ip_addr), "unknown", ip_addr)