Skip to content

Commit b86ea84

Browse files
EQL: Fix sequences with conditions involving keys and non-keys (#133134) (#133385)
1 parent 805fa51 commit b86ea84

File tree

7 files changed

+291
-16
lines changed

7 files changed

+291
-16
lines changed

docs/changelog/133134.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
pr: 133134
2+
summary: Fix sequences with conditions involving keys and non-keys
3+
area: EQL
4+
type: bug
5+
issues: []

x-pack/plugin/eql/qa/rest/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ apply plugin: 'elasticsearch.internal-test-artifact'
1212

1313
restResources {
1414
restApi {
15-
include '_common', 'bulk', 'indices', 'eql'
15+
include '_common', 'bulk', 'indices', 'eql', 'capabilities'
1616
}
1717
}
1818

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
---
2+
setup:
3+
- requires:
4+
test_runner_features: [ capabilities ]
5+
capabilities:
6+
- method: POST
7+
path: /{index}/_eql/search
8+
parameters: [ ]
9+
capabilities: [ filters_on_keys_fix ]
10+
reason: "Testing a recent fix"
11+
- do:
12+
indices.create:
13+
index: eql_test
14+
body:
15+
{
16+
"mappings": {
17+
"properties": {
18+
"@timestamp": {
19+
"type": "date"
20+
},
21+
"event": {
22+
"properties": {
23+
"type": {
24+
"type": "keyword",
25+
"ignore_above": 1024
26+
}
27+
}
28+
},
29+
"user": {
30+
"properties": {
31+
"domain": {
32+
"type": "keyword",
33+
"ignore_above": 1024
34+
},
35+
"name": {
36+
"type": "keyword",
37+
"fields": {
38+
"text": {
39+
"type": "match_only_text"
40+
}
41+
}
42+
}
43+
}
44+
},
45+
"winlog": {
46+
"dynamic": "true",
47+
"properties": {
48+
"computer_name": {
49+
"type": "keyword",
50+
"ignore_above": 1024
51+
}
52+
}
53+
},
54+
"source": {
55+
"properties": {
56+
"ip": {
57+
"type": "ip"
58+
}
59+
}
60+
}
61+
}
62+
}
63+
}
64+
- do:
65+
bulk:
66+
refresh: true
67+
body:
68+
- index:
69+
_index: eql_test
70+
_id: "1"
71+
- "winlog":
72+
"computer_name": "foo.bar.baz"
73+
"@timestamp": "2025-06-18T12:21:37.018Z"
74+
"event":
75+
"category": "authentication"
76+
"code": "5145"
77+
"user":
78+
"domain": "bar.baz"
79+
"name": "something"
80+
"source":
81+
"ip": "192.168.56.200"
82+
- index:
83+
_index: eql_test
84+
_id: "2"
85+
- "winlog":
86+
"computer_name": "foo.bar.baz"
87+
"@timestamp": "2025-06-18T12:21:37.093Z"
88+
"event":
89+
"category": "authentication"
90+
"user":
91+
"domain": "BAR.BAZ"
92+
"name": "foo$"
93+
"source":
94+
"ip": "192.168.56.200"
95+
96+
---
97+
"Test one join key":
98+
- do:
99+
eql.search:
100+
index: eql_test
101+
body:
102+
query: 'sequence by source.ip with maxspan=5s [any where event.code : "5145" and winlog.computer_name == "foo.bar.baz" ] [any where winlog.computer_name == "foo.bar.baz" and startswith(winlog.computer_name, substring(user.name, 0, -1)) ]'
103+
104+
- match: {timed_out: false}
105+
- match: {hits.total.value: 1}
106+
- match: {hits.total.relation: "eq"}
107+
108+
---
109+
"Test two join keys ":
110+
- do:
111+
eql.search:
112+
index: eql_test
113+
body:
114+
query: 'sequence by source.ip, winlog.computer_name with maxspan=5s [any where event.code : "5145" and winlog.computer_name == "foo.bar.baz" ] [any where winlog.computer_name == "foo.bar.baz" and startswith(winlog.computer_name, substring(user.name, 0, -1)) ]'
115+
116+
- match: {timed_out: false}
117+
- match: {hits.total.value: 1}
118+
- match: {hits.total.relation: "eq"}
119+

x-pack/plugin/eql/src/main/java/org/elasticsearch/xpack/eql/optimizer/Optimizer.java

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import org.elasticsearch.xpack.eql.session.Payload.Type;
2424
import org.elasticsearch.xpack.eql.util.MathUtils;
2525
import org.elasticsearch.xpack.eql.util.StringUtils;
26+
import org.elasticsearch.xpack.ql.expression.Attribute;
2627
import org.elasticsearch.xpack.ql.expression.Expression;
2728
import org.elasticsearch.xpack.ql.expression.FieldAttribute;
2829
import org.elasticsearch.xpack.ql.expression.Literal;
@@ -416,23 +417,29 @@ private static List<Constraint> detectKeyConstraints(Expression condition, Keyed
416417

417418
List<Expression> and = Predicates.splitAnd(condition);
418419
for (Expression exp : and) {
419-
// if there are no conjunction and at least one key matches, save the expression along with the key
420-
// and its ordinal so it can be replaced
421-
if (exp.anyMatch(Or.class::isInstance) == false) {
422-
// comparisons against variables are not done
423-
// hence why on the first key match, the expression is picked up
424-
exp.anyMatch(e -> {
425-
for (int i = 0; i < keys.size(); i++) {
426-
Expression key = keys.get(i);
427-
if (e.semanticEquals(key)) {
428-
constraints.add(new Constraint(exp, filter, i));
429-
return true;
430-
}
431-
}
432-
return false;
433-
});
420+
// if the expression only involves filter keys, it's simple enough (eg. there are no conjunction), and at least one key
421+
// matches, save the expression along with the key and its ordinal so it can be replaced
422+
if (exp.anyMatch(Or.class::isInstance)) {
423+
continue;
424+
}
425+
426+
// expressions that involve attributes other than the keys have to be discarded
427+
if (exp.anyMatch(x -> x instanceof Attribute && keys.stream().noneMatch(k -> x.semanticEquals(k)))) {
428+
continue;
434429
}
430+
431+
exp.anyMatch(e -> {
432+
for (int i = 0; i < keys.size(); i++) {
433+
Expression key = keys.get(i);
434+
if (e.semanticEquals(key)) {
435+
constraints.add(new Constraint(exp, filter, i));
436+
return true;
437+
}
438+
}
439+
return false;
440+
});
435441
}
442+
436443
return constraints;
437444
}
438445

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
package org.elasticsearch.xpack.eql.plugin;
9+
10+
import java.util.HashSet;
11+
import java.util.Set;
12+
13+
public final class EqlCapabilities {
14+
15+
private EqlCapabilities() {}
16+
17+
/** Fix bug on filters that include join keys https://github.com/elastic/elasticsearch/issues/133065 */
18+
private static final String FILTERS_ON_KEYS_FIX = "filters_on_keys_fix";
19+
20+
public static final Set<String> CAPABILITIES;
21+
static {
22+
HashSet<String> capabilities = new HashSet<>();
23+
capabilities.add(FILTERS_ON_KEYS_FIX);
24+
CAPABILITIES = Set.copyOf(capabilities);
25+
}
26+
}

x-pack/plugin/eql/src/main/java/org/elasticsearch/xpack/eql/plugin/RestEqlSearchAction.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929

3030
import java.io.IOException;
3131
import java.util.List;
32+
import java.util.Set;
3233

3334
import static org.elasticsearch.rest.RestRequest.Method.GET;
3435
import static org.elasticsearch.rest.RestRequest.Method.POST;
@@ -116,4 +117,9 @@ public void onFailure(Exception e) {
116117
public String getName() {
117118
return "eql_search";
118119
}
120+
121+
@Override
122+
public Set<String> supportedCapabilities() {
123+
return EqlCapabilities.CAPABILITIES;
124+
}
119125
}

x-pack/plugin/eql/src/test/java/org/elasticsearch/xpack/eql/optimizer/OptimizerTests.java

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import org.elasticsearch.xpack.eql.analysis.Analyzer;
1313
import org.elasticsearch.xpack.eql.analysis.PostAnalyzer;
1414
import org.elasticsearch.xpack.eql.analysis.PreAnalyzer;
15+
import org.elasticsearch.xpack.eql.expression.function.scalar.string.StartsWith;
1516
import org.elasticsearch.xpack.eql.expression.function.scalar.string.ToString;
1617
import org.elasticsearch.xpack.eql.parser.EqlParser;
1718
import org.elasticsearch.xpack.eql.plan.logical.AbstractJoin;
@@ -576,6 +577,113 @@ public void testQueryLevelTwoKeyConstraints() {
576577
assertEquals(ruleBCondition, filterCondition(query2.child().children().get(0)));
577578
}
578579

580+
/**
581+
* sequence
582+
* 1. filter startsWith(a, "foo") by a
583+
* 2. filter X by a
584+
* ==
585+
* 1. filter startsWith(a, "foo") by a
586+
* 2. filter startsWith(a, "foo") by a
587+
* \filter X
588+
*/
589+
public void testKeyConstraintWithFunction() {
590+
Attribute a = key("a");
591+
592+
Expression keyCondition = startsWithExp(a, new Literal(EMPTY, "foo", DataTypes.KEYWORD));
593+
Expression filter = equalsExpression();
594+
595+
KeyedFilter rule1 = keyedFilter(basicFilter(keyCondition), a);
596+
KeyedFilter rule2 = keyedFilter(basicFilter(filter), a);
597+
598+
AbstractJoin j = randomSequenceOrSample(rule1, rule2);
599+
600+
List<KeyedFilter> queries = j.queries();
601+
assertEquals(rule1, queries.get(0));
602+
assertEquals(keyCondition, filterCondition(queries.get(1).child()));
603+
assertEquals(filterCondition(rule2.child()), filterCondition(queries.get(1).child().children().get(0)));
604+
}
605+
606+
/**
607+
* sequence
608+
* 1. filter startsWith(a, b) by a
609+
* 2. filter X by a
610+
* ==
611+
* same
612+
*/
613+
public void testKeyConstraintWithNonKey() {
614+
Attribute a = key("a");
615+
616+
Expression keyCondition = startsWithExp(a, key("b"));
617+
Expression filter = equalsExpression();
618+
619+
KeyedFilter rule1 = keyedFilter(basicFilter(keyCondition), a);
620+
KeyedFilter rule2 = keyedFilter(basicFilter(filter), a);
621+
622+
AbstractJoin j = randomSequenceOrSample(rule1, rule2);
623+
624+
List<KeyedFilter> queries = j.queries();
625+
assertEquals(rule1, queries.get(0));
626+
assertEquals(rule2, queries.get(1));
627+
}
628+
629+
/**
630+
* sequence
631+
* 1. filter startsWith(a, b) and c > 10 by a, c
632+
* 2. filter X by a, c
633+
* ==
634+
* 1. filter startsWith(a, b) and c > 10 by a, c
635+
* 2. filter c > 10 by a, c
636+
* \filter X
637+
*/
638+
public void testKeyConstraintWithNonKeyPartialPropagation() {
639+
Attribute a = key("a");
640+
Attribute b = key("b");
641+
Attribute c = key("c");
642+
643+
GreaterThan gtExp = gtExpression(c);
644+
Expression keyCondition = new And(EMPTY, startsWithExp(a, b), gtExp);
645+
Expression filter = equalsExpression();
646+
647+
KeyedFilter rule1 = keyedFilter(basicFilter(keyCondition), a, c);
648+
KeyedFilter rule2 = keyedFilter(basicFilter(filter), a, c);
649+
650+
AbstractJoin j = randomSequenceOrSample(rule1, rule2);
651+
652+
List<KeyedFilter> queries = j.queries();
653+
assertEquals(rule1, queries.get(0));
654+
assertEquals(gtExp, filterCondition(queries.get(1).child()));
655+
assertEquals(filterCondition(rule2.child()), filterCondition(queries.get(1).child().children().get(0)));
656+
}
657+
658+
/**
659+
* sequence
660+
* 1. filter startsWith(a, b) by a, c
661+
* 2. filter X and c > 10 by a, c
662+
* ==
663+
* 1. filter c > 10 by a, c
664+
* \filter startsWith(a, b)
665+
* 2. filter X and c > 10 by a, c
666+
*/
667+
public void testKeyConstraintWithNonKeyPartialReversePropagation() {
668+
Attribute a = key("a");
669+
Attribute b = key("b");
670+
Attribute c = key("c");
671+
672+
GreaterThan gtExp = gtExpression(c);
673+
Expression keyCondition = startsWithExp(a, b);
674+
Expression filter = new And(EMPTY, equalsExpression(), gtExp);
675+
676+
KeyedFilter rule1 = keyedFilter(basicFilter(keyCondition), a, c);
677+
KeyedFilter rule2 = keyedFilter(basicFilter(filter), a, c);
678+
679+
AbstractJoin j = randomSequenceOrSample(rule1, rule2);
680+
681+
List<KeyedFilter> queries = j.queries();
682+
assertEquals(gtExp, filterCondition(queries.get(0).child()));
683+
assertEquals(filterCondition(rule1.child()), filterCondition(queries.get(0).child().children().get(0)));
684+
assertEquals(rule2, queries.get(1));
685+
}
686+
579687
/**
580688
* Key conditions inside a disjunction (OR) are ignored
581689
* <p>
@@ -817,4 +925,8 @@ private static Equals equalsExpression() {
817925
private static GreaterThan gtExpression(Attribute b) {
818926
return new GreaterThan(EMPTY, b, new Literal(EMPTY, 1, INTEGER), UTC);
819927
}
928+
929+
private static StartsWith startsWithExp(Expression a, Expression b) {
930+
return new StartsWith(EMPTY, a, b, randomBoolean());
931+
}
820932
}

0 commit comments

Comments
 (0)