diff --git a/dd-java-agent/instrumentation/play/play-appsec-2.5/src/main/java/datadog/trace/instrumentation/play25/appsec/BodyParserHelpers.java b/dd-java-agent/instrumentation/play/play-appsec-2.5/src/main/java/datadog/trace/instrumentation/play25/appsec/BodyParserHelpers.java index f68cb7fbad0..16f0309e4bc 100644 --- a/dd-java-agent/instrumentation/play/play-appsec-2.5/src/main/java/datadog/trace/instrumentation/play25/appsec/BodyParserHelpers.java +++ b/dd-java-agent/instrumentation/play/play-appsec-2.5/src/main/java/datadog/trace/instrumentation/play25/appsec/BodyParserHelpers.java @@ -19,6 +19,7 @@ import datadog.trace.api.gateway.RequestContextSlot; import datadog.trace.bootstrap.instrumentation.api.AgentSpan; import datadog.trace.bootstrap.instrumentation.api.AgentTracer; +import java.lang.reflect.Method; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -47,6 +48,21 @@ public class BodyParserHelpers { private static final Logger log = LoggerFactory.getLogger(BodyParserHelpers.class); public static final int MAX_RECURSION = 15; + // Cached via reflection to avoid embedding a hard binary reference to + // files():Lscala/collection/Seq; — the return type changed to + // Lscala/collection/immutable/Seq; in Scala 2.13 (Play 2.7+), which would + // cause muzzle to disable the instrumentation for Play 2.7. + private static final Method MULTIPART_FILES_METHOD; + + static { + Method m = null; + try { + m = MultipartFormData.class.getMethod("files"); + } catch (Exception ignored) { + } + MULTIPART_FILES_METHOD = m; + } + private static JFunction1< scala.collection.immutable.Map>, scala.collection.immutable.Map>> @@ -105,20 +121,91 @@ private static String handleText(String s) { private static MultipartFormData handleMultipartFormData(MultipartFormData data) { scala.collection.immutable.Map> mpfd = data.asFormUrlEncoded(); - - if (mpfd == null || mpfd.isEmpty()) { - return data; + BlockingException pendingBlock = null; + + if (mpfd != null && !mpfd.isEmpty()) { + try { + Object conv = tryConvertingScalaContainers(mpfd, MAX_CONVERSION_DEPTH); + handleArbitraryPostData(conv, "multipartFormData"); + } catch (BlockingException be) { + pendingBlock = be; + } catch (Exception e) { + log.debug("Error handling result of multipartFormData BodyParser", e); + } } try { - Object conv = tryConvertingScalaContainers(mpfd, MAX_CONVERSION_DEPTH); - handleArbitraryPostData(conv, "multipartFormData"); + if (MULTIPART_FILES_METHOD != null) { + Object files = MULTIPART_FILES_METHOD.invoke(data); + if (files instanceof scala.collection.Iterable) { + handleMultipartFilenames( + new ScalaIteratorAdapter(((scala.collection.Iterable) files).iterator())); + } + } + } catch (BlockingException be) { + if (pendingBlock == null) pendingBlock = be; } catch (Exception e) { - handleException(e, "Error handling result of multipartFormData BodyParser"); + log.debug("Error handling multipartFormData filenames", e); } + + if (pendingBlock != null) throw pendingBlock; return data; } + private static void handleMultipartFilenames(java.util.Iterator iterator) { + AgentSpan span = activeSpan(); + if (span == null) { + return; + } + RequestContext reqCtx = span.getRequestContext(); + if (reqCtx == null || reqCtx.getData(RequestContextSlot.APPSEC) == null) { + return; + } + + List filenames = collectFilenames(iterator); + if (filenames.isEmpty()) { + return; + } + + CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); + BiFunction, Flow> callback = + cbp.getCallback(EVENTS.requestFilesFilenames()); + if (callback == null) { + return; + } + executeFilenamesCallback(reqCtx, callback, filenames); + } + + static List collectFilenames(java.util.Iterator iterator) { + List filenames = new ArrayList<>(); + while (iterator.hasNext()) { + MultipartFormData.FilePart part = (MultipartFormData.FilePart) iterator.next(); + String filename = part.filename(); + if (filename != null && !filename.isEmpty()) { + filenames.add(filename); + } + } + return filenames; + } + + private static void executeFilenamesCallback( + RequestContext reqCtx, + BiFunction, Flow> callback, + List filenames) { + Flow flow = callback.apply(reqCtx, filenames); + Flow.Action action = flow.getAction(); + if (action instanceof Flow.Action.RequestBlockingAction) { + Flow.Action.RequestBlockingAction rba = (Flow.Action.RequestBlockingAction) action; + BlockResponseFunction brf = reqCtx.getBlockResponseFunction(); + if (brf != null) { + boolean success = brf.tryCommitBlockingResponse(reqCtx.getTraceSegment(), rba); + if (success) { + throw new BlockingException("Blocked request (multipart file upload)"); + } + } + } + } + public static Function1 getHandleJsonF() { return HANDLE_JSON; } @@ -302,4 +389,22 @@ private static Object jsNodeToJavaObject(JsonNode value, int maxRecursion) { return value.asText(""); } } + + static final class ScalaIteratorAdapter implements java.util.Iterator { + private final Iterator delegate; + + ScalaIteratorAdapter(Iterator delegate) { + this.delegate = delegate; + } + + @Override + public boolean hasNext() { + return delegate.hasNext(); + } + + @Override + public Object next() { + return delegate.next(); + } + } } diff --git a/dd-java-agent/instrumentation/play/play-appsec-2.5/src/main/java/datadog/trace/instrumentation/play25/appsec/FormUrlEncodedInstrumentation.java b/dd-java-agent/instrumentation/play/play-appsec-2.5/src/main/java/datadog/trace/instrumentation/play25/appsec/FormUrlEncodedInstrumentation.java index a241d8adf13..7f678338970 100644 --- a/dd-java-agent/instrumentation/play/play-appsec-2.5/src/main/java/datadog/trace/instrumentation/play25/appsec/FormUrlEncodedInstrumentation.java +++ b/dd-java-agent/instrumentation/play/play-appsec-2.5/src/main/java/datadog/trace/instrumentation/play25/appsec/FormUrlEncodedInstrumentation.java @@ -31,7 +31,7 @@ public String instrumentedType() { @Override public String[] helperClassNames() { return new String[] { - packageName + ".BodyParserHelpers", + packageName + ".BodyParserHelpers", packageName + ".BodyParserHelpers$ScalaIteratorAdapter", }; } diff --git a/dd-java-agent/instrumentation/play/play-appsec-2.5/src/main/java/datadog/trace/instrumentation/play25/appsec/PlayBodyParsersInstrumentation.java b/dd-java-agent/instrumentation/play/play-appsec-2.5/src/main/java/datadog/trace/instrumentation/play25/appsec/PlayBodyParsersInstrumentation.java index 9cb53468cbd..2b4b8ee09af 100644 --- a/dd-java-agent/instrumentation/play/play-appsec-2.5/src/main/java/datadog/trace/instrumentation/play25/appsec/PlayBodyParsersInstrumentation.java +++ b/dd-java-agent/instrumentation/play/play-appsec-2.5/src/main/java/datadog/trace/instrumentation/play25/appsec/PlayBodyParsersInstrumentation.java @@ -38,7 +38,7 @@ public String[] knownMatchingTypes() { @Override public String[] helperClassNames() { return new String[] { - packageName + ".BodyParserHelpers", + packageName + ".BodyParserHelpers", packageName + ".BodyParserHelpers$ScalaIteratorAdapter", }; } diff --git a/dd-java-agent/instrumentation/play/play-appsec-2.5/src/main/java/datadog/trace/instrumentation/play25/appsec/ResultsStatusInstrumentation.java b/dd-java-agent/instrumentation/play/play-appsec-2.5/src/main/java/datadog/trace/instrumentation/play25/appsec/ResultsStatusInstrumentation.java index b48aa3f18a1..bec92b9145c 100644 --- a/dd-java-agent/instrumentation/play/play-appsec-2.5/src/main/java/datadog/trace/instrumentation/play25/appsec/ResultsStatusInstrumentation.java +++ b/dd-java-agent/instrumentation/play/play-appsec-2.5/src/main/java/datadog/trace/instrumentation/play25/appsec/ResultsStatusInstrumentation.java @@ -28,7 +28,7 @@ public String instrumentedType() { @Override public String[] helperClassNames() { return new String[] { - packageName + ".BodyParserHelpers", + packageName + ".BodyParserHelpers", packageName + ".BodyParserHelpers$ScalaIteratorAdapter", }; } diff --git a/dd-java-agent/instrumentation/play/play-appsec-2.5/src/main/java/datadog/trace/instrumentation/play25/appsec/TolerantJsonInstrumentation.java b/dd-java-agent/instrumentation/play/play-appsec-2.5/src/main/java/datadog/trace/instrumentation/play25/appsec/TolerantJsonInstrumentation.java index 57009e0e7f4..183bd2b6a4c 100644 --- a/dd-java-agent/instrumentation/play/play-appsec-2.5/src/main/java/datadog/trace/instrumentation/play25/appsec/TolerantJsonInstrumentation.java +++ b/dd-java-agent/instrumentation/play/play-appsec-2.5/src/main/java/datadog/trace/instrumentation/play25/appsec/TolerantJsonInstrumentation.java @@ -35,7 +35,7 @@ public String instrumentedType() { @Override public String[] helperClassNames() { return new String[] { - packageName + ".BodyParserHelpers", + packageName + ".BodyParserHelpers", packageName + ".BodyParserHelpers$ScalaIteratorAdapter", }; } diff --git a/dd-java-agent/instrumentation/play/play-appsec-2.5/src/main/java/datadog/trace/instrumentation/play25/appsec/TolerantTextInstrumentation.java b/dd-java-agent/instrumentation/play/play-appsec-2.5/src/main/java/datadog/trace/instrumentation/play25/appsec/TolerantTextInstrumentation.java index dbc99ee28c1..f4729728264 100644 --- a/dd-java-agent/instrumentation/play/play-appsec-2.5/src/main/java/datadog/trace/instrumentation/play25/appsec/TolerantTextInstrumentation.java +++ b/dd-java-agent/instrumentation/play/play-appsec-2.5/src/main/java/datadog/trace/instrumentation/play25/appsec/TolerantTextInstrumentation.java @@ -30,7 +30,7 @@ public String instrumentedType() { @Override public String[] helperClassNames() { return new String[] { - packageName + ".BodyParserHelpers", + packageName + ".BodyParserHelpers", packageName + ".BodyParserHelpers$ScalaIteratorAdapter", }; } diff --git a/dd-java-agent/instrumentation/play/play-appsec-2.5/src/test/groovy/datadog/trace/instrumentation/play25/server/PlayServerTest.groovy b/dd-java-agent/instrumentation/play/play-appsec-2.5/src/test/groovy/datadog/trace/instrumentation/play25/server/PlayServerTest.groovy index b29d5709c28..c05a77b877f 100644 --- a/dd-java-agent/instrumentation/play/play-appsec-2.5/src/test/groovy/datadog/trace/instrumentation/play25/server/PlayServerTest.groovy +++ b/dd-java-agent/instrumentation/play/play-appsec-2.5/src/test/groovy/datadog/trace/instrumentation/play25/server/PlayServerTest.groovy @@ -83,6 +83,11 @@ class PlayServerTest extends HttpServerTest { true } + @Override + boolean testBodyFilenames() { + true + } + @Override boolean testBlocking() { true diff --git a/dd-java-agent/instrumentation/play/play-appsec-2.5/src/test/java/datadog/trace/instrumentation/play25/appsec/BodyParserHelpersTest.java b/dd-java-agent/instrumentation/play/play-appsec-2.5/src/test/java/datadog/trace/instrumentation/play25/appsec/BodyParserHelpersTest.java new file mode 100644 index 00000000000..e14f55c7d74 --- /dev/null +++ b/dd-java-agent/instrumentation/play/play-appsec-2.5/src/test/java/datadog/trace/instrumentation/play25/appsec/BodyParserHelpersTest.java @@ -0,0 +1,167 @@ +package datadog.trace.instrumentation.play25.appsec; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Method; +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import play.api.libs.json.JsValue; +import play.api.mvc.MultipartFormData; + +class BodyParserHelpersTest { + + private static JsValue parse(String json) { + return play.api.libs.json.Json$.MODULE$.parse(json); + } + + @Test + void jsValueToJavaObject_nullInputReturnsNull() { + assertNull(BodyParserHelpers.jsValueToJavaObject(null)); + } + + @Test + void jsValueToJavaObject_jsNullReturnsNull() { + assertNull(BodyParserHelpers.jsValueToJavaObject(parse("null"))); + } + + @Test + void jsValueToJavaObject_string() { + Object result = BodyParserHelpers.jsValueToJavaObject(parse("\"hello\"")); + assertEquals("hello", result); + } + + @Test + void jsValueToJavaObject_number() { + Object result = BodyParserHelpers.jsValueToJavaObject(parse("42")); + assertTrue(result instanceof BigDecimal); + assertEquals(0, ((BigDecimal) result).compareTo(new BigDecimal("42"))); + } + + @Test + void jsValueToJavaObject_booleanTrue() { + Object result = BodyParserHelpers.jsValueToJavaObject(parse("true")); + assertEquals(Boolean.TRUE, result); + } + + @Test + void jsValueToJavaObject_booleanFalse() { + Object result = BodyParserHelpers.jsValueToJavaObject(parse("false")); + assertEquals(Boolean.FALSE, result); + } + + @Test + @SuppressWarnings("unchecked") + void jsValueToJavaObject_object() { + Object result = BodyParserHelpers.jsValueToJavaObject(parse("{\"key\":\"value\",\"num\":1}")); + assertTrue(result instanceof Map); + Map map = (Map) result; + assertEquals("value", map.get("key")); + assertTrue(map.get("num") instanceof BigDecimal); + } + + @Test + @SuppressWarnings("unchecked") + void jsValueToJavaObject_array() { + Object result = BodyParserHelpers.jsValueToJavaObject(parse("[\"a\",\"b\",\"c\"]")); + assertTrue(result instanceof List); + List list = (List) result; + assertEquals(3, list.size()); + assertEquals("a", list.get(0)); + assertEquals("b", list.get(1)); + assertEquals("c", list.get(2)); + } + + @Test + @SuppressWarnings("unchecked") + void jsValueToJavaObject_nestedObject() { + Object result = + BodyParserHelpers.jsValueToJavaObject(parse("{\"outer\":{\"inner\":\"deep\"}}")); + assertTrue(result instanceof Map); + Map outer = (Map) result; + assertTrue(outer.get("outer") instanceof Map); + Map inner = (Map) outer.get("outer"); + assertEquals("deep", inner.get("inner")); + } + + @Test + void jsValueToJavaObject_zeroRecursionReturnsNull() { + Object result = BodyParserHelpers.jsValueToJavaObject(parse("{\"key\":\"value\"}"), 0); + assertNull(result); + } + + @Test + @SuppressWarnings("unchecked") + void jsValueToJavaObject_recursionLimitTruncatesNesting() { + // depth=1: outer object is converted, but children exceed the limit and become null + Object result = BodyParserHelpers.jsValueToJavaObject(parse("{\"a\":{\"b\":\"val\"}}"), 1); + assertTrue(result instanceof Map); + Map map = (Map) result; + assertNull(map.get("a")); + } + + // --- collectFilenames tests --- + + @Test + void collectFilenames_emptyIterator() { + List result = BodyParserHelpers.collectFilenames(Collections.emptyIterator()); + assertTrue(result.isEmpty()); + } + + @Test + void collectFilenames_nullFilenameExcluded() throws Exception { + List result = + BodyParserHelpers.collectFilenames( + Collections.singletonList(filePart("f", null)).iterator()); + assertTrue(result.isEmpty()); + } + + @Test + void collectFilenames_emptyFilenameExcluded() throws Exception { + List result = + BodyParserHelpers.collectFilenames( + Collections.singletonList(filePart("f", "")).iterator()); + assertTrue(result.isEmpty()); + } + + @Test + void collectFilenames_validFilenameIncluded() throws Exception { + List result = + BodyParserHelpers.collectFilenames( + Collections.singletonList(filePart("f", "evil.php")).iterator()); + assertEquals(Collections.singletonList("evil.php"), result); + } + + @Test + void collectFilenames_mixedPartsFiltered() throws Exception { + List parts = + Arrays.asList( + filePart("f1", "a.pdf"), + filePart("f2", null), + filePart("f3", ""), + filePart("f4", "b.jpg")); + List result = BodyParserHelpers.collectFilenames(parts.iterator()); + assertEquals(Arrays.asList("a.pdf", "b.jpg"), result); + } + + @SuppressWarnings("unchecked") + private static MultipartFormData.FilePart filePart(String key, String filename) + throws Exception { + // FilePart is a Scala case class nested in object MultipartFormData. + // Use the companion object's apply() to avoid JVM inner-class constructor complexity. + Class companionClass = Class.forName("play.api.mvc.MultipartFormData$FilePart$"); + Object companion = companionClass.getField("MODULE$").get(null); + for (Method m : companionClass.getMethods()) { + if ("apply".equals(m.getName()) && m.getParameterCount() == 4) { + return (MultipartFormData.FilePart) + m.invoke(companion, key, filename, scala.None$.MODULE$, new Object()); + } + } + throw new IllegalStateException("FilePart.apply(4 params) not found"); + } +} diff --git a/dd-java-agent/instrumentation/play/play-appsec-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/BodyParserHelpers.java b/dd-java-agent/instrumentation/play/play-appsec-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/BodyParserHelpers.java index e374e2ee0ac..c5004dc3bbb 100644 --- a/dd-java-agent/instrumentation/play/play-appsec-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/BodyParserHelpers.java +++ b/dd-java-agent/instrumentation/play/play-appsec-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/BodyParserHelpers.java @@ -19,6 +19,7 @@ import datadog.trace.api.gateway.RequestContextSlot; import datadog.trace.bootstrap.instrumentation.api.AgentSpan; import datadog.trace.bootstrap.instrumentation.api.AgentTracer; +import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -57,6 +58,21 @@ public class BodyParserHelpers { private static final Logger log = LoggerFactory.getLogger(BodyParserHelpers.class); public static final int MAX_RECURSION = 15; + // Cached via reflection to avoid embedding a hard binary reference to + // files():Lscala/collection/Seq; — the return type changed to + // Lscala/collection/immutable/Seq; in Scala 2.13 (Play 2.7+), which would + // cause muzzle to disable the instrumentation for Play 2.7. + private static final Method MULTIPART_FILES_METHOD; + + static { + Method m = null; + try { + m = MultipartFormData.class.getMethod("files"); + } catch (Exception ignored) { + } + MULTIPART_FILES_METHOD = m; + } + private static JFunction1< scala.collection.immutable.Map>, scala.collection.immutable.Map>> @@ -116,20 +132,91 @@ private static String handleText(String s) { private static MultipartFormData handleMultipartFormData(MultipartFormData data) { scala.collection.immutable.Map> mpfd = data.asFormUrlEncoded(); - - if (mpfd == null || mpfd.isEmpty()) { - return data; + BlockingException pendingBlock = null; + + if (mpfd != null && !mpfd.isEmpty()) { + try { + Object conv = tryConvertingScalaContainers(mpfd, MAX_CONVERSION_DEPTH); + handleArbitraryPostData(conv, "multipartFormData"); + } catch (BlockingException be) { + pendingBlock = be; + } catch (Exception e) { + log.debug("Error handling result of multipartFormData BodyParser", e); + } } try { - Object conv = tryConvertingScalaContainers(mpfd, MAX_CONVERSION_DEPTH); - handleArbitraryPostData(conv, "multipartFormData"); + if (MULTIPART_FILES_METHOD != null) { + Object files = MULTIPART_FILES_METHOD.invoke(data); + if (files instanceof scala.collection.Iterable) { + handleMultipartFilenames( + new ScalaIteratorAdapter(((scala.collection.Iterable) files).iterator())); + } + } + } catch (BlockingException be) { + if (pendingBlock == null) pendingBlock = be; } catch (Exception e) { - handleException(e, "Error handling result of multipartFormData BodyParser"); + log.debug("Error handling multipartFormData filenames", e); } + + if (pendingBlock != null) throw pendingBlock; return data; } + private static void handleMultipartFilenames(java.util.Iterator iterator) { + AgentSpan span = activeSpan(); + if (span == null) { + return; + } + RequestContext reqCtx = span.getRequestContext(); + if (reqCtx == null || reqCtx.getData(RequestContextSlot.APPSEC) == null) { + return; + } + + List filenames = collectFilenames(iterator); + if (filenames.isEmpty()) { + return; + } + + CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); + BiFunction, Flow> callback = + cbp.getCallback(EVENTS.requestFilesFilenames()); + if (callback == null) { + return; + } + executeFilenamesCallback(reqCtx, callback, filenames); + } + + static List collectFilenames(java.util.Iterator iterator) { + List filenames = new ArrayList<>(); + while (iterator.hasNext()) { + MultipartFormData.FilePart part = (MultipartFormData.FilePart) iterator.next(); + String filename = part.filename(); + if (filename != null && !filename.isEmpty()) { + filenames.add(filename); + } + } + return filenames; + } + + private static void executeFilenamesCallback( + RequestContext reqCtx, + BiFunction, Flow> callback, + List filenames) { + Flow flow = callback.apply(reqCtx, filenames); + Flow.Action action = flow.getAction(); + if (action instanceof Flow.Action.RequestBlockingAction) { + Flow.Action.RequestBlockingAction rba = (Flow.Action.RequestBlockingAction) action; + BlockResponseFunction brf = reqCtx.getBlockResponseFunction(); + if (brf != null) { + boolean success = brf.tryCommitBlockingResponse(reqCtx.getTraceSegment(), rba); + if (success) { + throw new BlockingException("Blocked request (multipart file upload)"); + } + } + } + } + public static Function1 getHandleJsonF() { return HANDLE_JSON; } @@ -450,4 +537,22 @@ private static Object convertW3cNode(org.w3c.dom.Node node, int maxRecursion) { } return null; } + + static final class ScalaIteratorAdapter implements java.util.Iterator { + private final Iterator delegate; + + ScalaIteratorAdapter(Iterator delegate) { + this.delegate = delegate; + } + + @Override + public boolean hasNext() { + return delegate.hasNext(); + } + + @Override + public Object next() { + return delegate.next(); + } + } } diff --git a/dd-java-agent/instrumentation/play/play-appsec-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/FormUrlEncodedInstrumentation.java b/dd-java-agent/instrumentation/play/play-appsec-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/FormUrlEncodedInstrumentation.java index 1fedaad2fb6..3833218e1d3 100644 --- a/dd-java-agent/instrumentation/play/play-appsec-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/FormUrlEncodedInstrumentation.java +++ b/dd-java-agent/instrumentation/play/play-appsec-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/FormUrlEncodedInstrumentation.java @@ -37,7 +37,7 @@ public String instrumentedType() { @Override public String[] helperClassNames() { return new String[] { - packageName + ".BodyParserHelpers", + packageName + ".BodyParserHelpers", packageName + ".BodyParserHelpers$ScalaIteratorAdapter", }; } diff --git a/dd-java-agent/instrumentation/play/play-appsec-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/PlayBodyParsersInstrumentation.java b/dd-java-agent/instrumentation/play/play-appsec-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/PlayBodyParsersInstrumentation.java index 6e92ff8eab8..afcc1950eda 100644 --- a/dd-java-agent/instrumentation/play/play-appsec-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/PlayBodyParsersInstrumentation.java +++ b/dd-java-agent/instrumentation/play/play-appsec-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/PlayBodyParsersInstrumentation.java @@ -45,7 +45,7 @@ public String[] knownMatchingTypes() { @Override public String[] helperClassNames() { return new String[] { - packageName + ".BodyParserHelpers", + packageName + ".BodyParserHelpers", packageName + ".BodyParserHelpers$ScalaIteratorAdapter", }; } diff --git a/dd-java-agent/instrumentation/play/play-appsec-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/ResultsStatusInstrumentation.java b/dd-java-agent/instrumentation/play/play-appsec-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/ResultsStatusInstrumentation.java index 248c5a6403f..659a47a02bf 100644 --- a/dd-java-agent/instrumentation/play/play-appsec-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/ResultsStatusInstrumentation.java +++ b/dd-java-agent/instrumentation/play/play-appsec-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/ResultsStatusInstrumentation.java @@ -48,7 +48,7 @@ public String instrumentedType() { @Override public String[] helperClassNames() { return new String[] { - packageName + ".BodyParserHelpers", + packageName + ".BodyParserHelpers", packageName + ".BodyParserHelpers$ScalaIteratorAdapter", }; } diff --git a/dd-java-agent/instrumentation/play/play-appsec-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/TolerantJsonInstrumentation.java b/dd-java-agent/instrumentation/play/play-appsec-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/TolerantJsonInstrumentation.java index bac21e81579..865fb87399f 100644 --- a/dd-java-agent/instrumentation/play/play-appsec-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/TolerantJsonInstrumentation.java +++ b/dd-java-agent/instrumentation/play/play-appsec-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/TolerantJsonInstrumentation.java @@ -46,7 +46,7 @@ public String instrumentedType() { @Override public String[] helperClassNames() { return new String[] { - packageName + ".BodyParserHelpers", + packageName + ".BodyParserHelpers", packageName + ".BodyParserHelpers$ScalaIteratorAdapter", }; } diff --git a/dd-java-agent/instrumentation/play/play-appsec-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/TolerantTextInstrumentation.java b/dd-java-agent/instrumentation/play/play-appsec-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/TolerantTextInstrumentation.java index e10524cbcfc..d87ff023af2 100644 --- a/dd-java-agent/instrumentation/play/play-appsec-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/TolerantTextInstrumentation.java +++ b/dd-java-agent/instrumentation/play/play-appsec-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/TolerantTextInstrumentation.java @@ -43,7 +43,7 @@ public String instrumentedType() { @Override public String[] helperClassNames() { return new String[] { - packageName + ".BodyParserHelpers", + packageName + ".BodyParserHelpers", packageName + ".BodyParserHelpers$ScalaIteratorAdapter", }; } diff --git a/dd-java-agent/instrumentation/play/play-appsec-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/TolerantXmlInstrumentation.java b/dd-java-agent/instrumentation/play/play-appsec-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/TolerantXmlInstrumentation.java index 8c0fb1c27ad..8b4787f2f69 100644 --- a/dd-java-agent/instrumentation/play/play-appsec-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/TolerantXmlInstrumentation.java +++ b/dd-java-agent/instrumentation/play/play-appsec-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/TolerantXmlInstrumentation.java @@ -43,7 +43,7 @@ public String instrumentedType() { @Override public String[] helperClassNames() { return new String[] { - packageName + ".BodyParserHelpers", + packageName + ".BodyParserHelpers", packageName + ".BodyParserHelpers$ScalaIteratorAdapter", }; } diff --git a/dd-java-agent/instrumentation/play/play-appsec-2.6/src/test/groovy/datadog/trace/instrumentation/play26/server/PlayAsyncServerTest.groovy b/dd-java-agent/instrumentation/play/play-appsec-2.6/src/test/groovy/datadog/trace/instrumentation/play26/server/PlayAsyncServerTest.groovy index 4155deea955..339c17ff797 100644 --- a/dd-java-agent/instrumentation/play/play-appsec-2.6/src/test/groovy/datadog/trace/instrumentation/play26/server/PlayAsyncServerTest.groovy +++ b/dd-java-agent/instrumentation/play/play-appsec-2.6/src/test/groovy/datadog/trace/instrumentation/play26/server/PlayAsyncServerTest.groovy @@ -7,6 +7,12 @@ import spock.lang.Shared import java.util.concurrent.Executors class PlayAsyncServerTest extends AbstractPlayServerTest { + + @Override + boolean testBodyFilenames() { + true + } + @Shared def executor diff --git a/dd-java-agent/instrumentation/play/play-appsec-2.6/src/test/groovy/datadog/trace/instrumentation/play26/server/PlayServerTest.groovy b/dd-java-agent/instrumentation/play/play-appsec-2.6/src/test/groovy/datadog/trace/instrumentation/play26/server/PlayServerTest.groovy index b425a73b0ce..b48ff0ad201 100644 --- a/dd-java-agent/instrumentation/play/play-appsec-2.6/src/test/groovy/datadog/trace/instrumentation/play26/server/PlayServerTest.groovy +++ b/dd-java-agent/instrumentation/play/play-appsec-2.6/src/test/groovy/datadog/trace/instrumentation/play26/server/PlayServerTest.groovy @@ -7,6 +7,11 @@ import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.BODY_X class PlayServerTest extends AbstractPlayServerTest { + @Override + boolean testBodyFilenames() { + true + } + def 'test instrumentation gateway xml request body'() { setup: def request = request( diff --git a/dd-java-agent/instrumentation/play/play-appsec-2.6/src/test/java/datadog/trace/instrumentation/play26/appsec/BodyParserHelpersTest.java b/dd-java-agent/instrumentation/play/play-appsec-2.6/src/test/java/datadog/trace/instrumentation/play26/appsec/BodyParserHelpersTest.java new file mode 100644 index 00000000000..17fd88344a1 --- /dev/null +++ b/dd-java-agent/instrumentation/play/play-appsec-2.6/src/test/java/datadog/trace/instrumentation/play26/appsec/BodyParserHelpersTest.java @@ -0,0 +1,168 @@ +package datadog.trace.instrumentation.play26.appsec; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Method; +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import play.api.libs.json.JsValue; +import play.api.mvc.MultipartFormData; + +class BodyParserHelpersTest { + + private static JsValue parse(String json) { + return play.api.libs.json.Json$.MODULE$.parse(json); + } + + @Test + void jsValueToJavaObject_nullInputReturnsNull() { + assertNull(BodyParserHelpers.jsValueToJavaObject(null)); + } + + @Test + void jsValueToJavaObject_jsNullReturnsNull() { + assertNull(BodyParserHelpers.jsValueToJavaObject(parse("null"))); + } + + @Test + void jsValueToJavaObject_string() { + Object result = BodyParserHelpers.jsValueToJavaObject(parse("\"hello\"")); + assertEquals("hello", result); + } + + @Test + void jsValueToJavaObject_number() { + Object result = BodyParserHelpers.jsValueToJavaObject(parse("42")); + assertTrue(result instanceof BigDecimal); + assertEquals(0, ((BigDecimal) result).compareTo(new BigDecimal("42"))); + } + + @Test + void jsValueToJavaObject_booleanTrue() { + Object result = BodyParserHelpers.jsValueToJavaObject(parse("true")); + assertEquals(Boolean.TRUE, result); + } + + @Test + void jsValueToJavaObject_booleanFalse() { + Object result = BodyParserHelpers.jsValueToJavaObject(parse("false")); + assertEquals(Boolean.FALSE, result); + } + + @Test + @SuppressWarnings("unchecked") + void jsValueToJavaObject_object() { + Object result = BodyParserHelpers.jsValueToJavaObject(parse("{\"key\":\"value\",\"num\":1}")); + assertTrue(result instanceof Map); + Map map = (Map) result; + assertEquals("value", map.get("key")); + assertTrue(map.get("num") instanceof BigDecimal); + } + + @Test + @SuppressWarnings("unchecked") + void jsValueToJavaObject_array() { + Object result = BodyParserHelpers.jsValueToJavaObject(parse("[\"a\",\"b\",\"c\"]")); + assertTrue(result instanceof List); + List list = (List) result; + assertEquals(3, list.size()); + assertEquals("a", list.get(0)); + assertEquals("b", list.get(1)); + assertEquals("c", list.get(2)); + } + + @Test + @SuppressWarnings("unchecked") + void jsValueToJavaObject_nestedObject() { + Object result = + BodyParserHelpers.jsValueToJavaObject(parse("{\"outer\":{\"inner\":\"deep\"}}")); + assertTrue(result instanceof Map); + Map outer = (Map) result; + assertTrue(outer.get("outer") instanceof Map); + Map inner = (Map) outer.get("outer"); + assertEquals("deep", inner.get("inner")); + } + + @Test + void jsValueToJavaObject_zeroRecursionReturnsNull() { + Object result = BodyParserHelpers.jsValueToJavaObject(parse("{\"key\":\"value\"}"), 0); + assertNull(result); + } + + @Test + @SuppressWarnings("unchecked") + void jsValueToJavaObject_recursionLimitTruncatesNesting() { + // depth=1 means the object itself is converted but children are null + Object result = BodyParserHelpers.jsValueToJavaObject(parse("{\"a\":{\"b\":\"val\"}}"), 1); + assertTrue(result instanceof Map); + Map map = (Map) result; + // inner object exceeds depth so its value is null + assertNull(map.get("a")); + } + + // --- collectFilenames tests --- + + @Test + void collectFilenames_emptyIterator() { + List result = BodyParserHelpers.collectFilenames(Collections.emptyIterator()); + assertTrue(result.isEmpty()); + } + + @Test + void collectFilenames_nullFilenameExcluded() throws Exception { + List result = + BodyParserHelpers.collectFilenames( + Collections.singletonList(filePart("f", null)).iterator()); + assertTrue(result.isEmpty()); + } + + @Test + void collectFilenames_emptyFilenameExcluded() throws Exception { + List result = + BodyParserHelpers.collectFilenames( + Collections.singletonList(filePart("f", "")).iterator()); + assertTrue(result.isEmpty()); + } + + @Test + void collectFilenames_validFilenameIncluded() throws Exception { + List result = + BodyParserHelpers.collectFilenames( + Collections.singletonList(filePart("f", "evil.php")).iterator()); + assertEquals(Collections.singletonList("evil.php"), result); + } + + @Test + void collectFilenames_mixedPartsFiltered() throws Exception { + List parts = + Arrays.asList( + filePart("f1", "a.pdf"), + filePart("f2", null), + filePart("f3", ""), + filePart("f4", "b.jpg")); + List result = BodyParserHelpers.collectFilenames(parts.iterator()); + assertEquals(Arrays.asList("a.pdf", "b.jpg"), result); + } + + @SuppressWarnings("unchecked") + private static MultipartFormData.FilePart filePart(String key, String filename) + throws Exception { + // FilePart is a Scala case class nested in object MultipartFormData. + // Use the companion object's apply() to avoid JVM inner-class constructor complexity. + Class companionClass = Class.forName("play.api.mvc.MultipartFormData$FilePart$"); + Object companion = companionClass.getField("MODULE$").get(null); + for (Method m : companionClass.getMethods()) { + if ("apply".equals(m.getName()) && m.getParameterCount() == 4) { + return (MultipartFormData.FilePart) + m.invoke(companion, key, filename, scala.None$.MODULE$, new Object()); + } + } + throw new IllegalStateException("FilePart.apply(4 params) not found"); + } +} diff --git a/dd-java-agent/instrumentation/undertow/undertow-2.0/src/main/java/datadog/trace/instrumentation/undertow/FormDataMap.java b/dd-java-agent/instrumentation/undertow/undertow-2.0/src/main/java/datadog/trace/instrumentation/undertow/FormDataMap.java index 5a952ec8381..267fe107f60 100644 --- a/dd-java-agent/instrumentation/undertow/undertow-2.0/src/main/java/datadog/trace/instrumentation/undertow/FormDataMap.java +++ b/dd-java-agent/instrumentation/undertow/undertow-2.0/src/main/java/datadog/trace/instrumentation/undertow/FormDataMap.java @@ -21,7 +21,9 @@ private Map> getMap() { Deque formValues = formData.get(key); List values = new ArrayList<>(formValues.size()); for (FormData.FormValue formValue : formValues) { - if (!formValue.isFile()) { + // In undertow 2.2+, isFile() returns false for in-memory file uploads; getFileName() + // correctly identifies all file uploads regardless of storage. + if (formValue.getFileName() == null) { values.add(formValue.getValue()); } } diff --git a/dd-java-agent/instrumentation/undertow/undertow-2.0/src/main/java/datadog/trace/instrumentation/undertow/MultiPartUploadHandlerInstrumentation.java b/dd-java-agent/instrumentation/undertow/undertow-2.0/src/main/java/datadog/trace/instrumentation/undertow/MultiPartUploadHandlerInstrumentation.java index bcf47413616..52bd0b73897 100644 --- a/dd-java-agent/instrumentation/undertow/undertow-2.0/src/main/java/datadog/trace/instrumentation/undertow/MultiPartUploadHandlerInstrumentation.java +++ b/dd-java-agent/instrumentation/undertow/undertow-2.0/src/main/java/datadog/trace/instrumentation/undertow/MultiPartUploadHandlerInstrumentation.java @@ -22,6 +22,8 @@ import datadog.trace.bootstrap.instrumentation.api.AgentTracer; import io.undertow.server.HttpServerExchange; import io.undertow.server.handlers.form.FormData; +import java.util.ArrayList; +import java.util.List; import java.util.function.BiFunction; import net.bytebuddy.asm.Advice; @@ -81,9 +83,11 @@ static void after( } CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); - BiFunction> callback = + BiFunction> bodyCallback = cbp.getCallback(EVENTS.requestBodyProcessed()); - if (callback == null) { + BiFunction, Flow> filenamesCb = + cbp.getCallback(EVENTS.requestFilesFilenames()); + if (bodyCallback == null && filenamesCb == null) { return; } FormData attachment = exchange.getAttachment(FORM_DATA); @@ -91,15 +95,47 @@ static void after( return; } - Flow flow = callback.apply(reqCtx, new FormDataMap(attachment)); - Flow.Action action = flow.getAction(); - if (action instanceof Flow.Action.RequestBlockingAction) { - Flow.Action.RequestBlockingAction rba = (Flow.Action.RequestBlockingAction) action; - BlockResponseFunction blockResponseFunction = reqCtx.getBlockResponseFunction(); - if (blockResponseFunction != null) { - blockResponseFunction.tryCommitBlockingResponse(reqCtx.getTraceSegment(), rba); - if (t == null) { - t = new BlockingException("Blocked request (for MultiPartUploadHandler/parseBlocking)"); + if (bodyCallback != null) { + Flow flow = bodyCallback.apply(reqCtx, new FormDataMap(attachment)); + Flow.Action action = flow.getAction(); + if (action instanceof Flow.Action.RequestBlockingAction) { + Flow.Action.RequestBlockingAction rba = (Flow.Action.RequestBlockingAction) action; + BlockResponseFunction blockResponseFunction = reqCtx.getBlockResponseFunction(); + if (blockResponseFunction != null) { + boolean success = + blockResponseFunction.tryCommitBlockingResponse(reqCtx.getTraceSegment(), rba); + if (success && t == null) { + t = + new BlockingException( + "Blocked request (for MultiPartUploadHandler/parseBlocking)"); + } + } + } + } + + if (filenamesCb != null) { + List filenames = new ArrayList<>(); + for (String key : attachment) { + for (FormData.FormValue formValue : attachment.get(key)) { + String filename = formValue.getFileName(); + if (filename != null && !filename.isEmpty()) { + filenames.add(filename); + } + } + } + if (!filenames.isEmpty()) { + Flow filenamesFlow = filenamesCb.apply(reqCtx, filenames); + Flow.Action filenamesAction = filenamesFlow.getAction(); + if (filenamesAction instanceof Flow.Action.RequestBlockingAction) { + Flow.Action.RequestBlockingAction rba = + (Flow.Action.RequestBlockingAction) filenamesAction; + BlockResponseFunction brf = reqCtx.getBlockResponseFunction(); + if (brf != null && t == null) { + boolean success = brf.tryCommitBlockingResponse(reqCtx.getTraceSegment(), rba); + if (success) { + t = new BlockingException("Blocked request (multipart file upload)"); + } + } } } } diff --git a/dd-java-agent/instrumentation/undertow/undertow-2.0/src/test/groovy/UndertowServletAsyncTest.groovy b/dd-java-agent/instrumentation/undertow/undertow-2.0/src/test/groovy/UndertowServletAsyncTest.groovy index e4d0a95908a..1866b24d7cb 100644 --- a/dd-java-agent/instrumentation/undertow/undertow-2.0/src/test/groovy/UndertowServletAsyncTest.groovy +++ b/dd-java-agent/instrumentation/undertow/undertow-2.0/src/test/groovy/UndertowServletAsyncTest.groovy @@ -295,6 +295,11 @@ class UndertowServletAsyncTest extends HttpServerTest { true } + @Override + boolean testBodyFilenames() { + true + } + @Override boolean testBlockingOnResponse() { true diff --git a/dd-java-agent/instrumentation/undertow/undertow-2.0/src/test/groovy/UndertowServletTest.groovy b/dd-java-agent/instrumentation/undertow/undertow-2.0/src/test/groovy/UndertowServletTest.groovy index a3413e5fdc5..6fc47cd0e15 100644 --- a/dd-java-agent/instrumentation/undertow/undertow-2.0/src/test/groovy/UndertowServletTest.groovy +++ b/dd-java-agent/instrumentation/undertow/undertow-2.0/src/test/groovy/UndertowServletTest.groovy @@ -196,6 +196,11 @@ abstract class UndertowServletTest extends HttpServerTest { true } + @Override + boolean testBodyFilenames() { + true + } + @Override boolean testBlockingOnResponse() { true diff --git a/dd-java-agent/instrumentation/undertow/undertow-2.0/src/test/java/datadog/trace/instrumentation/undertow/FormDataMapTest.java b/dd-java-agent/instrumentation/undertow/undertow-2.0/src/test/java/datadog/trace/instrumentation/undertow/FormDataMapTest.java new file mode 100644 index 00000000000..c8c4366a963 --- /dev/null +++ b/dd-java-agent/instrumentation/undertow/undertow-2.0/src/test/java/datadog/trace/instrumentation/undertow/FormDataMapTest.java @@ -0,0 +1,130 @@ +package datadog.trace.instrumentation.undertow; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.undertow.server.handlers.form.FormData; +import io.undertow.util.HeaderMap; +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.Proxy; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.Collection; +import java.util.Deque; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class FormDataMapTest { + + @TempDir Path tempDir; + + @Test + void textFieldIsIncluded() { + FormData fd = new FormData(10); + fd.add("name", "John"); + FormDataMap map = new FormDataMap(fd); + + assertTrue(map.containsKey("name")); + Collection values = map.get("name"); + assertEquals(1, values.size()); + assertTrue(values.contains("John")); + } + + @Test + void multipleTextValuesForSameKey() { + FormData fd = new FormData(10); + fd.add("tag", "foo"); + fd.add("tag", "bar"); + FormDataMap map = new FormDataMap(fd); + + Collection values = map.get("tag"); + assertEquals(2, values.size()); + assertTrue(values.contains("foo")); + assertTrue(values.contains("bar")); + } + + @Test + void diskFileIsExcluded() throws IOException { + Path file = Files.createTempFile(tempDir, "upload", ".txt"); + FormData fd = new FormData(10); + fd.add("upload", file, "evil.php", new HeaderMap()); + FormDataMap map = new FormDataMap(fd); + + assertTrue(map.containsKey("upload")); + assertTrue(map.get("upload").isEmpty()); + } + + @Test + void inMemoryFileIsExcluded() throws Exception { + // In undertow 2.2+, isFile() returns false for in-memory uploads, but getFileName() is still + // set. Verify our check (getFileName() == null) correctly excludes these uploads too. + FormData fd = new FormData(10); + addInMemoryFileValue(fd, "file", "evil.php"); + FormDataMap map = new FormDataMap(fd); + + assertTrue(map.containsKey("file")); + assertTrue(map.get("file").isEmpty()); + } + + @Test + void mixedTextAndFileFields() throws IOException { + Path file = Files.createTempFile(tempDir, "upload", ".txt"); + FormData fd = new FormData(10); + fd.add("name", "John"); + fd.add("email", "john@example.com"); + fd.add("upload", file, "evil.php", new HeaderMap()); + FormDataMap map = new FormDataMap(fd); + + assertEquals(3, map.size()); + assertTrue(map.get("name").contains("John")); + assertTrue(map.get("email").contains("john@example.com")); + assertTrue(map.get("upload").isEmpty()); + } + + @Test + void emptyFormData() { + FormData fd = new FormData(10); + FormDataMap map = new FormDataMap(fd); + + assertTrue(map.isEmpty()); + assertEquals(0, map.size()); + } + + @SuppressWarnings("unchecked") + private static void addInMemoryFileValue(FormData fd, String name, String filename) + throws Exception { + Field valuesField = FormData.class.getDeclaredField("values"); + valuesField.setAccessible(true); + Map> values = + (Map>) valuesField.get(fd); + + // Use a Proxy so this compiles against undertow 2.0 and also works against 2.2.x, + // which added getCharset(), getFileItem(), isFileItem(), and isBigField() to the interface. + FormData.FormValue inMemory = + (FormData.FormValue) + Proxy.newProxyInstance( + FormData.FormValue.class.getClassLoader(), + new Class[] {FormData.FormValue.class}, + (proxy, method, args) -> { + switch (method.getName()) { + case "getValue": + return ""; + case "isFile": + case "isFileItem": + case "isBigField": + return false; + case "getFileName": + return filename; + default: + return null; + } + }); + + Deque deque = new ArrayDeque<>(); + deque.add(inMemory); + values.put(name, deque); + } +} diff --git a/dd-java-agent/instrumentation/undertow/undertow-2.2/src/test/groovy/UndertowServletTest.groovy b/dd-java-agent/instrumentation/undertow/undertow-2.2/src/test/groovy/UndertowServletTest.groovy index a0a43369bf3..c7a1924eb12 100644 --- a/dd-java-agent/instrumentation/undertow/undertow-2.2/src/test/groovy/UndertowServletTest.groovy +++ b/dd-java-agent/instrumentation/undertow/undertow-2.2/src/test/groovy/UndertowServletTest.groovy @@ -193,6 +193,11 @@ class UndertowServletTest extends HttpServerTest { true } + @Override + boolean testBodyFilenames() { + true + } + boolean hasResponseSpan(ServerEndpoint endpoint) { // FIXME: re-enable when jakarta servlet will be fully supported // return endpoint == REDIRECT || endpoint == NOT_FOUND