4.3 ES|QL for Multi-Stage Security Analysis
Key Takeaways
ES|QL (Elasticsearch Query Language) utilizes an iterative, piped execution model (|) where tabular data flows sequentially from source extraction to final aggregation.
ES|QL runs on a dedicated compute engine in Elasticsearch that processes columnar blocks of values rather than one document at a time.
The DISSECT and GROK commands in ES|QL allow analysts to parse unstructured message fields on the fly during active investigations without modifying ingest pipelines.
In-stream data enrichment via the ENRICH command correlates telemetry directly with threat intelligence or asset classification tables within the query execution pipeline.
Post-aggregation filtering in ES|QL enables multi-stage threshold detection (such as identifying brute-force attacks across distinct source IPs) within a single query.
Traditional search queries in SIEM platforms operate on a single-pass document paradigm: filters isolate matching records, and aggregations compute metric summaries. However, complex security threat hunting requires multi-stage analytical workflows—filtering preliminary events, calculating derived values, parsing embedded strings, enriching records against threat intelligence, aggregating metrics across entities, and applying secondary thresholds to isolate malicious behaviors.
Introduced as a technical preview in Elastic Stack 8.11 and generally available since 8.14, ES|QL (Elasticsearch Query Language) introduces an expressive, pipe-based (|) query architecture powered by a ground-up vectorized compute engine. For security analysts, ES|QL unifies search, aggregation, and transformation into a single intuitive syntax, fundamentally streamlining advanced triage and hunt operations.
The ES|QL Vectorized Compute Architecture
Traditional Elasticsearch queries evaluate documents row-by-row, translating JSON query DSL objects into Lucene collector executions. In contrast, ES|QL operates on a vectorized execution model:
- Columnar Data Processing: Data is transferred from storage directly into memory structured as columns rather than complete JSON documents. Operations execute across column arrays, optimizing CPU cache locality.
- Block-at-a-Time Operators: Operators work on whole blocks of values instead of one document at a time, which reduces per-row overhead and lets the JVM use vectorized CPU instructions where it can.
- Pipelined Execution Pipeline: The pipe symbol (
|) passes tabular intermediate results from one processing command directly to the next. Shards execute early filtering and partial aggregations concurrently before passing condensed tabular streams to coordinating nodes.
Core ES|QL Commands for Security Operations
An ES|QL query begins with a source command (FROM) followed by a sequential series of processing commands separated by pipes (|):
ES|QL string literals always use double quotes ("failure"); single quotes are not valid string delimiters.
1. FROM — Target Data Definition
Specifies the data stream, index pattern, or alias to query. Metadata columns (such as _index or @timestamp) can be projected directly.
FROM logs-endpoint.events.*, logs-system.*
2. WHERE — Stage Filtering
Applies boolean filtering criteria. WHERE can be invoked at any stage: early in the pipe to prune raw documents at the shard level, or late in the pipe to filter aggregated metric outputs.
| WHERE event.category == "authentication" AND event.outcome == "failure"
3. STATS ... BY — Aggregation & Metrics
Computes statistical metrics (such as COUNT(*), SUM(), AVG(), COUNT_DISTINCT()) grouped by one or more categorical dimensions.
| STATS fail_count = COUNT(*), distinct_targets = COUNT_DISTINCT(user.name) BY source.ip
4. EVAL — Dynamic Column Calculation
Creates or transforms columns during query execution using mathematical expressions, string functions, or conditional logic (CASE).
| EVAL total_bytes = source.bytes + destination.bytes,
mb_transferred = total_bytes / (1024 * 1024)
5. DISSECT & GROK — In-Line Unstructured Parsing
Extracts structured fields from raw text columns (like message) on the fly. DISSECT uses delimiter-based pattern matching (ultra-fast), whereas GROK utilizes regular expressions.
| DISSECT message "%{src_ip} %{dst_ip} %{protocol} %{action}"
6. ENRICH — In-Stream Intelligence Correlation
Enriches streaming results against an existing Elasticsearch Enrich Policy, appending external threat intelligence, asset ownership, or vulnerability scores directly into the query result.
| ENRICH threat_intel_ipv4_policy ON source.ip WITH indicator_type, threat_score
7. SORT, LIMIT, KEEP, DROP — Output Shaping
KEEP: Retains only specified columns, discarding temporary parsing fields.DROP: Omits specific noisy or sensitive columns.SORT: Orders the output table by one or more columns ascending (ASC) or descending (DESC).LIMIT: Restricts output to a defined row count. If a query has noLIMIT, ES|QL applies a default of 1,000 rows, and no query returns more than 10,000 rows.
Practical Threat Hunting & SecOps Scenarios
Scenario 1: Detecting Distributed Password Spraying
In password spraying attacks, an adversary attempts authentication against numerous accounts from a single external IP address or small pool of IPs, keeping attempts per user low to evade per-account lockouts.
FROM logs-auth.*
| WHERE event.category == "authentication"
AND event.outcome == "failure"
AND @timestamp >= NOW() - 2 HOURS
| STATS
total_failures = COUNT(*),
targeted_accounts = COUNT_DISTINCT(user.name)
BY source.ip
| WHERE total_failures >= 25 AND targeted_accounts >= 5
| EVAL accounts_per_failure = targeted_accounts / (total_failures * 1.0)
| SORT total_failures DESC
| LIMIT 20
Analytical Walkthrough: The query filters failed authentication events over the past two hours, aggregates total failures and distinct usernames per source IP, applies a secondary WHERE stage to isolate IPs targeting at least 5 distinct users, calculates the account-to-failure ratio, and outputs the top 20 suspicious sources.
Scenario 2: Data Exfiltration Threat Hunting by Egress Volume
Identifying internal endpoints communicating with external IP addresses transferring abnormal outbound byte volumes:
FROM logs-network.*
| WHERE network.direction == "outbound"
AND destination.ip != null
AND NOT CIDR_MATCH(destination.ip, "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")
| EVAL egress_mb = destination.bytes / (1024 * 1024)
| STATS
total_egress_mb = SUM(egress_mb),
session_count = COUNT(*)
BY source.ip, destination.ip, destination.port
| WHERE total_egress_mb > 500
| SORT total_egress_mb DESC
| LIMIT 50
Analytical Walkthrough: Filters outbound network telemetry, excludes internal RFC 1918 subnets using the built-in CIDR_MATCH function, converts raw destination bytes to megabytes via EVAL, aggregates total volume and connection counts per IP pair, and retains only pairs exceeding 500 MB.
Scenario 3: In-Line Syslog Dissection with Threat Intel Enrichment
Triage of unparsed legacy firewall telemetry where key fields reside in a raw string message:
FROM logs-firewall.raw-*
| WHERE message IS NOT NULL AND @timestamp >= NOW() - 1 HOUR
| DISSECT message "%{ts} %{fw_host} %{action}: %{src_ip}:%{src_port} -> %{dst_ip}:%{dst_port}"
| WHERE action == "PERMIT"
| ENRICH threat_intel_ip_policy ON dst_ip WITH threat_family, confidence_score
| WHERE confidence_score >= 80
| KEEP ts, fw_host, src_ip, dst_ip, dst_port, threat_family, confidence_score
| SORT confidence_score DESC
| LIMIT 100
Analytical Walkthrough: The query pulls raw firewall messages, executes delimiter-based DISSECT to parse IPs and ports, filters for permitted connections, joins the parsed destination IP against a threat intelligence enrich policy, isolates high-confidence malicious destinations, and emits an executive triage table.
ES|QL Command Reference Guide
| Command | Syntax Prototype | SecOps Use Case |
|---|---|---|
| FROM | FROM <indices/streams> [METADATA <fields>] | Initializes the pipeline with target telemetry streams. |
| WHERE | WHERE <boolean_expression> | Filters records before or after aggregation stages. |
| STATS | STATS <metric = func()> ... BY <fields> | Aggregates event volume, unique entities, or totals. |
| EVAL | EVAL <new_col = expression> ... | Normalizes units, calculates ratios, or derives flags. |
| DISSECT | DISSECT <string_field> "<pattern>" | Parses fixed-pattern delimiter strings on the fly. |
| GROK | GROK <string_field> "<pattern>" | Parses complex or irregular regex strings on the fly. |
| ENRICH | ENRICH <policy> ON <key> WITH <fields> | Merges threat intel or asset metadata in-stream. |
| SORT | SORT <col> [ASC|DESC] [NULLS FIRST|LAST] | Ranks outputs by threat score, count, or bytes. |
| LIMIT | LIMIT <integer> | Caps intermediate or final tabular row returns. |
| KEEP / DROP | KEEP <cols...> | DROP <cols...> | Sanitizes and shapes table columns for reporting. |
A threat hunter needs to query failed logon attempts across all authentication data streams, calculate the count of failures per target username, filter for usernames with more than 15 failures, and return the top 10 targeted accounts sorted by failure volume. Which ES|QL query achieves this objective?
FROM logs-auth.* | WHERE event.outcome == "failure" | STATS fail_count = COUNT(*) BY user.name | WHERE fail_count > 15 | SORT fail_count DESC | LIMIT 10
SELECT user.name, COUNT() as fail_count FROM logs-auth. WHERE event.outcome = 'failure' GROUP BY user.name HAVING fail_count > 15 ORDER BY fail_count DESC LIMIT 10
FROM logs-auth.* | AGGREGATE COUNT(*) OVER user.name FILTER event.outcome == 'failure' | THRESHOLD 15 | TOP 10
FIND event.outcome: 'failure' IN logs-auth.* | BUCKET user.name | STATS count > 15 | SORT DESC | LIMIT 10
How does the ES|QL ENRICH command improve threat hunting efficiency compared to traditional ingest-time lookups?
It permanently rewrites historical documents in Elasticsearch cold storage with updated threat intelligence feeds
It automatically sends external DNS queries to public reputation databases during query execution
It executes an in-stream join between search results and an Elasticsearch enrich policy table directly inside the vectorized engine at query time
It decrypts encrypted SSL/TLS payload streams using private keys stored in the Kibana keystore
An analyst receives unparsed syslog records from an edge proxy where fields are separated by standard pipe delimiters (|). The analyst needs to extract client_ip and status_code on the fly in an ad-hoc ES|QL threat hunting query without using complex regular expressions. Which command is optimized for this fixed-delimiter extraction?
EXTRACT message USING REGEX
SPLIT message BY '|'
PARSE_COLUMN client_ip, status_code FROM message
DISSECT message "%{client_ip}|%{status_code}|%{remainder}"
Sections you finish are checked off in the contents.