From ebaa3381ce5da0fd922df36d5ef65a07592daeec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20=C3=81lvarez=20=C3=81lvarez?= Date: Wed, 25 Jun 2025 10:27:08 +0200 Subject: [PATCH] Extract Play json body response schemas --- .../appsec/ResultsStatusInstrumentation.java | 44 ++++++++ .../appsec/StatusHeaderInstrumentation.java | 40 +++++++ .../play25/appsec/BodyParserHelpers.java | 10 +- .../appsec/ResultsStatusApplyAdvice.java | 57 ++++++++++ .../appsec/StatusHeaderSendJsonAdvice.java | 67 +++++++++++ .../play25/server/PlayRouters.groovy | 5 +- .../play25/server/PlayServerTest.groovy | 5 + .../play25/PlayController.scala | 2 +- .../play25/PlayRoutersScala.scala | 2 +- .../play26/server/PlayRouters.groovy | 6 +- .../play26/server/PlayServerTest.groovy | 5 + .../server/latestdep/PlayRouters.groovy | 5 +- .../server/latestdep/PlayController.scala | 2 +- .../server/latestdep/PlayRoutersScala.scala | 2 +- .../play26/appsec/BodyParserHelpers.java | 11 +- .../appsec/ResultsStatusInstrumentation.java | 100 +++++++++++++++++ .../appsec/StatusHeaderInstrumentation.java | 105 ++++++++++++++++++ .../app/controllers/AppSecController.scala | 11 +- dd-smoke-tests/play-2.5/conf/routes | 1 + .../smoketest/AppSecPlayNettySmokeTest.groovy | 36 ++++++ .../app/controllers/AppSecController.scala | 7 ++ dd-smoke-tests/play-2.6/conf/routes | 1 + .../smoketest/AppSecPlaySmokeTest.groovy | 36 ++++++ .../app/controllers/AppSecController.scala | 7 ++ dd-smoke-tests/play-2.7/conf/routes | 1 + .../smoketest/AppSecPlaySmokeTest.groovy | 36 ++++++ .../app/controllers/AppSecController.scala | 7 ++ dd-smoke-tests/play-2.8/conf/routes | 1 + .../smoketest/AppSecPlaySmokeTest.groovy | 36 ++++++ 29 files changed, 629 insertions(+), 19 deletions(-) create mode 100644 dd-java-agent/instrumentation/play-2.4/src/main/java/datadog/trace/instrumentation/play25/appsec/ResultsStatusInstrumentation.java create mode 100644 dd-java-agent/instrumentation/play-2.4/src/main/java/datadog/trace/instrumentation/play25/appsec/StatusHeaderInstrumentation.java create mode 100644 dd-java-agent/instrumentation/play-2.4/src/main/java_play25/datadog/trace/instrumentation/play25/appsec/ResultsStatusApplyAdvice.java create mode 100644 dd-java-agent/instrumentation/play-2.4/src/main/java_play25/datadog/trace/instrumentation/play25/appsec/StatusHeaderSendJsonAdvice.java create mode 100644 dd-java-agent/instrumentation/play-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/ResultsStatusInstrumentation.java create mode 100644 dd-java-agent/instrumentation/play-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/StatusHeaderInstrumentation.java diff --git a/dd-java-agent/instrumentation/play-2.4/src/main/java/datadog/trace/instrumentation/play25/appsec/ResultsStatusInstrumentation.java b/dd-java-agent/instrumentation/play-2.4/src/main/java/datadog/trace/instrumentation/play25/appsec/ResultsStatusInstrumentation.java new file mode 100644 index 00000000000..a9dce06f944 --- /dev/null +++ b/dd-java-agent/instrumentation/play-2.4/src/main/java/datadog/trace/instrumentation/play25/appsec/ResultsStatusInstrumentation.java @@ -0,0 +1,44 @@ +package datadog.trace.instrumentation.play25.appsec; + +import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named; + +import com.google.auto.service.AutoService; +import datadog.trace.agent.tooling.Instrumenter; +import datadog.trace.agent.tooling.InstrumenterModule; +import datadog.trace.agent.tooling.muzzle.Reference; + +@AutoService(InstrumenterModule.class) +public class ResultsStatusInstrumentation extends InstrumenterModule.AppSec + implements Instrumenter.ForSingleType, Instrumenter.HasMethodAdvice { + + public ResultsStatusInstrumentation() { + super("play"); + } + + @Override + public String muzzleDirective() { + return "play25only"; + } + + @Override + public Reference[] additionalMuzzleReferences() { + return MuzzleReferences.PLAY_25_ONLY; + } + + @Override + public String instrumentedType() { + return "play.api.mvc.Results$Status"; + } + + @Override + public String[] helperClassNames() { + return new String[] { + packageName + ".BodyParserHelpers", + }; + } + + @Override + public void methodAdvice(MethodTransformer transformer) { + transformer.applyAdvice(named("apply"), packageName + ".ResultsStatusApplyAdvice"); + } +} diff --git a/dd-java-agent/instrumentation/play-2.4/src/main/java/datadog/trace/instrumentation/play25/appsec/StatusHeaderInstrumentation.java b/dd-java-agent/instrumentation/play-2.4/src/main/java/datadog/trace/instrumentation/play25/appsec/StatusHeaderInstrumentation.java new file mode 100644 index 00000000000..31c71c3a1d0 --- /dev/null +++ b/dd-java-agent/instrumentation/play-2.4/src/main/java/datadog/trace/instrumentation/play25/appsec/StatusHeaderInstrumentation.java @@ -0,0 +1,40 @@ +package datadog.trace.instrumentation.play25.appsec; + +import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.takesArgument; + +import com.google.auto.service.AutoService; +import datadog.trace.agent.tooling.Instrumenter; +import datadog.trace.agent.tooling.InstrumenterModule; +import datadog.trace.agent.tooling.muzzle.Reference; + +@AutoService(InstrumenterModule.class) +public class StatusHeaderInstrumentation extends InstrumenterModule.AppSec + implements Instrumenter.ForSingleType, Instrumenter.HasMethodAdvice { + + public StatusHeaderInstrumentation() { + super("play"); + } + + @Override + public String muzzleDirective() { + return "play25only"; + } + + @Override + public Reference[] additionalMuzzleReferences() { + return MuzzleReferences.PLAY_25_ONLY; + } + + @Override + public String instrumentedType() { + return "play.mvc.StatusHeader"; + } + + @Override + public void methodAdvice(MethodTransformer transformer) { + transformer.applyAdvice( + named("sendJson").and(takesArgument(0, named("com.fasterxml.jackson.databind.JsonNode"))), + packageName + ".StatusHeaderSendJsonAdvice"); + } +} diff --git a/dd-java-agent/instrumentation/play-2.4/src/main/java_play25/datadog/trace/instrumentation/play25/appsec/BodyParserHelpers.java b/dd-java-agent/instrumentation/play-2.4/src/main/java_play25/datadog/trace/instrumentation/play25/appsec/BodyParserHelpers.java index c745a460954..a236ac37fb6 100644 --- a/dd-java-agent/instrumentation/play-2.4/src/main/java_play25/datadog/trace/instrumentation/play25/appsec/BodyParserHelpers.java +++ b/dd-java-agent/instrumentation/play-2.4/src/main/java_play25/datadog/trace/instrumentation/play25/appsec/BodyParserHelpers.java @@ -39,6 +39,7 @@ import scala.collection.Iterator; import scala.collection.Seq; import scala.compat.java8.JFunction1; +import scala.math.BigDecimal; public class BodyParserHelpers { @@ -231,7 +232,11 @@ private static void handleException(Exception e, String logMessage) { log.warn(logMessage, e); } - private static Object jsValueToJavaObject(JsValue value, int maxRecursion) { + public static Object jsValueToJavaObject(JsValue value) { + return jsValueToJavaObject(value, MAX_RECURSION); + } + + public static Object jsValueToJavaObject(JsValue value, int maxRecursion) { if (value == null || maxRecursion <= 0) { return null; } @@ -239,7 +244,8 @@ private static Object jsValueToJavaObject(JsValue value, int maxRecursion) { if (value instanceof JsString) { return ((JsString) value).value(); } else if (value instanceof JsNumber) { - return ((JsNumber) value).value(); + final BigDecimal number = ((JsNumber) value).value(); + return number == null ? null : number.bigDecimal(); } else if (value instanceof JsBoolean) { return ((JsBoolean) value).value(); } else if (value instanceof JsObject) { diff --git a/dd-java-agent/instrumentation/play-2.4/src/main/java_play25/datadog/trace/instrumentation/play25/appsec/ResultsStatusApplyAdvice.java b/dd-java-agent/instrumentation/play-2.4/src/main/java_play25/datadog/trace/instrumentation/play25/appsec/ResultsStatusApplyAdvice.java new file mode 100644 index 00000000000..295dd770eaf --- /dev/null +++ b/dd-java-agent/instrumentation/play-2.4/src/main/java_play25/datadog/trace/instrumentation/play25/appsec/ResultsStatusApplyAdvice.java @@ -0,0 +1,57 @@ +package datadog.trace.instrumentation.play25.appsec; + +import static datadog.trace.api.gateway.Events.EVENTS; +import static datadog.trace.instrumentation.play25.appsec.BodyParserHelpers.jsValueToJavaObject; + +import datadog.appsec.api.blocking.BlockingException; +import datadog.trace.advice.ActiveRequestContext; +import datadog.trace.advice.RequiresRequestContext; +import datadog.trace.api.gateway.BlockResponseFunction; +import datadog.trace.api.gateway.CallbackProvider; +import datadog.trace.api.gateway.Flow; +import datadog.trace.api.gateway.RequestContext; +import datadog.trace.api.gateway.RequestContextSlot; +import datadog.trace.bootstrap.instrumentation.api.AgentTracer; +import java.util.function.BiFunction; +import net.bytebuddy.asm.Advice; +import play.api.libs.json.JsValue; + +@RequiresRequestContext(RequestContextSlot.APPSEC) +public class ResultsStatusApplyAdvice { + + @Advice.OnMethodEnter(suppress = Throwable.class) + static void after( + @Advice.Argument(0) final Object content, @ActiveRequestContext RequestContext reqCtx) { + + if (!(content instanceof JsValue)) { + return; + } + + CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); + if (cbp == null) { + return; + } + BiFunction> callback = + cbp.getCallback(EVENTS.responseBody()); + if (callback == null) { + return; + } + + Flow flow = callback.apply(reqCtx, jsValueToJavaObject((JsValue) content)); + Flow.Action action = flow.getAction(); + if (action instanceof Flow.Action.RequestBlockingAction) { + BlockResponseFunction blockResponseFunction = reqCtx.getBlockResponseFunction(); + if (blockResponseFunction == null) { + return; + } + Flow.Action.RequestBlockingAction rba = (Flow.Action.RequestBlockingAction) action; + blockResponseFunction.tryCommitBlockingResponse( + reqCtx.getTraceSegment(), + rba.getStatusCode(), + rba.getBlockingContentType(), + rba.getExtraHeaders()); + + throw new BlockingException("Blocked request (for Results$Status/apply)"); + } + } +} diff --git a/dd-java-agent/instrumentation/play-2.4/src/main/java_play25/datadog/trace/instrumentation/play25/appsec/StatusHeaderSendJsonAdvice.java b/dd-java-agent/instrumentation/play-2.4/src/main/java_play25/datadog/trace/instrumentation/play25/appsec/StatusHeaderSendJsonAdvice.java new file mode 100644 index 00000000000..526eed0deaf --- /dev/null +++ b/dd-java-agent/instrumentation/play-2.4/src/main/java_play25/datadog/trace/instrumentation/play25/appsec/StatusHeaderSendJsonAdvice.java @@ -0,0 +1,67 @@ +package datadog.trace.instrumentation.play25.appsec; + +import static datadog.trace.api.gateway.Events.EVENTS; + +import com.fasterxml.jackson.databind.JsonNode; +import datadog.appsec.api.blocking.BlockingException; +import datadog.trace.advice.ActiveRequestContext; +import datadog.trace.advice.RequiresRequestContext; +import datadog.trace.api.gateway.BlockResponseFunction; +import datadog.trace.api.gateway.CallbackProvider; +import datadog.trace.api.gateway.Flow; +import datadog.trace.api.gateway.RequestContext; +import datadog.trace.api.gateway.RequestContextSlot; +import datadog.trace.bootstrap.CallDepthThreadLocalMap; +import datadog.trace.bootstrap.instrumentation.api.AgentTracer; +import java.util.function.BiFunction; +import net.bytebuddy.asm.Advice; +import play.mvc.StatusHeader; + +@RequiresRequestContext(RequestContextSlot.APPSEC) +public class StatusHeaderSendJsonAdvice { + + @Advice.OnMethodEnter(suppress = Throwable.class) + static void before() { + CallDepthThreadLocalMap.incrementCallDepth(StatusHeader.class); + } + + @Advice.OnMethodExit(suppress = Throwable.class, onThrowable = Throwable.class) + static void after( + @Advice.Argument(0) final JsonNode json, @ActiveRequestContext RequestContext reqCtx) { + final int depth = CallDepthThreadLocalMap.decrementCallDepth(StatusHeader.class); + if (depth > 0) { + return; + } + + if (json == null) { + return; + } + + CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); + if (cbp == null) { + return; + } + BiFunction> callback = + cbp.getCallback(EVENTS.responseBody()); + if (callback == null) { + return; + } + + Flow flow = callback.apply(reqCtx, json); + Flow.Action action = flow.getAction(); + if (action instanceof Flow.Action.RequestBlockingAction) { + BlockResponseFunction blockResponseFunction = reqCtx.getBlockResponseFunction(); + if (blockResponseFunction == null) { + return; + } + Flow.Action.RequestBlockingAction rba = (Flow.Action.RequestBlockingAction) action; + blockResponseFunction.tryCommitBlockingResponse( + reqCtx.getTraceSegment(), + rba.getStatusCode(), + rba.getBlockingContentType(), + rba.getExtraHeaders()); + + throw new BlockingException("Blocked request (for StatusHeader/sendJson)"); + } + } +} diff --git a/dd-java-agent/instrumentation/play-2.4/src/test/groovy/datadog/trace/instrumentation/play25/server/PlayRouters.groovy b/dd-java-agent/instrumentation/play-2.4/src/test/groovy/datadog/trace/instrumentation/play25/server/PlayRouters.groovy index f0b49a51bfe..48359dc4bae 100644 --- a/dd-java-agent/instrumentation/play-2.4/src/test/groovy/datadog/trace/instrumentation/play25/server/PlayRouters.groovy +++ b/dd-java-agent/instrumentation/play-2.4/src/test/groovy/datadog/trace/instrumentation/play25/server/PlayRouters.groovy @@ -1,7 +1,6 @@ package datadog.trace.instrumentation.play25.server import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.ObjectMapper import datadog.appsec.api.blocking.Blocking import datadog.trace.agent.test.base.HttpServerTest import groovy.transform.CompileStatic @@ -130,7 +129,7 @@ class PlayRouters { -> JsonNode json = body().asJson() controller(BODY_JSON) { - Results.status(BODY_JSON.status, new ObjectMapper().writeValueAsString(json)) + Results.status(BODY_JSON.status, json) } } as Supplier) .build() @@ -253,7 +252,7 @@ class PlayRouters { CompletableFuture.supplyAsync({ -> controller(BODY_JSON) { - Results.status(BODY_JSON.status, new ObjectMapper().writeValueAsString(json)) + Results.status(BODY_JSON.status, json) } }, execContext) } as Supplier) diff --git a/dd-java-agent/instrumentation/play-2.4/src/test/groovy/datadog/trace/instrumentation/play25/server/PlayServerTest.groovy b/dd-java-agent/instrumentation/play-2.4/src/test/groovy/datadog/trace/instrumentation/play25/server/PlayServerTest.groovy index dab1f327069..bf5d9a09928 100644 --- a/dd-java-agent/instrumentation/play-2.4/src/test/groovy/datadog/trace/instrumentation/play25/server/PlayServerTest.groovy +++ b/dd-java-agent/instrumentation/play-2.4/src/test/groovy/datadog/trace/instrumentation/play25/server/PlayServerTest.groovy @@ -93,6 +93,11 @@ class PlayServerTest extends HttpServerTest { true } + @Override + boolean testResponseBodyJson() { + true + } + @Override String testPathParam() { '/path/?/param' diff --git a/dd-java-agent/instrumentation/play-2.4/src/test/scala/datadog/trace/instrumentation/play25/PlayController.scala b/dd-java-agent/instrumentation/play-2.4/src/test/scala/datadog/trace/instrumentation/play25/PlayController.scala index ff00fa780a9..1dfe30392be 100644 --- a/dd-java-agent/instrumentation/play-2.4/src/test/scala/datadog/trace/instrumentation/play25/PlayController.scala +++ b/dd-java-agent/instrumentation/play-2.4/src/test/scala/datadog/trace/instrumentation/play25/PlayController.scala @@ -77,7 +77,7 @@ class PlayController(implicit ec: ExecutionContext) extends Controller { def bodyJson = controller(ServerEndpoint.BODY_JSON) { request => val body: JsValue = request.body.asJson.getOrElse(JsNull) - Results.Ok(Json.stringify(body)) + Results.Ok(body) } private def controller(endpoint: ServerEndpoint)(block: Request[AnyContent] => Result) : Action[AnyContent] = { diff --git a/dd-java-agent/instrumentation/play-2.4/src/test/scala/datadog/trace/instrumentation/play25/PlayRoutersScala.scala b/dd-java-agent/instrumentation/play-2.4/src/test/scala/datadog/trace/instrumentation/play25/PlayRoutersScala.scala index 5f6b313fd7b..b0c6542d196 100644 --- a/dd-java-agent/instrumentation/play-2.4/src/test/scala/datadog/trace/instrumentation/play25/PlayRoutersScala.scala +++ b/dd-java-agent/instrumentation/play-2.4/src/test/scala/datadog/trace/instrumentation/play25/PlayRoutersScala.scala @@ -143,7 +143,7 @@ object PlayRoutersScala { case POST(p"/body-json") => Action.async(parser) { request => controller(BODY_JSON) { val body: JsValue = request.body.asJson.getOrElse(JsNull) - Results.Ok(Json.stringify(body)) + Results.Ok(body) } } } diff --git a/dd-java-agent/instrumentation/play-2.6/src/baseTest/groovy/datadog/trace/instrumentation/play26/server/PlayRouters.groovy b/dd-java-agent/instrumentation/play-2.6/src/baseTest/groovy/datadog/trace/instrumentation/play26/server/PlayRouters.groovy index 2d3925a7f28..c2226b78f6d 100644 --- a/dd-java-agent/instrumentation/play-2.6/src/baseTest/groovy/datadog/trace/instrumentation/play26/server/PlayRouters.groovy +++ b/dd-java-agent/instrumentation/play-2.6/src/baseTest/groovy/datadog/trace/instrumentation/play26/server/PlayRouters.groovy @@ -1,11 +1,11 @@ package datadog.trace.instrumentation.play26.server +import com.fasterxml.jackson.databind.ObjectMapper import datadog.appsec.api.blocking.Blocking import datadog.trace.agent.test.base.HttpServerTest import groovy.transform.CompileStatic import play.BuiltInComponents import play.api.libs.json.JsValue -import play.api.libs.json.Json$ import play.api.mvc.AnyContent import play.libs.concurrent.HttpExecution import play.mvc.Http @@ -150,7 +150,7 @@ class PlayRouters { -> controller(BODY_JSON) { JsValue json = body().asJson().get() - Results.status(BODY_JSON.status, Json$.MODULE$.stringify(json)) + Results.status(BODY_JSON.status, new ObjectMapper().readTree(json.toString())) } } as Supplier) .POST(BODY_XML.path).routeTo({ @@ -286,7 +286,7 @@ class PlayRouters { CompletableFuture.supplyAsync({ -> controller(BODY_JSON) { - Results.status(BODY_JSON.status, Json$.MODULE$.stringify(json)) + Results.status(BODY_JSON.status, new ObjectMapper().readTree(json.toString())) } }, execContext) } as Supplier) diff --git a/dd-java-agent/instrumentation/play-2.6/src/baseTest/groovy/datadog/trace/instrumentation/play26/server/PlayServerTest.groovy b/dd-java-agent/instrumentation/play-2.6/src/baseTest/groovy/datadog/trace/instrumentation/play26/server/PlayServerTest.groovy index 26ca37d551b..15030d324bd 100644 --- a/dd-java-agent/instrumentation/play-2.6/src/baseTest/groovy/datadog/trace/instrumentation/play26/server/PlayServerTest.groovy +++ b/dd-java-agent/instrumentation/play-2.6/src/baseTest/groovy/datadog/trace/instrumentation/play26/server/PlayServerTest.groovy @@ -96,6 +96,11 @@ class PlayServerTest extends HttpServerTest { true } + @Override + boolean testResponseBodyJson() { + true + } + @Override String testPathParam() { '/path/?/param' diff --git a/dd-java-agent/instrumentation/play-2.6/src/latestDepTest/groovy/datadog/trace/instrumentation/play26/server/latestdep/PlayRouters.groovy b/dd-java-agent/instrumentation/play-2.6/src/latestDepTest/groovy/datadog/trace/instrumentation/play26/server/latestdep/PlayRouters.groovy index 4bf05bb12c5..3f80a909182 100644 --- a/dd-java-agent/instrumentation/play-2.6/src/latestDepTest/groovy/datadog/trace/instrumentation/play26/server/latestdep/PlayRouters.groovy +++ b/dd-java-agent/instrumentation/play-2.6/src/latestDepTest/groovy/datadog/trace/instrumentation/play26/server/latestdep/PlayRouters.groovy @@ -1,7 +1,6 @@ package datadog.trace.instrumentation.play26.server.latestdep import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.ObjectMapper import datadog.appsec.api.blocking.Blocking import datadog.trace.agent.test.base.HttpServerTest import datadog.trace.instrumentation.play26.server.TestHttpErrorHandler @@ -121,7 +120,7 @@ class PlayRouters { .POST(BODY_JSON.path).routingTo({ Http.Request req -> controller(BODY_JSON) { JsonNode json = req.body().asJson() - Results.status(BODY_JSON.status, new ObjectMapper().writeValueAsString(json)) + Results.status(BODY_JSON.status, json) } } as RequestFunctions.Params0) .POST(BODY_XML.path).routingTo({ Http.Request req -> @@ -254,7 +253,7 @@ class PlayRouters { CompletableFuture.supplyAsync({ -> controller(BODY_JSON) { - Results.status(BODY_JSON.status, new ObjectMapper().writeValueAsString(json)) + Results.status(BODY_JSON.status, json) } }, execContext) } as RequestFunctions.Params0>) diff --git a/dd-java-agent/instrumentation/play-2.6/src/latestDepTest/scala/datadog/trace/instrumentation/play26/server/latestdep/PlayController.scala b/dd-java-agent/instrumentation/play-2.6/src/latestDepTest/scala/datadog/trace/instrumentation/play26/server/latestdep/PlayController.scala index 65a360ad27f..fb6bed4a7de 100644 --- a/dd-java-agent/instrumentation/play-2.6/src/latestDepTest/scala/datadog/trace/instrumentation/play26/server/latestdep/PlayController.scala +++ b/dd-java-agent/instrumentation/play-2.6/src/latestDepTest/scala/datadog/trace/instrumentation/play26/server/latestdep/PlayController.scala @@ -79,7 +79,7 @@ class PlayController(cc: ControllerComponents)(implicit ec: ExecutionContext) ex def bodyJson = controller(ServerEndpoint.BODY_JSON) { request => val body: JsValue = request.body.asJson.getOrElse(JsNull) - Results.Ok(Json.stringify(body)) + Results.Ok(body) } def bodyXml = controller(ServerEndpoint.BODY_XML) { request => diff --git a/dd-java-agent/instrumentation/play-2.6/src/latestDepTest/scala/datadog/trace/instrumentation/play26/server/latestdep/PlayRoutersScala.scala b/dd-java-agent/instrumentation/play-2.6/src/latestDepTest/scala/datadog/trace/instrumentation/play26/server/latestdep/PlayRoutersScala.scala index aa3256e96ed..7a1898c4ce2 100644 --- a/dd-java-agent/instrumentation/play-2.6/src/latestDepTest/scala/datadog/trace/instrumentation/play26/server/latestdep/PlayRoutersScala.scala +++ b/dd-java-agent/instrumentation/play-2.6/src/latestDepTest/scala/datadog/trace/instrumentation/play26/server/latestdep/PlayRoutersScala.scala @@ -147,7 +147,7 @@ object PlayRoutersScala { case POST(p"/body-json") => defaultActionBuilder.async(parser) { request => controller(BODY_JSON) { val body: JsValue = request.body.asJson.getOrElse(JsNull) - Results.Ok(Json.stringify(body)) + Results.Ok(body) } } diff --git a/dd-java-agent/instrumentation/play-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/BodyParserHelpers.java b/dd-java-agent/instrumentation/play-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/BodyParserHelpers.java index 87afd198a69..32b09d13d0d 100644 --- a/dd-java-agent/instrumentation/play-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/BodyParserHelpers.java +++ b/dd-java-agent/instrumentation/play-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/BodyParserHelpers.java @@ -45,6 +45,7 @@ import scala.collection.Iterator; import scala.collection.Seq; import scala.compat.java8.JFunction1; +import scala.math.BigDecimal; import scala.xml.Elem; import scala.xml.Node; import scala.xml.NodeSeq; @@ -277,7 +278,11 @@ private static void handleException(Exception e, String logMessage) { log.warn(logMessage, e); } - private static Object jsValueToJavaObject(JsValue value, int maxRecursion) { + public static Object jsValueToJavaObject(JsValue value) { + return jsValueToJavaObject(value, MAX_RECURSION); + } + + public static Object jsValueToJavaObject(JsValue value, int maxRecursion) { if (value == null || maxRecursion <= 0) { return null; } @@ -285,7 +290,9 @@ private static Object jsValueToJavaObject(JsValue value, int maxRecursion) { if (value instanceof JsString) { return ((JsString) value).value(); } else if (value instanceof JsNumber) { - return ((JsNumber) value).value(); + // migrate away from scala types + final BigDecimal number = ((JsNumber) value).value(); + return number == null ? null : number.bigDecimal(); } else if (value instanceof JsBoolean) { return ((JsBoolean) value).value(); } else if (value instanceof JsObject) { diff --git a/dd-java-agent/instrumentation/play-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/ResultsStatusInstrumentation.java b/dd-java-agent/instrumentation/play-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/ResultsStatusInstrumentation.java new file mode 100644 index 00000000000..da294a20bd3 --- /dev/null +++ b/dd-java-agent/instrumentation/play-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/ResultsStatusInstrumentation.java @@ -0,0 +1,100 @@ +package datadog.trace.instrumentation.play26.appsec; + +import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named; +import static datadog.trace.api.gateway.Events.EVENTS; +import static datadog.trace.instrumentation.play26.appsec.BodyParserHelpers.jsValueToJavaObject; + +import com.google.auto.service.AutoService; +import datadog.appsec.api.blocking.BlockingException; +import datadog.trace.advice.ActiveRequestContext; +import datadog.trace.advice.RequiresRequestContext; +import datadog.trace.agent.tooling.Instrumenter; +import datadog.trace.agent.tooling.InstrumenterModule; +import datadog.trace.agent.tooling.muzzle.Reference; +import datadog.trace.api.gateway.BlockResponseFunction; +import datadog.trace.api.gateway.CallbackProvider; +import datadog.trace.api.gateway.Flow; +import datadog.trace.api.gateway.RequestContext; +import datadog.trace.api.gateway.RequestContextSlot; +import datadog.trace.bootstrap.instrumentation.api.AgentTracer; +import datadog.trace.instrumentation.play26.MuzzleReferences; +import java.util.function.BiFunction; +import net.bytebuddy.asm.Advice; +import play.api.libs.json.JsValue; + +@AutoService(InstrumenterModule.class) +public class ResultsStatusInstrumentation extends InstrumenterModule.AppSec + implements Instrumenter.ForSingleType, Instrumenter.HasMethodAdvice { + + public ResultsStatusInstrumentation() { + super("play"); + } + + @Override + public String muzzleDirective() { + return "play26Plus"; + } + + @Override + public Reference[] additionalMuzzleReferences() { + return MuzzleReferences.PLAY_26_PLUS; + } + + @Override + public String instrumentedType() { + return "play.api.mvc.Results$Status"; + } + + @Override + public String[] helperClassNames() { + return new String[] { + packageName + ".BodyParserHelpers", + }; + } + + @Override + public void methodAdvice(MethodTransformer transformer) { + transformer.applyAdvice( + named("apply"), ResultsStatusInstrumentation.class.getName() + "$ResultsStatusApplyAdvice"); + } + + @RequiresRequestContext(RequestContextSlot.APPSEC) + public static class ResultsStatusApplyAdvice { + + @Advice.OnMethodEnter(suppress = Throwable.class) + static void after( + @Advice.Argument(0) final Object content, @ActiveRequestContext RequestContext reqCtx) { + + if (!(content instanceof JsValue)) { + return; + } + + CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); + if (cbp == null) { + return; + } + BiFunction> callback = + cbp.getCallback(EVENTS.responseBody()); + if (callback == null) { + return; + } + + Flow flow = callback.apply(reqCtx, jsValueToJavaObject((JsValue) content)); + Flow.Action action = flow.getAction(); + if (action instanceof Flow.Action.RequestBlockingAction) { + BlockResponseFunction blockResponseFunction = reqCtx.getBlockResponseFunction(); + if (blockResponseFunction == null) { + return; + } + Flow.Action.RequestBlockingAction rba = (Flow.Action.RequestBlockingAction) action; + blockResponseFunction.tryCommitBlockingResponse( + reqCtx.getTraceSegment(), + rba.getStatusCode(), + rba.getBlockingContentType(), + rba.getExtraHeaders()); + + throw new BlockingException("Blocked request (for Results$Status/apply)"); + } + } + } +} diff --git a/dd-java-agent/instrumentation/play-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/StatusHeaderInstrumentation.java b/dd-java-agent/instrumentation/play-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/StatusHeaderInstrumentation.java new file mode 100644 index 00000000000..71e8058fb17 --- /dev/null +++ b/dd-java-agent/instrumentation/play-2.6/src/main/java/datadog/trace/instrumentation/play26/appsec/StatusHeaderInstrumentation.java @@ -0,0 +1,105 @@ +package datadog.trace.instrumentation.play26.appsec; + +import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named; +import static datadog.trace.api.gateway.Events.EVENTS; +import static net.bytebuddy.matcher.ElementMatchers.takesArgument; + +import com.fasterxml.jackson.databind.JsonNode; +import com.google.auto.service.AutoService; +import datadog.appsec.api.blocking.BlockingException; +import datadog.trace.advice.ActiveRequestContext; +import datadog.trace.advice.RequiresRequestContext; +import datadog.trace.agent.tooling.Instrumenter; +import datadog.trace.agent.tooling.InstrumenterModule; +import datadog.trace.agent.tooling.muzzle.Reference; +import datadog.trace.api.gateway.BlockResponseFunction; +import datadog.trace.api.gateway.CallbackProvider; +import datadog.trace.api.gateway.Flow; +import datadog.trace.api.gateway.RequestContext; +import datadog.trace.api.gateway.RequestContextSlot; +import datadog.trace.bootstrap.CallDepthThreadLocalMap; +import datadog.trace.bootstrap.instrumentation.api.AgentTracer; +import datadog.trace.instrumentation.play26.MuzzleReferences; +import java.util.function.BiFunction; +import net.bytebuddy.asm.Advice; +import play.mvc.StatusHeader; + +@AutoService(InstrumenterModule.class) +public class StatusHeaderInstrumentation extends InstrumenterModule.AppSec + implements Instrumenter.ForSingleType, Instrumenter.HasMethodAdvice { + + public StatusHeaderInstrumentation() { + super("play"); + } + + @Override + public String muzzleDirective() { + return "play26Plus"; + } + + @Override + public Reference[] additionalMuzzleReferences() { + return MuzzleReferences.PLAY_26_PLUS; // force failure in <2.6 + } + + @Override + public String instrumentedType() { + return "play.mvc.StatusHeader"; + } + + @Override + public void methodAdvice(MethodTransformer transformer) { + transformer.applyAdvice( + named("sendJson").and(takesArgument(0, named("com.fasterxml.jackson.databind.JsonNode"))), + StatusHeaderInstrumentation.class.getName() + "$StatusHeaderSendJsonAdvice"); + } + + @RequiresRequestContext(RequestContextSlot.APPSEC) + public static class StatusHeaderSendJsonAdvice { + + @Advice.OnMethodEnter(suppress = Throwable.class) + static void before() { + CallDepthThreadLocalMap.incrementCallDepth(StatusHeader.class); + } + + @Advice.OnMethodExit(suppress = Throwable.class, onThrowable = Throwable.class) + static void after( + @Advice.Argument(0) final JsonNode json, @ActiveRequestContext RequestContext reqCtx) { + final int depth = CallDepthThreadLocalMap.decrementCallDepth(StatusHeader.class); + if (depth > 0) { + return; + } + + if (json == null) { + return; + } + + CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); + if (cbp == null) { + return; + } + BiFunction> callback = + cbp.getCallback(EVENTS.responseBody()); + if (callback == null) { + return; + } + + Flow flow = callback.apply(reqCtx, json); + Flow.Action action = flow.getAction(); + if (action instanceof Flow.Action.RequestBlockingAction) { + BlockResponseFunction blockResponseFunction = reqCtx.getBlockResponseFunction(); + if (blockResponseFunction == null) { + return; + } + Flow.Action.RequestBlockingAction rba = (Flow.Action.RequestBlockingAction) action; + blockResponseFunction.tryCommitBlockingResponse( + reqCtx.getTraceSegment(), + rba.getStatusCode(), + rba.getBlockingContentType(), + rba.getExtraHeaders()); + + throw new BlockingException("Blocked request (for StatusHeader/sendJson)"); + } + } + } +} diff --git a/dd-smoke-tests/play-2.5/app/controllers/AppSecController.scala b/dd-smoke-tests/play-2.5/app/controllers/AppSecController.scala index bbee19ee22e..1456b5441a3 100644 --- a/dd-smoke-tests/play-2.5/app/controllers/AppSecController.scala +++ b/dd-smoke-tests/play-2.5/app/controllers/AppSecController.scala @@ -1,11 +1,18 @@ package controllers -import play.api.mvc.{Action, AnyContent, Controller} +import play.api.mvc.{Action, AnyContent, AnyContentAsJson, Controller} class AppSecController extends Controller { - def apiSecuritySampling(statusCode: Int, test: String): Action[AnyContent] = Action { + def apiSecuritySampling(statusCode: Int, test: String) = Action { Status(statusCode)("EXECUTED") } + def apiResponse(): Action[AnyContent] = Action { request => + request.body match { + case AnyContentAsJson(data) => Ok(data).as("application/json") + case _ => BadRequest("No JSON") + } + } + } diff --git a/dd-smoke-tests/play-2.5/conf/routes b/dd-smoke-tests/play-2.5/conf/routes index 0520cfe1842..d7f24a29a24 100644 --- a/dd-smoke-tests/play-2.5/conf/routes +++ b/dd-smoke-tests/play-2.5/conf/routes @@ -9,3 +9,4 @@ GET /welcomes controllers.SController.doGet(id: Op # AppSec endpoints for testing GET /api_security/sampling/:statusCode controllers.AppSecController.apiSecuritySampling(statusCode: Int, test: String) +POST /api_security/response controllers.AppSecController.apiResponse() diff --git a/dd-smoke-tests/play-2.5/src/test/groovy/datadog/smoketest/AppSecPlayNettySmokeTest.groovy b/dd-smoke-tests/play-2.5/src/test/groovy/datadog/smoketest/AppSecPlayNettySmokeTest.groovy index 60e2978eb53..53400ba5ff8 100644 --- a/dd-smoke-tests/play-2.5/src/test/groovy/datadog/smoketest/AppSecPlayNettySmokeTest.groovy +++ b/dd-smoke-tests/play-2.5/src/test/groovy/datadog/smoketest/AppSecPlayNettySmokeTest.groovy @@ -2,11 +2,16 @@ package datadog.smoketest import datadog.smoketest.appsec.AbstractAppSecServerSmokeTest import datadog.trace.agent.test.utils.OkHttpUtils +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import okhttp3.MediaType import okhttp3.Request +import okhttp3.RequestBody import okhttp3.Response import spock.lang.Shared import java.nio.file.Files +import java.util.zip.GZIPInputStream import static java.util.concurrent.TimeUnit.SECONDS @@ -70,6 +75,37 @@ class AppSecPlayNettySmokeTest extends AbstractAppSecServerSmokeTest { span.meta.containsKey('_dd.appsec.s.req.headers') } + void 'test response schema extraction'() { + given: + def url = "http://localhost:${httpPort}/api_security/response" + def client = OkHttpUtils.clientBuilder().build() + def body = [ + "main" : [["key": "id001", "value": 1345.67], ["value": 1567.89, "key": "id002"]], + "nullable": null, + ] + def request = new Request.Builder() + .url(url) + .post(RequestBody.create(MediaType.get('application/json'), JsonOutput.toJson(body))) + .build() + + when: + final response = client.newCall(request).execute() + waitForTraceCount(1) + + then: + response.code() == 200 + def span = rootSpans.first() + span.meta.containsKey('_dd.appsec.s.res.headers') + span.meta.containsKey('_dd.appsec.s.res.body') + final schema = new JsonSlurper().parse(unzip(span.meta.get('_dd.appsec.s.res.body'))) + assert schema == [["main": [[[["key": [8], "value": [16]]]], ["len": 2]], "nullable": [1]]] + } + + private static byte[] unzip(final String text) { + final inflaterStream = new GZIPInputStream(new ByteArrayInputStream(text.decodeBase64())) + return inflaterStream.getBytes() + } + // Ensure to clean up server and not only the shell script that starts it def cleanupSpec() { def pid = runningServerPid() diff --git a/dd-smoke-tests/play-2.6/app/controllers/AppSecController.scala b/dd-smoke-tests/play-2.6/app/controllers/AppSecController.scala index cad6476d2ea..f699d64ac9c 100644 --- a/dd-smoke-tests/play-2.6/app/controllers/AppSecController.scala +++ b/dd-smoke-tests/play-2.6/app/controllers/AppSecController.scala @@ -10,4 +10,11 @@ class AppSecController @Inject() (cc: ControllerComponents) extends AbstractCont Status(statusCode)("EXECUTED") } + def apiResponse(): Action[AnyContent] = Action { request => + request.body match { + case AnyContentAsJson(data) => Ok(data).as("application/json") + case _ => BadRequest("No JSON") + } + } + } diff --git a/dd-smoke-tests/play-2.6/conf/routes b/dd-smoke-tests/play-2.6/conf/routes index 0520cfe1842..d7f24a29a24 100644 --- a/dd-smoke-tests/play-2.6/conf/routes +++ b/dd-smoke-tests/play-2.6/conf/routes @@ -9,3 +9,4 @@ GET /welcomes controllers.SController.doGet(id: Op # AppSec endpoints for testing GET /api_security/sampling/:statusCode controllers.AppSecController.apiSecuritySampling(statusCode: Int, test: String) +POST /api_security/response controllers.AppSecController.apiResponse() diff --git a/dd-smoke-tests/play-2.6/src/test/groovy/datadog/smoketest/AppSecPlaySmokeTest.groovy b/dd-smoke-tests/play-2.6/src/test/groovy/datadog/smoketest/AppSecPlaySmokeTest.groovy index 9c98fb7616a..47191211834 100644 --- a/dd-smoke-tests/play-2.6/src/test/groovy/datadog/smoketest/AppSecPlaySmokeTest.groovy +++ b/dd-smoke-tests/play-2.6/src/test/groovy/datadog/smoketest/AppSecPlaySmokeTest.groovy @@ -2,11 +2,16 @@ package datadog.smoketest import datadog.smoketest.appsec.AbstractAppSecServerSmokeTest import datadog.trace.agent.test.utils.OkHttpUtils +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import okhttp3.MediaType import okhttp3.Request +import okhttp3.RequestBody import okhttp3.Response import spock.lang.Shared import java.nio.file.Files +import java.util.zip.GZIPInputStream import static java.util.concurrent.TimeUnit.SECONDS @@ -75,6 +80,37 @@ abstract class AppSecPlaySmokeTest extends AbstractAppSecServerSmokeTest { span.meta.containsKey('_dd.appsec.s.req.headers') } + void 'test response schema extraction'() { + given: + def url = "http://localhost:${httpPort}/api_security/response" + def client = OkHttpUtils.clientBuilder().build() + def body = [ + "main" : [["key": "id001", "value": 1345.67], ["value": 1567.89, "key": "id002"]], + "nullable": null, + ] + def request = new Request.Builder() + .url(url) + .post(RequestBody.create(MediaType.get('application/json'), JsonOutput.toJson(body))) + .build() + + when: + final response = client.newCall(request).execute() + waitForTraceCount(1) + + then: + response.code() == 200 + def span = rootSpans.first() + span.meta.containsKey('_dd.appsec.s.res.headers') + span.meta.containsKey('_dd.appsec.s.res.body') + final schema = new JsonSlurper().parse(unzip(span.meta.get('_dd.appsec.s.res.body'))) + assert schema == [["main": [[[["key": [8], "value": [16]]]], ["len": 2]], "nullable": [1]]] + } + + private static byte[] unzip(final String text) { + final inflaterStream = new GZIPInputStream(new ByteArrayInputStream(text.decodeBase64())) + return inflaterStream.getBytes() + } + // Ensure to clean up server and not only the shell script that starts it def cleanupSpec() { diff --git a/dd-smoke-tests/play-2.7/app/controllers/AppSecController.scala b/dd-smoke-tests/play-2.7/app/controllers/AppSecController.scala index cad6476d2ea..f699d64ac9c 100644 --- a/dd-smoke-tests/play-2.7/app/controllers/AppSecController.scala +++ b/dd-smoke-tests/play-2.7/app/controllers/AppSecController.scala @@ -10,4 +10,11 @@ class AppSecController @Inject() (cc: ControllerComponents) extends AbstractCont Status(statusCode)("EXECUTED") } + def apiResponse(): Action[AnyContent] = Action { request => + request.body match { + case AnyContentAsJson(data) => Ok(data).as("application/json") + case _ => BadRequest("No JSON") + } + } + } diff --git a/dd-smoke-tests/play-2.7/conf/routes b/dd-smoke-tests/play-2.7/conf/routes index 0520cfe1842..d7f24a29a24 100644 --- a/dd-smoke-tests/play-2.7/conf/routes +++ b/dd-smoke-tests/play-2.7/conf/routes @@ -9,3 +9,4 @@ GET /welcomes controllers.SController.doGet(id: Op # AppSec endpoints for testing GET /api_security/sampling/:statusCode controllers.AppSecController.apiSecuritySampling(statusCode: Int, test: String) +POST /api_security/response controllers.AppSecController.apiResponse() diff --git a/dd-smoke-tests/play-2.7/src/test/groovy/datadog/smoketest/AppSecPlaySmokeTest.groovy b/dd-smoke-tests/play-2.7/src/test/groovy/datadog/smoketest/AppSecPlaySmokeTest.groovy index 360cf042f6f..86037672cf0 100644 --- a/dd-smoke-tests/play-2.7/src/test/groovy/datadog/smoketest/AppSecPlaySmokeTest.groovy +++ b/dd-smoke-tests/play-2.7/src/test/groovy/datadog/smoketest/AppSecPlaySmokeTest.groovy @@ -2,11 +2,16 @@ package datadog.smoketest import datadog.smoketest.appsec.AbstractAppSecServerSmokeTest import datadog.trace.agent.test.utils.OkHttpUtils +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import okhttp3.MediaType import okhttp3.Request +import okhttp3.RequestBody import okhttp3.Response import spock.lang.Shared import java.nio.file.Files +import java.util.zip.GZIPInputStream import static java.util.concurrent.TimeUnit.SECONDS @@ -74,6 +79,37 @@ abstract class AppSecPlaySmokeTest extends AbstractAppSecServerSmokeTest { span.meta.containsKey('_dd.appsec.s.req.headers') } + void 'test response schema extraction'() { + given: + def url = "http://localhost:${httpPort}/api_security/response" + def client = OkHttpUtils.clientBuilder().build() + def body = [ + "main" : [["key": "id001", "value": 1345.67], ["value": 1567.89, "key": "id002"]], + "nullable": null, + ] + def request = new Request.Builder() + .url(url) + .post(RequestBody.create(MediaType.get('application/json'), JsonOutput.toJson(body))) + .build() + + when: + final response = client.newCall(request).execute() + waitForTraceCount(1) + + then: + response.code() == 200 + def span = rootSpans.first() + span.meta.containsKey('_dd.appsec.s.res.headers') + span.meta.containsKey('_dd.appsec.s.res.body') + final schema = new JsonSlurper().parse(unzip(span.meta.get('_dd.appsec.s.res.body'))) + assert schema == [["main": [[[["key": [8], "value": [16]]]], ["len": 2]], "nullable": [1]]] + } + + private static byte[] unzip(final String text) { + final inflaterStream = new GZIPInputStream(new ByteArrayInputStream(text.decodeBase64())) + return inflaterStream.getBytes() + } + // Ensure to clean up server and not only the shell script that starts it def cleanupSpec() { def pid = runningServerPid() diff --git a/dd-smoke-tests/play-2.8/app/controllers/AppSecController.scala b/dd-smoke-tests/play-2.8/app/controllers/AppSecController.scala index cad6476d2ea..f699d64ac9c 100644 --- a/dd-smoke-tests/play-2.8/app/controllers/AppSecController.scala +++ b/dd-smoke-tests/play-2.8/app/controllers/AppSecController.scala @@ -10,4 +10,11 @@ class AppSecController @Inject() (cc: ControllerComponents) extends AbstractCont Status(statusCode)("EXECUTED") } + def apiResponse(): Action[AnyContent] = Action { request => + request.body match { + case AnyContentAsJson(data) => Ok(data).as("application/json") + case _ => BadRequest("No JSON") + } + } + } diff --git a/dd-smoke-tests/play-2.8/conf/routes b/dd-smoke-tests/play-2.8/conf/routes index 0520cfe1842..d7f24a29a24 100644 --- a/dd-smoke-tests/play-2.8/conf/routes +++ b/dd-smoke-tests/play-2.8/conf/routes @@ -9,3 +9,4 @@ GET /welcomes controllers.SController.doGet(id: Op # AppSec endpoints for testing GET /api_security/sampling/:statusCode controllers.AppSecController.apiSecuritySampling(statusCode: Int, test: String) +POST /api_security/response controllers.AppSecController.apiResponse() diff --git a/dd-smoke-tests/play-2.8/src/test/groovy/datadog/smoketest/AppSecPlaySmokeTest.groovy b/dd-smoke-tests/play-2.8/src/test/groovy/datadog/smoketest/AppSecPlaySmokeTest.groovy index 66077178b7a..935d7506658 100644 --- a/dd-smoke-tests/play-2.8/src/test/groovy/datadog/smoketest/AppSecPlaySmokeTest.groovy +++ b/dd-smoke-tests/play-2.8/src/test/groovy/datadog/smoketest/AppSecPlaySmokeTest.groovy @@ -2,11 +2,16 @@ package datadog.smoketest import datadog.smoketest.appsec.AbstractAppSecServerSmokeTest import datadog.trace.agent.test.utils.OkHttpUtils +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import okhttp3.MediaType import okhttp3.Request +import okhttp3.RequestBody import okhttp3.Response import spock.lang.Shared import java.nio.file.Files +import java.util.zip.GZIPInputStream import static java.util.concurrent.TimeUnit.SECONDS @@ -75,6 +80,37 @@ abstract class AppSecPlaySmokeTest extends AbstractAppSecServerSmokeTest { span.meta.containsKey('_dd.appsec.s.req.headers') } + void 'test response schema extraction'() { + given: + def url = "http://localhost:${httpPort}/api_security/response" + def client = OkHttpUtils.clientBuilder().build() + def body = [ + "main" : [["key": "id001", "value": 1345.67], ["value": 1567.89, "key": "id002"]], + "nullable": null, + ] + def request = new Request.Builder() + .url(url) + .post(RequestBody.create(MediaType.get('application/json'), JsonOutput.toJson(body))) + .build() + + when: + final response = client.newCall(request).execute() + waitForTraceCount(1) + + then: + response.code() == 200 + def span = rootSpans.first() + span.meta.containsKey('_dd.appsec.s.res.headers') + span.meta.containsKey('_dd.appsec.s.res.body') + final schema = new JsonSlurper().parse(unzip(span.meta.get('_dd.appsec.s.res.body'))) + assert schema == [["main": [[[["key": [8], "value": [16]]]], ["len": 2]], "nullable": [1]]] + } + + private static byte[] unzip(final String text) { + final inflaterStream = new GZIPInputStream(new ByteArrayInputStream(text.decodeBase64())) + return inflaterStream.getBytes() + } + // Ensure to clean up server and not only the shell script that starts it def cleanupSpec() { def pid = runningServerPid()