From fad6c11ee5e31dc71bac5d2cdd835ef9158375b3 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Bempel Date: Mon, 12 Oct 2026 16:45:10 +0200 Subject: [PATCH 1/4] Add timeout check during Matches expression eval In case of very expensive regex matching (like a backtracking one) timeout is not evaluated and only at the end of the evaluation. Using a CharSequence wrapper we can interleave a timeout check while accessing char through CharSequence::charAt(). regex expression are also pre-compiled while before they was compiled for each evaluation. --- .../java/com/datadog/debugger/el/DSL.java | 3 +- .../el/JsonToExpressionConverter.java | 7 +- .../debugger/el/PrettyPrintVisitor.java | 6 +- .../el/expressions/MatchesExpression.java | 82 ++++++++++++++++++- .../el/expressions/MatchesExpressionTest.java | 28 +++++++ 5 files changed, 117 insertions(+), 9 deletions(-) diff --git a/dd-java-agent/agent-debugger/debugger-el/src/main/java/com/datadog/debugger/el/DSL.java b/dd-java-agent/agent-debugger/debugger-el/src/main/java/com/datadog/debugger/el/DSL.java index a66d8f1b1c5..d5a78943a52 100644 --- a/dd-java-agent/agent-debugger/debugger-el/src/main/java/com/datadog/debugger/el/DSL.java +++ b/dd-java-agent/agent-debugger/debugger-el/src/main/java/com/datadog/debugger/el/DSL.java @@ -203,8 +203,7 @@ public static ContainsExpression contains(ValueExpression target, ValueExpres return new ContainsExpression(target, value); } - public static StringPredicateExpression matches( - ValueExpression valueExpression, StringValue str) { + public static MatchesExpression matches(ValueExpression valueExpression, StringValue str) { return new MatchesExpression(valueExpression, str); } diff --git a/dd-java-agent/agent-debugger/debugger-el/src/main/java/com/datadog/debugger/el/JsonToExpressionConverter.java b/dd-java-agent/agent-debugger/debugger-el/src/main/java/com/datadog/debugger/el/JsonToExpressionConverter.java index b9cad5b1ed7..5d341e08f96 100644 --- a/dd-java-agent/agent-debugger/debugger-el/src/main/java/com/datadog/debugger/el/JsonToExpressionConverter.java +++ b/dd-java-agent/agent-debugger/debugger-el/src/main/java/com/datadog/debugger/el/JsonToExpressionConverter.java @@ -7,7 +7,6 @@ import com.datadog.debugger.el.expressions.BooleanExpression; import com.datadog.debugger.el.expressions.ContainsExpression; -import com.datadog.debugger.el.expressions.StringPredicateExpression; import com.datadog.debugger.el.expressions.ValueExpression; import com.datadog.debugger.el.values.StringValue; import com.squareup.moshi.JsonReader; @@ -433,9 +432,9 @@ public static ValueExpression asValueExpression(JsonReader reader) throws IOE } } - private static StringPredicateExpression createStringPredicateExpression( + private static BooleanExpression createStringPredicateExpression( JsonReader reader, - BiFunction, StringValue, StringPredicateExpression> predicateFunc) + BiFunction, StringValue, ? extends BooleanExpression> predicateFunc) throws IOException { JsonReader.Token token = reader.peek(); if (token != BEGIN_ARRAY) { @@ -445,7 +444,7 @@ private static StringPredicateExpression createStringPredicateExpression( reader.beginArray(); ValueExpression source = asValueExpression(reader); StringValue str = new StringValue(reader.nextString()); - StringPredicateExpression expr = predicateFunc.apply(source, str); + BooleanExpression expr = predicateFunc.apply(source, str); reader.endArray(); return expr; } diff --git a/dd-java-agent/agent-debugger/debugger-el/src/main/java/com/datadog/debugger/el/PrettyPrintVisitor.java b/dd-java-agent/agent-debugger/debugger-el/src/main/java/com/datadog/debugger/el/PrettyPrintVisitor.java index f0295f66de3..618d8f72fad 100644 --- a/dd-java-agent/agent-debugger/debugger-el/src/main/java/com/datadog/debugger/el/PrettyPrintVisitor.java +++ b/dd-java-agent/agent-debugger/debugger-el/src/main/java/com/datadog/debugger/el/PrettyPrintVisitor.java @@ -147,7 +147,11 @@ public String visit(LenExpression lenExpression) { @Override public String visit(MatchesExpression matchesExpression) { - return stringPredicateExpression(matchesExpression); + return "matches(" + + nullSafeAccept(matchesExpression.getSourceString()) + + ", " + + nullSafeAccept(matchesExpression.getStr()) + + ")"; } @Override diff --git a/dd-java-agent/agent-debugger/debugger-el/src/main/java/com/datadog/debugger/el/expressions/MatchesExpression.java b/dd-java-agent/agent-debugger/debugger-el/src/main/java/com/datadog/debugger/el/expressions/MatchesExpression.java index aab2b2762f4..71088612f52 100644 --- a/dd-java-agent/agent-debugger/debugger-el/src/main/java/com/datadog/debugger/el/expressions/MatchesExpression.java +++ b/dd-java-agent/agent-debugger/debugger-el/src/main/java/com/datadog/debugger/el/expressions/MatchesExpression.java @@ -1,15 +1,93 @@ package com.datadog.debugger.el.expressions; +import com.datadog.debugger.el.EvalContext; +import com.datadog.debugger.el.EvaluationException; +import com.datadog.debugger.el.PrettyPrintVisitor; +import com.datadog.debugger.el.Value; import com.datadog.debugger.el.Visitor; import com.datadog.debugger.el.values.StringValue; +import datadog.trace.bootstrap.debugger.util.TimeoutChecker; +import java.util.regex.Pattern; + +public class MatchesExpression implements BooleanExpression { + private final ValueExpression sourceString; + private final StringValue str; + private final Pattern pattern; -public class MatchesExpression extends StringPredicateExpression { public MatchesExpression(ValueExpression sourceString, StringValue str) { - super(sourceString, str, String::matches, "matches"); + this.sourceString = sourceString; + this.str = str; + pattern = str != null && str.getValue() != null ? Pattern.compile(str.getValue()) : null; } @Override public R accept(Visitor visitor) { return visitor.visit(this); } + + @Override + public Boolean evaluate(EvalContext evalContext) { + Value sourceValue = + sourceString != null ? sourceString.evaluate(evalContext) : Value.nullValue(); + if (sourceValue.isUndefined()) { + throw new EvaluationException( + "Cannot evaluate the expression for undefined value", PrettyPrintVisitor.print(this)); + } + if (sourceValue.isNull()) { + throw new EvaluationException( + "Cannot evaluate the expression for null value", PrettyPrintVisitor.print(this)); + } + if (pattern != null && sourceValue.getValue() instanceof String) { + String sourceStr = (String) sourceValue.getValue(); + TimeoutCharSequence timeoutCharSequence = + new TimeoutCharSequence(evalContext.getTimeoutChecker(), this, sourceStr); + boolean result = pattern.matcher(timeoutCharSequence).matches(); + ExpressionHelper.checkTimeout(evalContext.getTimeoutChecker(), this); + return result; + } + return Boolean.FALSE; + } + + public ValueExpression getSourceString() { + return sourceString; + } + + public StringValue getStr() { + return str; + } + + /** Wraps a String to check the timeout on each char access while the regex is matching. */ + private static class TimeoutCharSequence implements CharSequence { + private final TimeoutChecker timeoutChecker; + private final MatchesExpression matchesExpression; + private final String s; + + TimeoutCharSequence( + TimeoutChecker timeoutChecker, MatchesExpression matchesExpression, String s) { + this.timeoutChecker = timeoutChecker; + this.matchesExpression = matchesExpression; + this.s = s; + } + + @Override + public int length() { + return s.length(); + } + + @Override + public char charAt(int index) { + ExpressionHelper.checkTimeout(timeoutChecker, matchesExpression); + return s.charAt(index); + } + + @Override + public CharSequence subSequence(int start, int end) { + return s.subSequence(start, end); + } + + @Override + public String toString() { + return s; + } + } } diff --git a/dd-java-agent/agent-debugger/debugger-el/src/test/java/com/datadog/debugger/el/expressions/MatchesExpressionTest.java b/dd-java-agent/agent-debugger/debugger-el/src/test/java/com/datadog/debugger/el/expressions/MatchesExpressionTest.java index c7899fbd330..e12a61c23f3 100644 --- a/dd-java-agent/agent-debugger/debugger-el/src/test/java/com/datadog/debugger/el/expressions/MatchesExpressionTest.java +++ b/dd-java-agent/agent-debugger/debugger-el/src/test/java/com/datadog/debugger/el/expressions/MatchesExpressionTest.java @@ -5,14 +5,17 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; import static org.junit.jupiter.api.Assertions.assertTrue; import com.datadog.debugger.el.DSL; import com.datadog.debugger.el.EvalContext; import com.datadog.debugger.el.EvaluationException; +import com.datadog.debugger.el.EvaluationTimeOutException; import com.datadog.debugger.el.values.StringValue; import datadog.trace.bootstrap.debugger.el.Values; import java.net.URI; +import java.time.Duration; import org.junit.jupiter.api.Test; class MatchesExpressionTest { @@ -63,4 +66,29 @@ void stringPrimitives() { assertTrue(expression.evaluate(evalContext)); assertEquals("matches(uri, \"^https?://w{3}\\.datadoghq\\.com$\")", print(expression)); } + + @Test + void catastrophicBacktrackingTimeout() { + // nested quantifiers against a string of 'a's without trailing 'b' backtracks exponentially. + // the backreference prevents the JDK (9+) from memoizing the group loop, as it does for (a+)+b + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 40; i++) { + sb.append('a'); + } + sb.append('!'); + MatchesExpression expression = + new MatchesExpression(DSL.value(sb.toString()), new StringValue("((a+)\\2?)+b")); + // guard against the regex not being interrupted: without timeout check this would run forever + EvaluationException exception = + assertTimeoutPreemptively( + Duration.ofSeconds(5), + () -> { + // create the context in the evaluating thread (CPU timeout checker is per thread) + EvalContext timeoutEvalContext = createEvalContext(this, Duration.ofMillis(10)); + return assertThrows( + EvaluationTimeOutException.class, () -> expression.evaluate(timeoutEvalContext)); + }); + assertEquals("timeout (10ms)", exception.getMessage()); + assertEquals("matches(\"" + sb + "\", \"((a+)\\2?)+b\")", print(expression)); + } } From 8b80baae599bec86dc8574d8b7afce2701614240 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Bempel Date: Mon, 12 Oct 2026 18:39:00 +0200 Subject: [PATCH 2/4] amortize timeout check --- .../datadog/debugger/el/expressions/MatchesExpression.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dd-java-agent/agent-debugger/debugger-el/src/main/java/com/datadog/debugger/el/expressions/MatchesExpression.java b/dd-java-agent/agent-debugger/debugger-el/src/main/java/com/datadog/debugger/el/expressions/MatchesExpression.java index 71088612f52..3f0e34cccdf 100644 --- a/dd-java-agent/agent-debugger/debugger-el/src/main/java/com/datadog/debugger/el/expressions/MatchesExpression.java +++ b/dd-java-agent/agent-debugger/debugger-el/src/main/java/com/datadog/debugger/el/expressions/MatchesExpression.java @@ -61,6 +61,7 @@ private static class TimeoutCharSequence implements CharSequence { private final TimeoutChecker timeoutChecker; private final MatchesExpression matchesExpression; private final String s; + private int charAccessCount; TimeoutCharSequence( TimeoutChecker timeoutChecker, MatchesExpression matchesExpression, String s) { @@ -76,7 +77,10 @@ public int length() { @Override public char charAt(int index) { - ExpressionHelper.checkTimeout(timeoutChecker, matchesExpression); + if ((charAccessCount++ & 0xFF) == 0) { + // checks every 256 char accesses + ExpressionHelper.checkTimeout(timeoutChecker, matchesExpression); + } return s.charAt(index); } From c8bc037a8e6302347c7e41bf5ebbdf151ac7026d Mon Sep 17 00:00:00 2001 From: Jean-Philippe Bempel Date: Tue, 13 Oct 2026 14:50:17 +0200 Subject: [PATCH 3/4] increase amortization to 4096 --- .../datadog/debugger/el/expressions/MatchesExpression.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dd-java-agent/agent-debugger/debugger-el/src/main/java/com/datadog/debugger/el/expressions/MatchesExpression.java b/dd-java-agent/agent-debugger/debugger-el/src/main/java/com/datadog/debugger/el/expressions/MatchesExpression.java index 3f0e34cccdf..aa81bd72da7 100644 --- a/dd-java-agent/agent-debugger/debugger-el/src/main/java/com/datadog/debugger/el/expressions/MatchesExpression.java +++ b/dd-java-agent/agent-debugger/debugger-el/src/main/java/com/datadog/debugger/el/expressions/MatchesExpression.java @@ -77,8 +77,8 @@ public int length() { @Override public char charAt(int index) { - if ((charAccessCount++ & 0xFF) == 0) { - // checks every 256 char accesses + if ((charAccessCount++ & 0xFFF) == 0) { + // checks every 4096 char accesses ExpressionHelper.checkTimeout(timeoutChecker, matchesExpression); } return s.charAt(index); From 9572645e4db9efb12888764431383f6deb4290b9 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Bempel Date: Wed, 14 Oct 2026 08:50:19 +0200 Subject: [PATCH 4/4] Fix equals hashCode for matches --- .../el/expressions/MatchesExpression.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/dd-java-agent/agent-debugger/debugger-el/src/main/java/com/datadog/debugger/el/expressions/MatchesExpression.java b/dd-java-agent/agent-debugger/debugger-el/src/main/java/com/datadog/debugger/el/expressions/MatchesExpression.java index aa81bd72da7..f22f90ee5ed 100644 --- a/dd-java-agent/agent-debugger/debugger-el/src/main/java/com/datadog/debugger/el/expressions/MatchesExpression.java +++ b/dd-java-agent/agent-debugger/debugger-el/src/main/java/com/datadog/debugger/el/expressions/MatchesExpression.java @@ -2,11 +2,13 @@ import com.datadog.debugger.el.EvalContext; import com.datadog.debugger.el.EvaluationException; +import com.datadog.debugger.el.Generated; import com.datadog.debugger.el.PrettyPrintVisitor; import com.datadog.debugger.el.Value; import com.datadog.debugger.el.Visitor; import com.datadog.debugger.el.values.StringValue; import datadog.trace.bootstrap.debugger.util.TimeoutChecker; +import java.util.Objects; import java.util.regex.Pattern; public class MatchesExpression implements BooleanExpression { @@ -94,4 +96,18 @@ public String toString() { return s; } } + + @Generated + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + MatchesExpression that = (MatchesExpression) o; + return Objects.equals(sourceString, that.sourceString) && Objects.equals(str, that.str); + } + + @Generated + @Override + public int hashCode() { + return Objects.hash(sourceString, str); + } }