diff --git a/dd-java-agent/agent-tooling/src/main/resources/datadog/trace/agent/tooling/bytebuddy/matcher/ignored_class_name.trie b/dd-java-agent/agent-tooling/src/main/resources/datadog/trace/agent/tooling/bytebuddy/matcher/ignored_class_name.trie index 95b54a47715..18a88747eec 100644 --- a/dd-java-agent/agent-tooling/src/main/resources/datadog/trace/agent/tooling/bytebuddy/matcher/ignored_class_name.trie +++ b/dd-java-agent/agent-tooling/src/main/resources/datadog/trace/agent/tooling/bytebuddy/matcher/ignored_class_name.trie @@ -190,6 +190,8 @@ 0 com.fasterxml.jackson.databind.util.TokenBuffer$Parser 0 com.fasterxml.jackson.databind.ObjectMapper 0 com.fasterxml.jackson.module.afterburner.util.MyClassLoader +# Included for API Security response schema collection +0 com.fasterxml.jackson.jaxrs.* 2 com.github.mustachejava.* 2 com.google.api.* 0 com.google.api.client.http.HttpRequest diff --git a/dd-java-agent/instrumentation/jakarta-rs-annotations-3/src/main/java/datadog/trace/instrumentation/jakarta3/MessageBodyWriterInstrumentation.java b/dd-java-agent/instrumentation/jakarta-rs-annotations-3/src/main/java/datadog/trace/instrumentation/jakarta3/MessageBodyWriterInstrumentation.java new file mode 100644 index 00000000000..9604b5ceac6 --- /dev/null +++ b/dd-java-agent/instrumentation/jakarta-rs-annotations-3/src/main/java/datadog/trace/instrumentation/jakarta3/MessageBodyWriterInstrumentation.java @@ -0,0 +1,87 @@ +package datadog.trace.instrumentation.jakarta3; + +import static datadog.trace.agent.tooling.bytebuddy.matcher.HierarchyMatchers.implementsInterface; +import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named; +import static datadog.trace.api.gateway.Events.EVENTS; +import static net.bytebuddy.matcher.ElementMatchers.takesArguments; + +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.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 jakarta.ws.rs.core.MediaType; +import java.util.function.BiFunction; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.matcher.ElementMatcher; + +@AutoService(InstrumenterModule.class) +public class MessageBodyWriterInstrumentation extends InstrumenterModule.AppSec + implements Instrumenter.ForTypeHierarchy, Instrumenter.HasMethodAdvice { + + public MessageBodyWriterInstrumentation() { + super("jakarta-rs"); + } + + @Override + public String hierarchyMarkerType() { + return "jakarta.ws.rs.ext.MessageBodyWriter"; + } + + @Override + public ElementMatcher hierarchyMatcher() { + return implementsInterface(named(hierarchyMarkerType())); + } + + @Override + public void methodAdvice(MethodTransformer transformer) { + transformer.applyAdvice( + named("writeTo").and(takesArguments(7)), getClass().getName() + "$MessageBodyWriterAdvice"); + } + + @RequiresRequestContext(RequestContextSlot.APPSEC) + public static class MessageBodyWriterAdvice { + @Advice.OnMethodEnter(suppress = Throwable.class) + static void before( + @Advice.Argument(0) Object entity, + @Advice.Argument(4) MediaType mediaType, + @ActiveRequestContext RequestContext reqCtx) { + + if (!MediaType.APPLICATION_JSON_TYPE.isCompatible(mediaType)) { + return; + } + + CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); + BiFunction> callback = + cbp.getCallback(EVENTS.responseBody()); + if (callback == null) { + return; + } + + Flow flow = callback.apply(reqCtx, entity); + 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 MessageBodyWriter)"); + } + } + } +} diff --git a/dd-java-agent/instrumentation/jax-rs-annotations-2/build.gradle b/dd-java-agent/instrumentation/jax-rs-annotations-2/build.gradle index 5b04967c113..50c9a86c164 100644 --- a/dd-java-agent/instrumentation/jax-rs-annotations-2/build.gradle +++ b/dd-java-agent/instrumentation/jax-rs-annotations-2/build.gradle @@ -10,6 +10,12 @@ muzzle { module = "javax.ws.rs-api" versions = "[,]" } + pass { + group = "javax.ws.rs" + module = "javax.ws.rs-api" + name = 'javax-message-body-writer' + versions = "[,]" + } } apply from: "$rootDir/gradle/java.gradle" diff --git a/dd-java-agent/instrumentation/jax-rs-annotations-2/src/main/java/datadog/trace/instrumentation/jaxrs2/MessageBodyWriterInstrumentation.java b/dd-java-agent/instrumentation/jax-rs-annotations-2/src/main/java/datadog/trace/instrumentation/jaxrs2/MessageBodyWriterInstrumentation.java new file mode 100644 index 00000000000..c546f5fbda3 --- /dev/null +++ b/dd-java-agent/instrumentation/jax-rs-annotations-2/src/main/java/datadog/trace/instrumentation/jaxrs2/MessageBodyWriterInstrumentation.java @@ -0,0 +1,92 @@ +package datadog.trace.instrumentation.jaxrs2; + +import static datadog.trace.agent.tooling.bytebuddy.matcher.HierarchyMatchers.implementsInterface; +import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named; +import static datadog.trace.api.gateway.Events.EVENTS; +import static net.bytebuddy.matcher.ElementMatchers.takesArguments; + +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.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 javax.ws.rs.core.MediaType; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.matcher.ElementMatcher; + +@AutoService(InstrumenterModule.class) +public class MessageBodyWriterInstrumentation extends InstrumenterModule.AppSec + implements Instrumenter.ForTypeHierarchy, Instrumenter.HasMethodAdvice { + + public MessageBodyWriterInstrumentation() { + super("jax-rs"); + } + + @Override + public String muzzleDirective() { + return "javax-message-body-writer"; + } + + @Override + public String hierarchyMarkerType() { + return "javax.ws.rs.ext.MessageBodyWriter"; + } + + @Override + public ElementMatcher hierarchyMatcher() { + return implementsInterface(named(hierarchyMarkerType())); + } + + @Override + public void methodAdvice(MethodTransformer transformer) { + transformer.applyAdvice( + named("writeTo").and(takesArguments(7)), getClass().getName() + "$MessageBodyWriterAdvice"); + } + + @RequiresRequestContext(RequestContextSlot.APPSEC) + public static class MessageBodyWriterAdvice { + @Advice.OnMethodEnter(suppress = Throwable.class) + static void before( + @Advice.Argument(0) Object entity, + @Advice.Argument(4) MediaType mediaType, + @ActiveRequestContext RequestContext reqCtx) { + + if (!MediaType.APPLICATION_JSON_TYPE.isCompatible(mediaType)) { + return; + } + + CallbackProvider cbp = AgentTracer.get().getCallbackProvider(RequestContextSlot.APPSEC); + BiFunction> callback = + cbp.getCallback(EVENTS.responseBody()); + if (callback == null) { + return; + } + + Flow flow = callback.apply(reqCtx, entity); + 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 MessageBodyWriter)"); + } + } + } +} diff --git a/dd-java-agent/instrumentation/jersey/build.gradle b/dd-java-agent/instrumentation/jersey/build.gradle index 4ba678ee51f..6994d611389 100644 --- a/dd-java-agent/instrumentation/jersey/build.gradle +++ b/dd-java-agent/instrumentation/jersey/build.gradle @@ -56,6 +56,7 @@ dependencies { jersey2JettyTestRuntimeOnly group: 'javax.xml.bind', name: 'jaxb-api', version: '2.2.3' jersey2JettyTestRuntimeOnly project(':dd-java-agent:instrumentation:jetty-9') jersey2JettyTestRuntimeOnly project(':dd-java-agent:instrumentation:jersey-2-appsec') + jersey2JettyTestRuntimeOnly project(':dd-java-agent:instrumentation:jax-rs-annotations-2') jersey3JettyTestImplementation project(':dd-java-agent:testing'), { exclude group: 'org.eclipse.jetty', module: 'jetty-server' @@ -72,6 +73,7 @@ dependencies { jersey3JettyTestRuntimeOnly project(':dd-java-agent:instrumentation:jetty-11') jersey3JettyTestRuntimeOnly project(':dd-java-agent:instrumentation:jersey-2-appsec') jersey3JettyTestRuntimeOnly project(':dd-java-agent:instrumentation:jersey-3-appsec') + jersey3JettyTestRuntimeOnly project(':dd-java-agent:instrumentation:jakarta-rs-annotations-3') } configurations.getByName('jersey3JettyTestRuntimeClasspath').resolutionStrategy { diff --git a/dd-java-agent/instrumentation/jersey/src/jersey2JettyTest/groovy/datadog/trace/instrumentation/jersey2/ClassToConvertBodyTo.groovy b/dd-java-agent/instrumentation/jersey/src/jersey2JettyTest/groovy/datadog/trace/instrumentation/jersey2/ClassToConvertBodyTo.groovy index b9241a2daa2..03770bb3642 100644 --- a/dd-java-agent/instrumentation/jersey/src/jersey2JettyTest/groovy/datadog/trace/instrumentation/jersey2/ClassToConvertBodyTo.groovy +++ b/dd-java-agent/instrumentation/jersey/src/jersey2JettyTest/groovy/datadog/trace/instrumentation/jersey2/ClassToConvertBodyTo.groovy @@ -1,5 +1,12 @@ package datadog.trace.instrumentation.jersey2 +import groovy.json.JsonBuilder + class ClassToConvertBodyTo { String a + + @Override + String toString() { + new JsonBuilder([a: a]).toString() + } } diff --git a/dd-java-agent/instrumentation/jersey/src/jersey2JettyTest/groovy/datadog/trace/instrumentation/jersey2/Jersey2JettyTest.groovy b/dd-java-agent/instrumentation/jersey/src/jersey2JettyTest/groovy/datadog/trace/instrumentation/jersey2/Jersey2JettyTest.groovy index a1d8873c63b..3fa5a4672ad 100644 --- a/dd-java-agent/instrumentation/jersey/src/jersey2JettyTest/groovy/datadog/trace/instrumentation/jersey2/Jersey2JettyTest.groovy +++ b/dd-java-agent/instrumentation/jersey/src/jersey2JettyTest/groovy/datadog/trace/instrumentation/jersey2/Jersey2JettyTest.groovy @@ -9,6 +9,11 @@ import javax.ws.rs.ext.ExceptionMapper class Jersey2JettyTest extends HttpServerTest { + @Override + boolean testResponseBodyJson() { + return true + } + @Override HttpServer server() { new JettyServer() diff --git a/dd-java-agent/instrumentation/jersey/src/jersey2JettyTest/groovy/datadog/trace/instrumentation/jersey2/ServiceResource.groovy b/dd-java-agent/instrumentation/jersey/src/jersey2JettyTest/groovy/datadog/trace/instrumentation/jersey2/ServiceResource.groovy index 3d8aa7acebf..943361bc13a 100644 --- a/dd-java-agent/instrumentation/jersey/src/jersey2JettyTest/groovy/datadog/trace/instrumentation/jersey2/ServiceResource.groovy +++ b/dd-java-agent/instrumentation/jersey/src/jersey2JettyTest/groovy/datadog/trace/instrumentation/jersey2/ServiceResource.groovy @@ -10,6 +10,7 @@ import javax.ws.rs.HeaderParam import javax.ws.rs.POST import javax.ws.rs.Path import javax.ws.rs.PathParam +import javax.ws.rs.Produces import javax.ws.rs.QueryParam import javax.ws.rs.core.MediaType import javax.ws.rs.core.Response @@ -87,10 +88,14 @@ class ServiceResource { @POST @Path("body-json") + @Produces(MediaType.APPLICATION_JSON) Response bodyJson(ClassToConvertBodyTo obj) { - controller(BODY_JSON) { - Response.status(BODY_JSON.status).entity("""{"a":"${obj.a}"}""" as String).build() - } + return controller(BODY_JSON, () -> { + Response response = Response.status(BODY_JSON.status) + .entity(obj) + .build() + return response + }) } @GET diff --git a/dd-java-agent/instrumentation/jersey/src/jersey3JettyTest/groovy/datadog/trace/instrumentation/jersey3/ClassToConvertBodyTo.groovy b/dd-java-agent/instrumentation/jersey/src/jersey3JettyTest/groovy/datadog/trace/instrumentation/jersey3/ClassToConvertBodyTo.groovy index 43f6f153f71..862045cb5d5 100644 --- a/dd-java-agent/instrumentation/jersey/src/jersey3JettyTest/groovy/datadog/trace/instrumentation/jersey3/ClassToConvertBodyTo.groovy +++ b/dd-java-agent/instrumentation/jersey/src/jersey3JettyTest/groovy/datadog/trace/instrumentation/jersey3/ClassToConvertBodyTo.groovy @@ -1,5 +1,12 @@ package datadog.trace.instrumentation.jersey3 +import groovy.json.JsonBuilder + class ClassToConvertBodyTo { String a + + @Override + String toString() { + new JsonBuilder([a: a]).toString() + } } diff --git a/dd-java-agent/instrumentation/jersey/src/jersey3JettyTest/groovy/datadog/trace/instrumentation/jersey3/Jersey3JettyTest.groovy b/dd-java-agent/instrumentation/jersey/src/jersey3JettyTest/groovy/datadog/trace/instrumentation/jersey3/Jersey3JettyTest.groovy index 16a83a380a0..b8076813660 100644 --- a/dd-java-agent/instrumentation/jersey/src/jersey3JettyTest/groovy/datadog/trace/instrumentation/jersey3/Jersey3JettyTest.groovy +++ b/dd-java-agent/instrumentation/jersey/src/jersey3JettyTest/groovy/datadog/trace/instrumentation/jersey3/Jersey3JettyTest.groovy @@ -8,6 +8,11 @@ import jakarta.ws.rs.ext.ExceptionMapper class Jersey3JettyTest extends HttpServerTest { + @Override + boolean testResponseBodyJson() { + return true + } + @Override HttpServer server() { new JettyServer() diff --git a/dd-java-agent/instrumentation/jersey/src/jersey3JettyTest/groovy/datadog/trace/instrumentation/jersey3/ServiceResource.groovy b/dd-java-agent/instrumentation/jersey/src/jersey3JettyTest/groovy/datadog/trace/instrumentation/jersey3/ServiceResource.groovy index 07ce69be498..74dcdc7ed1a 100644 --- a/dd-java-agent/instrumentation/jersey/src/jersey3JettyTest/groovy/datadog/trace/instrumentation/jersey3/ServiceResource.groovy +++ b/dd-java-agent/instrumentation/jersey/src/jersey3JettyTest/groovy/datadog/trace/instrumentation/jersey3/ServiceResource.groovy @@ -1,6 +1,7 @@ package datadog.trace.instrumentation.jersey3 import datadog.appsec.api.blocking.Blocking +import jakarta.ws.rs.Produces import org.glassfish.jersey.media.multipart.FormDataParam import jakarta.ws.rs.Consumes @@ -87,10 +88,13 @@ class ServiceResource { @POST @Path("body-json") + @Produces(MediaType.APPLICATION_JSON) Response bodyJson(ClassToConvertBodyTo obj) { - controller(BODY_JSON) { - Response.status(BODY_JSON.status).entity("""{"a":"${obj.a}"}""" as String).build() - } + controller(BODY_JSON, () -> + Response.status(BODY_JSON.status) + .entity(obj) + .build() + ) } @GET diff --git a/dd-smoke-tests/jersey-2/build.gradle b/dd-smoke-tests/jersey-2/build.gradle index c7284c835d1..5e7b2645269 100644 --- a/dd-smoke-tests/jersey-2/build.gradle +++ b/dd-smoke-tests/jersey-2/build.gradle @@ -20,6 +20,7 @@ dependencies { implementation group: 'javax.xml', name: 'jaxb-api', version:'2.1' testImplementation project(':dd-smoke-tests') testImplementation(testFixtures(project(":dd-smoke-tests:iast-util"))) + testImplementation project(':dd-smoke-tests:appsec') } tasks.withType(Test).configureEach { diff --git a/dd-smoke-tests/jersey-2/src/main/java/com/restserver/RequestBody.java b/dd-smoke-tests/jersey-2/src/main/java/com/restserver/RequestBody.java new file mode 100644 index 00000000000..535cab104cd --- /dev/null +++ b/dd-smoke-tests/jersey-2/src/main/java/com/restserver/RequestBody.java @@ -0,0 +1,45 @@ +package com.restserver; + +import java.util.List; + +public class RequestBody { + private List main; + private Object nullable; + + public List getMain() { + return main; + } + + public void setMain(List main) { + this.main = main; + } + + public Object getNullable() { + return nullable; + } + + public void setNullable(Object nullable) { + this.nullable = nullable; + } + + public static class KeyValue { + private String key; + private Double value; + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + public Double getValue() { + return value; + } + + public void setValue(Double value) { + this.value = value; + } + } +} diff --git a/dd-smoke-tests/jersey-2/src/main/java/com/restserver/Resource.java b/dd-smoke-tests/jersey-2/src/main/java/com/restserver/Resource.java index 246da86eca7..2538b616a34 100644 --- a/dd-smoke-tests/jersey-2/src/main/java/com/restserver/Resource.java +++ b/dd-smoke-tests/jersey-2/src/main/java/com/restserver/Resource.java @@ -139,4 +139,18 @@ public Response responseLocation(@QueryParam("param") String param) throws URISy public Response getCookie() throws SQLException { return Response.ok().cookie(new NewCookie("user-id", "7")).build(); } + + @Path("/api_security/response") + @POST + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + public Response bodyJson(RequestBody input) { + return Response.ok(input).build(); + } + + @GET + @Path("/api_security/sampling/{i}") + public Response apiSecuritySamplingWithStatus(@PathParam("i") int i) { + return Response.status(i).header("content-type", "text/plain").entity("Hello!\n").build(); + } } diff --git a/dd-smoke-tests/jersey-2/src/test/groovy/datadog/smoketest/Jersey2AppsecSmokeTest.groovy b/dd-smoke-tests/jersey-2/src/test/groovy/datadog/smoketest/Jersey2AppsecSmokeTest.groovy new file mode 100644 index 00000000000..ee70d63feb2 --- /dev/null +++ b/dd-smoke-tests/jersey-2/src/test/groovy/datadog/smoketest/Jersey2AppsecSmokeTest.groovy @@ -0,0 +1,99 @@ +package datadog.smoketest + +import datadog.smoketest.appsec.AbstractAppSecServerSmokeTest +import datadog.trace.agent.test.utils.OkHttpUtils +import datadog.trace.api.Platform +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import okhttp3.MediaType +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.Response + +import java.util.zip.GZIPInputStream + +class Jersey2AppsecSmokeTest extends AbstractAppSecServerSmokeTest{ + + @Override + ProcessBuilder createProcessBuilder() { + String jarPath = System.getProperty('datadog.smoketest.jersey2.jar.path') + + List command = [] + command.add(javaPath()) + command.addAll(defaultJavaProperties) + command.addAll(defaultAppSecProperties) + command.add('-Ddd.integration.grizzly.enabled=true') + if (Platform.isJavaVersionAtLeast(17)) { + command.addAll((String[]) ['--add-opens', 'java.base/java.lang=ALL-UNNAMED']) + } + command.addAll(['-jar', jarPath, Integer.toString(httpPort)]) + ProcessBuilder processBuilder = new ProcessBuilder(command) + processBuilder.directory(new File(buildDirectory)) + return processBuilder + } + + void 'API Security samples only one request per endpoint'() { + given: + def url = "http://localhost:${httpPort}/hello/api_security/sampling/200?test=value" + def request = new Request.Builder() + .url(url) + .addHeader('X-My-Header', "value") + .get() + .build() + + when: + List responses = (1..3).collect { + client.newCall(request).execute() + } + + then: + responses.each { + assert it.code() == 200 + } + waitForTraceCount(3) + def spans = rootSpans.toList().toSorted { it.span.duration } + spans.size() == 3 + def sampledSpans = spans.findAll { + it.meta.keySet().any { + it.startsWith('_dd.appsec.s.req.') + } + } + sampledSpans.size() == 1 + def span = sampledSpans[0] + span.meta.containsKey('_dd.appsec.s.req.query') + span.meta.containsKey('_dd.appsec.s.req.params') + span.meta.containsKey('_dd.appsec.s.req.headers') + } + + + void 'test response schema extraction'() { + given: + def url = "http://localhost:${httpPort}/hello/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() + } +} diff --git a/dd-smoke-tests/jersey-3/build.gradle b/dd-smoke-tests/jersey-3/build.gradle index e98032c4a91..8b59df9d3f4 100644 --- a/dd-smoke-tests/jersey-3/build.gradle +++ b/dd-smoke-tests/jersey-3/build.gradle @@ -14,11 +14,13 @@ jar { dependencies { implementation group: 'org.glassfish.jersey.containers', name: 'jersey-container-grizzly2-http', version:'3.0.2' + implementation 'org.glassfish.jersey.media:jersey-media-json-jackson:3.0.2' implementation group: 'org.glassfish.jersey.inject', name: 'jersey-hk2', version:'3.0.2' implementation group: 'org.glassfish.hk2', name: 'hk2-metadata-generator', version:'3.0.2' implementation group: 'jakarta.activation', name: 'jakarta.activation-api', version:'2.0.1' testImplementation project(':dd-smoke-tests') testImplementation(testFixtures(project(":dd-smoke-tests:iast-util"))) + testImplementation project(':dd-smoke-tests:appsec') } tasks.withType(Test).configureEach { diff --git a/dd-smoke-tests/jersey-3/src/main/java/smoketest/MainApp.java b/dd-smoke-tests/jersey-3/src/main/java/smoketest/MainApp.java index cadbcd20b47..ba2f6eced0d 100644 --- a/dd-smoke-tests/jersey-3/src/main/java/smoketest/MainApp.java +++ b/dd-smoke-tests/jersey-3/src/main/java/smoketest/MainApp.java @@ -7,6 +7,7 @@ import org.glassfish.grizzly.http.server.HttpServer; import org.glassfish.jersey.grizzly2.httpserver.GrizzlyHttpServerFactory; import org.glassfish.jersey.internal.inject.ParamConverters.StringConstructor; +import org.glassfish.jersey.jackson.JacksonFeature; import org.glassfish.jersey.server.ResourceConfig; import smoketest.config.AutoScanFeature; @@ -29,6 +30,8 @@ public static HttpServer startServer(String httpPort) { // enable auto scan @Contract and @Service config.register(AutoScanFeature.class); + config.register(JacksonFeature.class); + LOGGER.info("Starting Server........"); URI uri = URI.create(BASE_URI + httpPort + "/"); diff --git a/dd-smoke-tests/jersey-3/src/main/java/smoketest/RequestBody.java b/dd-smoke-tests/jersey-3/src/main/java/smoketest/RequestBody.java new file mode 100644 index 00000000000..1c1cc183f41 --- /dev/null +++ b/dd-smoke-tests/jersey-3/src/main/java/smoketest/RequestBody.java @@ -0,0 +1,45 @@ +package smoketest; + +import java.util.List; + +public class RequestBody { + private List main; + private Object nullable; + + public List getMain() { + return main; + } + + public void setMain(List main) { + this.main = main; + } + + public Object getNullable() { + return nullable; + } + + public void setNullable(Object nullable) { + this.nullable = nullable; + } + + public static class KeyValue { + private String key; + private Double value; + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + public Double getValue() { + return value; + } + + public void setValue(Double value) { + this.value = value; + } + } +} diff --git a/dd-smoke-tests/jersey-3/src/main/java/smoketest/Resource.java b/dd-smoke-tests/jersey-3/src/main/java/smoketest/Resource.java index b872edc6849..9e5d7364ca3 100644 --- a/dd-smoke-tests/jersey-3/src/main/java/smoketest/Resource.java +++ b/dd-smoke-tests/jersey-3/src/main/java/smoketest/Resource.java @@ -1,5 +1,6 @@ package smoketest; +import jakarta.ws.rs.Consumes; import jakarta.ws.rs.CookieParam; import jakarta.ws.rs.FormParam; import jakarta.ws.rs.GET; @@ -129,4 +130,18 @@ public Response responseLocation(@QueryParam("param") String param) throws URISy public Response getCookie() throws SQLException { return Response.ok().cookie(new NewCookie("user-id", "7")).build(); } + + @Path("/api_security/response") + @POST + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + public Response bodyJson(RequestBody input) { + return Response.ok(input).build(); + } + + @GET + @Path("/api_security/sampling/{i}") + public Response apiSecuritySamplingWithStatus(@PathParam("i") int i) { + return Response.status(i).header("content-type", "text/plain").entity("Hello!\n").build(); + } } diff --git a/dd-smoke-tests/jersey-3/src/test/groovy/datadog/smoketest/Jersey3AppsecSmokeTest.groovy b/dd-smoke-tests/jersey-3/src/test/groovy/datadog/smoketest/Jersey3AppsecSmokeTest.groovy new file mode 100644 index 00000000000..79efd0280b3 --- /dev/null +++ b/dd-smoke-tests/jersey-3/src/test/groovy/datadog/smoketest/Jersey3AppsecSmokeTest.groovy @@ -0,0 +1,95 @@ +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 java.util.zip.GZIPInputStream + +class Jersey3AppsecSmokeTest extends AbstractAppSecServerSmokeTest { + + @Override + ProcessBuilder createProcessBuilder() { + String jarPath = System.getProperty('datadog.smoketest.jersey3.jar.path') + + List command = [] + command.add(javaPath()) + command.addAll(defaultJavaProperties) + command.addAll(defaultAppSecProperties) + command.add('-Ddd.integration.grizzly.enabled=true') + command.addAll(['-jar', jarPath, Integer.toString(httpPort)]) + ProcessBuilder processBuilder = new ProcessBuilder(command) + processBuilder.directory(new File(buildDirectory)) + return processBuilder + } + + void 'API Security samples only one request per endpoint'() { + given: + def url = "http://localhost:${httpPort}/hello/api_security/sampling/200?test=value" + def request = new Request.Builder() + .url(url) + .addHeader('X-My-Header', "value") + .get() + .build() + + when: + List responses = (1..3).collect { + client.newCall(request).execute() + } + + then: + responses.each { + assert it.code() == 200 + } + waitForTraceCount(3) + def spans = rootSpans.toList().toSorted { it.span.duration } + spans.size() == 3 + def sampledSpans = spans.findAll { + it.meta.keySet().any { + it.startsWith('_dd.appsec.s.req.') + } + } + sampledSpans.size() == 1 + def span = sampledSpans[0] + span.meta.containsKey('_dd.appsec.s.req.query') + span.meta.containsKey('_dd.appsec.s.req.params') + span.meta.containsKey('_dd.appsec.s.req.headers') + } + + + void 'test response schema extraction'() { + given: + def url = "http://localhost:${httpPort}/hello/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() + } +} diff --git a/dd-smoke-tests/jersey-3/src/test/groovy/datadog/smoketest/Jersey3SmokeTest.groovy b/dd-smoke-tests/jersey-3/src/test/groovy/datadog/smoketest/Jersey3SmokeTest.groovy index 4ee050d62f4..62b6641fbf6 100644 --- a/dd-smoke-tests/jersey-3/src/test/groovy/datadog/smoketest/Jersey3SmokeTest.groovy +++ b/dd-smoke-tests/jersey-3/src/test/groovy/datadog/smoketest/Jersey3SmokeTest.groovy @@ -17,8 +17,6 @@ class Jersey3SmokeTest extends AbstractJerseySmokeTest { command.addAll(defaultJavaProperties) command.addAll(iastJvmOpts()) command.add(withSystemProperty('integration.grizzly.enabled', true)) - //command.add("-Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000") - //command.add("-Xdebug") command.addAll(['-jar', jarPath, Integer.toString(httpPort)]) ProcessBuilder processBuilder = new ProcessBuilder(command) processBuilder.directory(new File(buildDirectory))