diff --git a/dd-java-agent/instrumentation/ratpack-1.5/src/main/java/datadog/trace/instrumentation/ratpack/JsonRendererAdvice.java b/dd-java-agent/instrumentation/ratpack-1.5/src/main/java/datadog/trace/instrumentation/ratpack/JsonRendererAdvice.java new file mode 100644 index 00000000000..bba023cba05 --- /dev/null +++ b/dd-java-agent/instrumentation/ratpack-1.5/src/main/java/datadog/trace/instrumentation/ratpack/JsonRendererAdvice.java @@ -0,0 +1,55 @@ +package datadog.trace.instrumentation.ratpack; + +import static datadog.trace.api.gateway.Events.EVENTS; + +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 ratpack.jackson.JsonRender; + +@RequiresRequestContext(RequestContextSlot.APPSEC) +public class JsonRendererAdvice { + + @Advice.OnMethodEnter(suppress = Throwable.class) + static void enter( + @Advice.Argument(1) final JsonRender render, + @ActiveRequestContext final RequestContext reqCtx) { + Object obj = render == null ? null : render.getObject(); + if (obj == 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, obj); + Flow.Action action = flow.getAction(); + if (action instanceof Flow.Action.RequestBlockingAction) { + BlockResponseFunction brf = reqCtx.getBlockResponseFunction(); + if (brf != null) { + Flow.Action.RequestBlockingAction rba = (Flow.Action.RequestBlockingAction) action; + brf.tryCommitBlockingResponse( + reqCtx.getTraceSegment(), + rba.getStatusCode(), + rba.getBlockingContentType(), + rba.getExtraHeaders()); + + throw new BlockingException("Blocked request (for JsonRenderer/render)"); + } + } + } +} diff --git a/dd-java-agent/instrumentation/ratpack-1.5/src/main/java/datadog/trace/instrumentation/ratpack/JsonRendererInstrumentation.java b/dd-java-agent/instrumentation/ratpack-1.5/src/main/java/datadog/trace/instrumentation/ratpack/JsonRendererInstrumentation.java new file mode 100644 index 00000000000..ccbd274ee70 --- /dev/null +++ b/dd-java-agent/instrumentation/ratpack-1.5/src/main/java/datadog/trace/instrumentation/ratpack/JsonRendererInstrumentation.java @@ -0,0 +1,44 @@ +package datadog.trace.instrumentation.ratpack; + +import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.isMethod; +import static net.bytebuddy.matcher.ElementMatchers.takesArgument; +import static net.bytebuddy.matcher.ElementMatchers.takesArguments; + +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 JsonRendererInstrumentation extends InstrumenterModule.AppSec + implements Instrumenter.ForSingleType, Instrumenter.HasMethodAdvice { + + // so it doesn't apply to ratpack < 1.5 + private static final Reference FILE_IO = new Reference.Builder("ratpack.file.FileIo").build(); + + public JsonRendererInstrumentation() { + super("ratpack"); + } + + @Override + public String instrumentedType() { + return "ratpack.jackson.internal.JsonRenderer"; + } + + @Override + public Reference[] additionalMuzzleReferences() { + return new Reference[] {FILE_IO}; + } + + @Override + public void methodAdvice(MethodTransformer transformer) { + transformer.applyAdvice( + isMethod() + .and(named("render")) + .and(takesArguments(2)) + .and(takesArgument(0, named("ratpack.handling.Context"))) + .and(takesArgument(1, named("ratpack.jackson.JsonRender"))), + packageName + ".JsonRendererAdvice"); + } +} diff --git a/dd-java-agent/instrumentation/ratpack-1.5/src/test/groovy/server/RatpackAsyncHttpServerTest.groovy b/dd-java-agent/instrumentation/ratpack-1.5/src/test/groovy/server/RatpackAsyncHttpServerTest.groovy index 9276bc3743f..fa11d83c32c 100644 --- a/dd-java-agent/instrumentation/ratpack-1.5/src/test/groovy/server/RatpackAsyncHttpServerTest.groovy +++ b/dd-java-agent/instrumentation/ratpack-1.5/src/test/groovy/server/RatpackAsyncHttpServerTest.groovy @@ -10,6 +10,7 @@ import ratpack.exec.Promise import ratpack.form.Form import ratpack.groovy.test.embed.GroovyEmbeddedApp import ratpack.handling.HandlerDecorator +import static ratpack.jackson.Jackson.json import java.nio.charset.StandardCharsets @@ -142,7 +143,8 @@ class RatpackAsyncHttpServerTest extends RatpackHttpServerTest { } then {endpoint -> controller(endpoint) { context.parse(Map).then { map -> - response.status(BODY_JSON.status).send('text/plain', "{\"a\":\"${map['a']}\"}") + response.status(BODY_JSON.status) + context.render(json(map)) } } } diff --git a/dd-java-agent/instrumentation/ratpack-1.5/src/test/groovy/server/RatpackForkedHttpServerTest.groovy b/dd-java-agent/instrumentation/ratpack-1.5/src/test/groovy/server/RatpackForkedHttpServerTest.groovy index 9ba443b9548..8e700a79ea1 100644 --- a/dd-java-agent/instrumentation/ratpack-1.5/src/test/groovy/server/RatpackForkedHttpServerTest.groovy +++ b/dd-java-agent/instrumentation/ratpack-1.5/src/test/groovy/server/RatpackForkedHttpServerTest.groovy @@ -7,6 +7,7 @@ import ratpack.exec.Promise import ratpack.form.Form import ratpack.groovy.test.embed.GroovyEmbeddedApp import ratpack.handling.HandlerDecorator +import static ratpack.jackson.Jackson.json import java.nio.charset.StandardCharsets @@ -105,10 +106,11 @@ class RatpackForkedHttpServerTest extends RatpackHttpServerTest { all { Promise.sync { BODY_JSON - }.fork().then {endpoint -> + }.fork().then { endpoint -> controller(endpoint) { context.parse(Map).then { map -> - response.status(BODY_JSON.status).send('text/plain', "{\"a\":\"${map['a']}\"}") + response.status(BODY_JSON.status) + context.render(json(map)) } } } diff --git a/dd-java-agent/instrumentation/ratpack-1.5/src/test/groovy/server/RatpackHttpServerTest.groovy b/dd-java-agent/instrumentation/ratpack-1.5/src/test/groovy/server/RatpackHttpServerTest.groovy index 373342c23e5..43d0bc9402d 100644 --- a/dd-java-agent/instrumentation/ratpack-1.5/src/test/groovy/server/RatpackHttpServerTest.groovy +++ b/dd-java-agent/instrumentation/ratpack-1.5/src/test/groovy/server/RatpackHttpServerTest.groovy @@ -77,6 +77,11 @@ class RatpackHttpServerTest extends HttpServerTest { true } + @Override + boolean testResponseBodyJson() { + true + } + @Override String testPathParam() { true diff --git a/dd-java-agent/instrumentation/ratpack-1.5/src/test/groovy/server/SyncRatpackApp.groovy b/dd-java-agent/instrumentation/ratpack-1.5/src/test/groovy/server/SyncRatpackApp.groovy index 8033682958d..9fd73f15f87 100644 --- a/dd-java-agent/instrumentation/ratpack-1.5/src/test/groovy/server/SyncRatpackApp.groovy +++ b/dd-java-agent/instrumentation/ratpack-1.5/src/test/groovy/server/SyncRatpackApp.groovy @@ -5,6 +5,7 @@ import ratpack.form.Form import ratpack.groovy.test.embed.GroovyEmbeddedApp import ratpack.handling.HandlerDecorator import ratpack.test.embed.EmbeddedApp +import static ratpack.jackson.Jackson.json import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.BODY_JSON import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.BODY_MULTIPART @@ -46,9 +47,10 @@ enum SyncRatpackApp implements EmbeddedApp { prefix(CREATED.relativeRawPath()) { all { controller(CREATED) { - request.body.then { typedData -> + request.body.then { + typedData -> response.status(CREATED.status) - .send('text/plain', "${CREATED.body}: ${typedData.text}") + .send('text/plain', "${CREATED.body}: ${typedData.text}") } } } @@ -61,9 +63,14 @@ enum SyncRatpackApp implements EmbeddedApp { prefix(BODY_URLENCODED.relativeRawPath()) { all { controller(BODY_URLENCODED) { - context.parse(Form).then { form -> - def text = form.findAll { it.key != 'ignore'} - .collectEntries {[it.key, it.value as List]} as String + context.parse(Form).then { + form -> + def text = form.findAll { + it.key != 'ignore' + } + .collectEntries { + [it.key, it.value as List] + } as String response.status(BODY_URLENCODED.status).send('text/plain', text) } } @@ -72,8 +79,11 @@ enum SyncRatpackApp implements EmbeddedApp { prefix(BODY_MULTIPART.relativeRawPath()) { all { controller(BODY_MULTIPART) { - context.parse(Form).then { form -> - def text = form.collectEntries {[it.key, it.value as List]} as String + context.parse(Form).then { + form -> + def text = form.collectEntries { + [it.key, it.value as List] + } as String response.status(BODY_MULTIPART.status).send('text/plain', text) } } @@ -82,8 +92,11 @@ enum SyncRatpackApp implements EmbeddedApp { prefix(BODY_JSON.relativeRawPath()) { all { controller(BODY_JSON) { - context.parse(Map).then { map -> - response.status(BODY_JSON.status).send('text/plain', "{\"a\":\"${map['a']}\"}") + context.parse(Map).then { + map -> { + response.status(BODY_JSON.status) + context.render(json(map)) + } } } } diff --git a/dd-smoke-tests/ratpack-1.5/build.gradle b/dd-smoke-tests/ratpack-1.5/build.gradle new file mode 100644 index 00000000000..81c8d47092f --- /dev/null +++ b/dd-smoke-tests/ratpack-1.5/build.gradle @@ -0,0 +1,24 @@ +plugins { + id "com.gradleup.shadow" +} + +apply from: "$rootDir/gradle/java.gradle" + +jar { + manifest { + attributes('Main-Class': 'datadog.smoketest.ratpack.RatpackApp') + } +} +dependencies { + implementation 'io.ratpack:ratpack-core:1.5.0' + implementation 'com.sun.activation:jakarta.activation:1.2.2' + + testImplementation project(':dd-smoke-tests') + testImplementation project(':dd-smoke-tests:appsec') +} + +tasks.withType(Test).configureEach { + dependsOn "shadowJar" + jvmArgs "-Ddatadog.smoketest.ratpack.shadowJar.path=${tasks.shadowJar.archiveFile.get()}" +} + diff --git a/dd-smoke-tests/ratpack-1.5/src/main/java/datadog/smoketest/ratpack/RatpackApp.java b/dd-smoke-tests/ratpack-1.5/src/main/java/datadog/smoketest/ratpack/RatpackApp.java new file mode 100644 index 00000000000..c9ca8527a75 --- /dev/null +++ b/dd-smoke-tests/ratpack-1.5/src/main/java/datadog/smoketest/ratpack/RatpackApp.java @@ -0,0 +1,41 @@ +package datadog.smoketest.ratpack; + +import static ratpack.jackson.Jackson.json; + +import com.fasterxml.jackson.databind.JsonNode; +import ratpack.server.RatpackServer; + +public class RatpackApp { + + public static void main(String[] args) throws Exception { + int port = Integer.parseInt(System.getProperty("ratpack.http.port", "8080")); + RatpackServer.start( + server -> + server + .serverConfig(config -> config.port(port)) + .handlers( + chain -> + chain + .path( + "api_security/sampling/:status_code", + ctx -> { + ctx.getResponse() + .status( + Integer.parseInt(ctx.getPathTokens().get("status_code"))) + .send("EXECUTED"); + }) + .path( + "api_security/response", + ctx -> + ctx.parse(JsonNode.class) + .then( + node -> { + ctx.getResponse().status(200); + ctx.render(json(node)); + })) + .all( + ctx -> { + ctx.getResponse().status(404).send("Not Found"); + }))); + } +} diff --git a/dd-smoke-tests/ratpack-1.5/src/test/groovy/AppSecRatpackSmokeTest.groovy b/dd-smoke-tests/ratpack-1.5/src/test/groovy/AppSecRatpackSmokeTest.groovy new file mode 100644 index 00000000000..96e5ee8ee91 --- /dev/null +++ b/dd-smoke-tests/ratpack-1.5/src/test/groovy/AppSecRatpackSmokeTest.groovy @@ -0,0 +1,101 @@ +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 AppSecRatpackSmokeTest extends AbstractAppSecServerSmokeTest { + + @Override + ProcessBuilder createProcessBuilder() { + String ratpackUberJar = System.getProperty("datadog.smoketest.ratpack.shadowJar.path") + + List command = new ArrayList<>() + command.add(javaPath()) + command.addAll(defaultJavaProperties) + command.addAll(defaultAppSecProperties) + command.addAll((String[]) [ + "-Ddd.writer.type=MultiWriter:TraceStructureWriter:${output.getAbsolutePath()},DDAgentWriter", + "-Dratpack.http.port=${httpPort}", + "-jar", + ratpackUberJar + ]) + ProcessBuilder processBuilder = new ProcessBuilder(command) + processBuilder.directory(new File(buildDirectory)) + } + + @Override + File createTemporaryFile() { + return new File("${buildDirectory}/tmp/trace-structure-ratpack.out") + } + + void 'API Security samples only one request per endpoint'() { + given: + def url = "http://localhost:${httpPort}/api_security/sampling/200?test=value" + def client = OkHttpUtils.clientBuilder().build() + 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}/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/settings.gradle b/settings.gradle index 1c20bcacfa1..6f365dc8a68 100644 --- a/settings.gradle +++ b/settings.gradle @@ -150,6 +150,7 @@ include ':dd-smoke-tests:profiling-integration-tests' include ':dd-smoke-tests:quarkus' include ':dd-smoke-tests:quarkus-native' include ':dd-smoke-tests:sample-trace' +include ':dd-smoke-tests:ratpack-1.5' include ':dd-smoke-tests:resteasy' include ':dd-smoke-tests:spring-boot-3.0-native' include ':dd-smoke-tests:spring-boot-2.4-webflux'