diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/GatewayBridge.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/GatewayBridge.java index 0eeeb10b998..743c93c0728 100644 --- a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/GatewayBridge.java +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/GatewayBridge.java @@ -158,6 +158,7 @@ public void init() { subscriptionService.registerCallback(EVENTS.shellCmd(), this::onShellCmd); subscriptionService.registerCallback(EVENTS.user(), this::onUser); subscriptionService.registerCallback(EVENTS.loginEvent(), this::onLoginEvent); + subscriptionService.registerCallback(EVENTS.httpRoute(), this::onHttpRoute); if (additionalIGEvents.contains(EVENTS.requestPathParams())) { subscriptionService.registerCallback(EVENTS.requestPathParams(), this::onRequestPathParams); @@ -224,6 +225,14 @@ private Flow onUser(final RequestContext ctx_, final String user) { } } + private void onHttpRoute(final RequestContext ctx_, final String route) { + final AppSecRequestContext ctx = ctx_.getData(RequestContextSlot.APPSEC); + if (ctx == null) { + return; + } + ctx.setRoute(route); + } + private Flow onLoginEvent( final RequestContext ctx_, final LoginEvent event, final String login) { final AppSecRequestContext ctx = ctx_.getData(RequestContextSlot.APPSEC); diff --git a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/GatewayBridgeSpecification.groovy b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/GatewayBridgeSpecification.groovy index d3d62600e73..18c34db6d37 100644 --- a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/GatewayBridgeSpecification.groovy +++ b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/GatewayBridgeSpecification.groovy @@ -114,6 +114,7 @@ class GatewayBridgeSpecification extends DDSpecification { BiFunction> shellCmdCB BiFunction> userCB TriFunction> loginEventCB + BiConsumer httpRouteCB WafMetricCollector wafMetricCollector = Mock(WafMetricCollector) @@ -477,6 +478,7 @@ class GatewayBridgeSpecification extends DDSpecification { 1 * ig.registerCallback(EVENTS.shellCmd(), _) >> { shellCmdCB = it[1]; null } 1 * ig.registerCallback(EVENTS.user(), _) >> { userCB = it[1]; null } 1 * ig.registerCallback(EVENTS.loginEvent(), _) >> { loginEventCB = it[1]; null } + 1 * ig.registerCallback(EVENTS.httpRoute(), _) >> { httpRouteCB = it[1]; null } 0 * ig.registerCallback(_, _) bridge.init() @@ -1327,4 +1329,15 @@ class GatewayBridgeSpecification extends DDSpecification { 0 * traceSegment.setTagTop(_, _) } + void 'test on httpRoute'() { + given: + final route = 'dummy-route' + + when: + httpRouteCB.accept(ctx, route) + + then: + arCtx.getRoute() == route + } + } diff --git a/dd-java-agent/instrumentation/play-2.3/src/main/java/datadog/trace/instrumentation/play23/PlayHttpServerDecorator.java b/dd-java-agent/instrumentation/play-2.3/src/main/java/datadog/trace/instrumentation/play23/PlayHttpServerDecorator.java index 404157597aa..a53c5a739eb 100644 --- a/dd-java-agent/instrumentation/play-2.3/src/main/java/datadog/trace/instrumentation/play23/PlayHttpServerDecorator.java +++ b/dd-java-agent/instrumentation/play-2.3/src/main/java/datadog/trace/instrumentation/play23/PlayHttpServerDecorator.java @@ -1,8 +1,12 @@ package datadog.trace.instrumentation.play23; +import static datadog.trace.api.gateway.Events.EVENTS; import static datadog.trace.bootstrap.instrumentation.decorator.http.HttpResourceDecorator.HTTP_RESOURCE_DECORATOR; import datadog.trace.api.Config; +import datadog.trace.api.gateway.CallbackProvider; +import datadog.trace.api.gateway.RequestContext; +import datadog.trace.api.gateway.RequestContextSlot; import datadog.trace.bootstrap.instrumentation.api.AgentPropagation; import datadog.trace.bootstrap.instrumentation.api.AgentSpan; import datadog.trace.bootstrap.instrumentation.api.AgentSpanContext; @@ -12,6 +16,9 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.UndeclaredThrowableException; import java.util.concurrent.CompletionException; +import java.util.function.BiConsumer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import play.api.Routes; import play.api.mvc.Headers; import play.api.mvc.Request; @@ -19,6 +26,7 @@ public class PlayHttpServerDecorator extends HttpServerDecorator { + private static final Logger LOG = LoggerFactory.getLogger(PlayHttpServerDecorator.class); public static final boolean REPORT_HTTP_STATUS = Config.get().getPlayReportHttpStatus(); public static final CharSequence PLAY_REQUEST = UTF8BytesString.create("play.request"); public static final CharSequence PLAY_ACTION = UTF8BytesString.create("play-action"); @@ -88,11 +96,35 @@ public AgentSpan onRequest( if (!pathOption.isEmpty()) { final String path = (String) pathOption.get(); HTTP_RESOURCE_DECORATOR.withRoute(span, request.method(), path); + dispatchRoute(span, path); } } return span; } + /** + * Play does not set the http.route in the local root span so we need to store it in the context + * for API security + */ + private void dispatchRoute(final AgentSpan span, final String route) { + try { + final RequestContext ctx = span.getRequestContext(); + if (ctx == null) { + return; + } + final CallbackProvider cbp = tracer().getCallbackProvider(RequestContextSlot.APPSEC); + if (cbp == null) { + return; + } + final BiConsumer cb = cbp.getCallback(EVENTS.httpRoute()); + if (cb != null) { + cb.accept(ctx, route); + } + } catch (final Throwable t) { + LOG.debug("Failed to dispatch route", t); + } + } + @Override public AgentSpan onError(final AgentSpan span, Throwable throwable) { if (REPORT_HTTP_STATUS) { diff --git a/dd-java-agent/instrumentation/play-2.4/src/main/java/datadog/trace/instrumentation/play24/PlayHttpServerDecorator.java b/dd-java-agent/instrumentation/play-2.4/src/main/java/datadog/trace/instrumentation/play24/PlayHttpServerDecorator.java index 6837a065059..efd55a5bb23 100644 --- a/dd-java-agent/instrumentation/play-2.4/src/main/java/datadog/trace/instrumentation/play24/PlayHttpServerDecorator.java +++ b/dd-java-agent/instrumentation/play-2.4/src/main/java/datadog/trace/instrumentation/play24/PlayHttpServerDecorator.java @@ -1,8 +1,12 @@ package datadog.trace.instrumentation.play24; +import static datadog.trace.api.gateway.Events.EVENTS; import static datadog.trace.bootstrap.instrumentation.decorator.http.HttpResourceDecorator.HTTP_RESOURCE_DECORATOR; import datadog.trace.api.Config; +import datadog.trace.api.gateway.CallbackProvider; +import datadog.trace.api.gateway.RequestContext; +import datadog.trace.api.gateway.RequestContextSlot; import datadog.trace.bootstrap.instrumentation.api.AgentPropagation; import datadog.trace.bootstrap.instrumentation.api.AgentSpan; import datadog.trace.bootstrap.instrumentation.api.AgentSpanContext; @@ -12,6 +16,9 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.UndeclaredThrowableException; import java.util.concurrent.CompletionException; +import java.util.function.BiConsumer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import play.api.mvc.Headers; import play.api.mvc.Request; import play.api.mvc.Result; @@ -19,6 +26,7 @@ public class PlayHttpServerDecorator extends HttpServerDecorator { + private static final Logger LOG = LoggerFactory.getLogger(PlayHttpServerDecorator.class); public static final boolean REPORT_HTTP_STATUS = Config.get().getPlayReportHttpStatus(); public static final CharSequence PLAY_REQUEST = UTF8BytesString.create("play.request"); public static final CharSequence PLAY_ACTION = UTF8BytesString.create("play-action"); @@ -88,11 +96,35 @@ public AgentSpan onRequest( if (!pathOption.isEmpty()) { final String path = (String) pathOption.get(); HTTP_RESOURCE_DECORATOR.withRoute(span, request.method(), path); + dispatchRoute(span, path); } } return span; } + /** + * Play does not set the http.route in the local root span so we need to store it in the context + * for API security + */ + private void dispatchRoute(final AgentSpan span, final String route) { + try { + final RequestContext ctx = span.getRequestContext(); + if (ctx == null) { + return; + } + final CallbackProvider cbp = tracer().getCallbackProvider(RequestContextSlot.APPSEC); + if (cbp == null) { + return; + } + final BiConsumer cb = cbp.getCallback(EVENTS.httpRoute()); + if (cb != null) { + cb.accept(ctx, route); + } + } catch (final Throwable t) { + LOG.debug("Failed to dispatch route", t); + } + } + @Override public AgentSpan onError(final AgentSpan span, Throwable throwable) { if (REPORT_HTTP_STATUS) { diff --git a/dd-java-agent/instrumentation/play-2.6/src/main/java/datadog/trace/instrumentation/play26/PlayHttpServerDecorator.java b/dd-java-agent/instrumentation/play-2.6/src/main/java/datadog/trace/instrumentation/play26/PlayHttpServerDecorator.java index eebedf0f508..494546754cf 100644 --- a/dd-java-agent/instrumentation/play-2.6/src/main/java/datadog/trace/instrumentation/play26/PlayHttpServerDecorator.java +++ b/dd-java-agent/instrumentation/play-2.6/src/main/java/datadog/trace/instrumentation/play26/PlayHttpServerDecorator.java @@ -1,15 +1,20 @@ package datadog.trace.instrumentation.play26; +import static datadog.trace.api.gateway.Events.EVENTS; import static datadog.trace.bootstrap.instrumentation.decorator.http.HttpResourceDecorator.HTTP_RESOURCE_DECORATOR; import datadog.trace.api.Config; import datadog.trace.api.cache.DDCache; import datadog.trace.api.cache.DDCaches; +import datadog.trace.api.gateway.CallbackProvider; +import datadog.trace.api.gateway.RequestContext; +import datadog.trace.api.gateway.RequestContextSlot; import datadog.trace.bootstrap.instrumentation.api.AgentPropagation; import datadog.trace.bootstrap.instrumentation.api.AgentSpan; import datadog.trace.bootstrap.instrumentation.api.AgentSpanContext; import datadog.trace.bootstrap.instrumentation.api.ResourceNamePriorities; import datadog.trace.bootstrap.instrumentation.api.URIDataAdapter; +import datadog.trace.bootstrap.instrumentation.api.URIUtils; import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; import datadog.trace.bootstrap.instrumentation.decorator.HttpServerDecorator; import java.lang.invoke.MethodHandle; @@ -18,6 +23,9 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.UndeclaredThrowableException; import java.util.concurrent.CompletionException; +import java.util.function.BiConsumer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import play.api.mvc.Headers; import play.api.mvc.Request; import play.api.mvc.Result; @@ -29,6 +37,7 @@ public class PlayHttpServerDecorator extends HttpServerDecorator { + private static final Logger LOG = LoggerFactory.getLogger(PlayHttpServerDecorator.class); public static final boolean REPORT_HTTP_STATUS = Config.get().getPlayReportHttpStatus(); public static final CharSequence PLAY_REQUEST = UTF8BytesString.create("play.request"); public static final CharSequence PLAY_ACTION = UTF8BytesString.create("play-action"); @@ -143,11 +152,35 @@ public AgentSpan onRequest( PATH_CACHE.computeIfAbsent( defOption.get().path(), p -> addMissingSlash(p, request.path())); HTTP_RESOURCE_DECORATOR.withRoute(span, request.method(), path, true); + dispatchRoute(span, path); } } return span; } + /** + * Play does not set the http.route in the local root span so we need to store it in the context + * for API security + */ + private void dispatchRoute(final AgentSpan span, final CharSequence route) { + try { + final RequestContext ctx = span.getRequestContext(); + if (ctx == null) { + return; + } + final CallbackProvider cbp = tracer().getCallbackProvider(RequestContextSlot.APPSEC); + if (cbp == null) { + return; + } + final BiConsumer cb = cbp.getCallback(EVENTS.httpRoute()); + if (cb != null) { + cb.accept(ctx, URIUtils.decode(route.toString())); + } + } catch (final Throwable t) { + LOG.debug("Failed to dispatch route", t); + } + } + /* This is a workaround to add a `/` if it is missing when using split routes. diff --git a/dd-smoke-tests/play-2.4/app/controllers/AppSecController.scala b/dd-smoke-tests/play-2.4/app/controllers/AppSecController.scala new file mode 100644 index 00000000000..bbee19ee22e --- /dev/null +++ b/dd-smoke-tests/play-2.4/app/controllers/AppSecController.scala @@ -0,0 +1,11 @@ +package controllers + +import play.api.mvc.{Action, AnyContent, Controller} + +class AppSecController extends Controller { + + def apiSecuritySampling(statusCode: Int, test: String): Action[AnyContent] = Action { + Status(statusCode)("EXECUTED") + } + +} diff --git a/dd-smoke-tests/play-2.4/build.gradle b/dd-smoke-tests/play-2.4/build.gradle index 9be50043bde..13d374d2a24 100644 --- a/dd-smoke-tests/play-2.4/build.gradle +++ b/dd-smoke-tests/play-2.4/build.gradle @@ -63,6 +63,7 @@ dependencies { implementation group: 'io.opentracing', name: 'opentracing-util', version: '0.32.0' testImplementation project(':dd-smoke-tests') + testImplementation project(':dd-smoke-tests:appsec') } configurations.testImplementation { diff --git a/dd-smoke-tests/play-2.4/conf/routes b/dd-smoke-tests/play-2.4/conf/routes index e9abf0cc6db..0520cfe1842 100644 --- a/dd-smoke-tests/play-2.4/conf/routes +++ b/dd-smoke-tests/play-2.4/conf/routes @@ -6,3 +6,6 @@ # An example controller showing a sample home page GET /welcomej controllers.JController.doGet(id: Int ?= 0) GET /welcomes controllers.SController.doGet(id: Option[Int]) + +# AppSec endpoints for testing +GET /api_security/sampling/:statusCode controllers.AppSecController.apiSecuritySampling(statusCode: Int, test: String) diff --git a/dd-smoke-tests/play-2.4/src/test/groovy/datadog/smoketest/AppSecPlayNettySmokeTest.groovy b/dd-smoke-tests/play-2.4/src/test/groovy/datadog/smoketest/AppSecPlayNettySmokeTest.groovy new file mode 100644 index 00000000000..701eb66ec68 --- /dev/null +++ b/dd-smoke-tests/play-2.4/src/test/groovy/datadog/smoketest/AppSecPlayNettySmokeTest.groovy @@ -0,0 +1,92 @@ +package datadog.smoketest + +import datadog.smoketest.appsec.AbstractAppSecServerSmokeTest +import datadog.trace.agent.test.utils.OkHttpUtils +import okhttp3.Request +import okhttp3.Response +import spock.lang.Shared + +import java.nio.file.Files + +import static java.util.concurrent.TimeUnit.SECONDS + +class AppSecPlayNettySmokeTest extends AbstractAppSecServerSmokeTest { + + @Shared + File playDirectory = new File("${buildDirectory}/stage/main") + + @Override + ProcessBuilder createProcessBuilder() { + // If the server is not shut down correctly, this file can be left there and will block + // the start of a new test + def runningPid = new File(playDirectory.getPath(), "RUNNING_PID") + if (runningPid.exists()) { + runningPid.delete() + } + def command = isWindows() ? 'main.bat' : 'main' + ProcessBuilder processBuilder = new ProcessBuilder("${playDirectory}/bin/${command}") + processBuilder.directory(playDirectory) + processBuilder.environment().put("JAVA_OPTS", + (defaultAppSecProperties + defaultJavaProperties).collect({ it.replace(' ', '\\ ')}).join(" ") + + " -Dconfig.file=${playDirectory}/conf/application.conf" + + " -Dhttp.port=${httpPort}" + + " -Dhttp.address=127.0.0.1" + + " -Dplay.server.provider=play.core.server.NettyServerProvider" + + " -Ddd.writer.type=MultiWriter:TraceStructureWriter:${output.getAbsolutePath()},DDAgentWriter") + return processBuilder + } + + @Override + File createTemporaryFile() { + return new File("${buildDirectory}/tmp/trace-structure-play-2.4-appsec-netty.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.headers') + } + + // Ensure to clean up server and not only the shell script that starts it + def cleanupSpec() { + def pid = runningServerPid() + if (pid) { + def commands = isWindows() ? ['taskkill', '/PID', pid, '/T', '/F'] : ['kill', '-9', pid] + new ProcessBuilder(commands).start().waitFor(10, SECONDS) + } + } + + def runningServerPid() { + def runningPid = new File(playDirectory.getPath(), 'RUNNING_PID') + if (runningPid.exists()) { + return Files.lines(runningPid.toPath()).findAny().orElse(null) + } + } + + static isWindows() { + return System.getProperty('os.name').toLowerCase().contains('win') + } +} diff --git a/dd-smoke-tests/play-2.5/app/controllers/AppSecController.scala b/dd-smoke-tests/play-2.5/app/controllers/AppSecController.scala new file mode 100644 index 00000000000..bbee19ee22e --- /dev/null +++ b/dd-smoke-tests/play-2.5/app/controllers/AppSecController.scala @@ -0,0 +1,11 @@ +package controllers + +import play.api.mvc.{Action, AnyContent, Controller} + +class AppSecController extends Controller { + + def apiSecuritySampling(statusCode: Int, test: String): Action[AnyContent] = Action { + Status(statusCode)("EXECUTED") + } + +} diff --git a/dd-smoke-tests/play-2.5/build.gradle b/dd-smoke-tests/play-2.5/build.gradle index 6c992889cd0..7e363639b28 100644 --- a/dd-smoke-tests/play-2.5/build.gradle +++ b/dd-smoke-tests/play-2.5/build.gradle @@ -65,6 +65,7 @@ dependencies { implementation group: 'io.opentracing', name: 'opentracing-util', version: '0.32.0' testImplementation project(':dd-smoke-tests') + testImplementation project(':dd-smoke-tests:appsec') } configurations.testImplementation { diff --git a/dd-smoke-tests/play-2.5/conf/routes b/dd-smoke-tests/play-2.5/conf/routes index e9abf0cc6db..0520cfe1842 100644 --- a/dd-smoke-tests/play-2.5/conf/routes +++ b/dd-smoke-tests/play-2.5/conf/routes @@ -6,3 +6,6 @@ # An example controller showing a sample home page GET /welcomej controllers.JController.doGet(id: Int ?= 0) GET /welcomes controllers.SController.doGet(id: Option[Int]) + +# AppSec endpoints for testing +GET /api_security/sampling/:statusCode controllers.AppSecController.apiSecuritySampling(statusCode: Int, test: String) 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 new file mode 100644 index 00000000000..60e2978eb53 --- /dev/null +++ b/dd-smoke-tests/play-2.5/src/test/groovy/datadog/smoketest/AppSecPlayNettySmokeTest.groovy @@ -0,0 +1,92 @@ +package datadog.smoketest + +import datadog.smoketest.appsec.AbstractAppSecServerSmokeTest +import datadog.trace.agent.test.utils.OkHttpUtils +import okhttp3.Request +import okhttp3.Response +import spock.lang.Shared + +import java.nio.file.Files + +import static java.util.concurrent.TimeUnit.SECONDS + +class AppSecPlayNettySmokeTest extends AbstractAppSecServerSmokeTest { + + @Shared + File playDirectory = new File("${buildDirectory}/stage/main") + + @Override + ProcessBuilder createProcessBuilder() { + // If the server is not shut down correctly, this file can be left there and will block + // the start of a new test + def runningPid = new File(playDirectory.getPath(), "RUNNING_PID") + if (runningPid.exists()) { + runningPid.delete() + } + def command = isWindows() ? 'main.bat' : 'main' + ProcessBuilder processBuilder = new ProcessBuilder("${playDirectory}/bin/${command}") + processBuilder.directory(playDirectory) + processBuilder.environment().put("JAVA_OPTS", + (defaultAppSecProperties + defaultJavaProperties).collect({ it.replace(' ', '\\ ')}).join(" ") + + " -Dconfig.file=${playDirectory}/conf/application.conf" + + " -Dhttp.port=${httpPort}" + + " -Dhttp.address=127.0.0.1" + + " -Dplay.server.provider=play.core.server.NettyServerProvider" + + " -Ddd.writer.type=MultiWriter:TraceStructureWriter:${output.getAbsolutePath()},DDAgentWriter") + return processBuilder + } + + @Override + File createTemporaryFile() { + return new File("${buildDirectory}/tmp/trace-structure-play-2.5-appsec-netty.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.headers') + } + + // Ensure to clean up server and not only the shell script that starts it + def cleanupSpec() { + def pid = runningServerPid() + if (pid) { + def commands = isWindows() ? ['taskkill', '/PID', pid, '/T', '/F'] : ['kill', '-9', pid] + new ProcessBuilder(commands).start().waitFor(10, SECONDS) + } + } + + def runningServerPid() { + def runningPid = new File(playDirectory.getPath(), 'RUNNING_PID') + if (runningPid.exists()) { + return Files.lines(runningPid.toPath()).findAny().orElse(null) + } + } + + static isWindows() { + return System.getProperty('os.name').toLowerCase().contains('win') + } +} diff --git a/dd-smoke-tests/play-2.6/app/controllers/AppSecController.scala b/dd-smoke-tests/play-2.6/app/controllers/AppSecController.scala new file mode 100644 index 00000000000..cad6476d2ea --- /dev/null +++ b/dd-smoke-tests/play-2.6/app/controllers/AppSecController.scala @@ -0,0 +1,13 @@ +package controllers + +import javax.inject._ +import play.api.mvc._ + +@Singleton +class AppSecController @Inject() (cc: ControllerComponents) extends AbstractController(cc) { + + def apiSecuritySampling(statusCode: Int, test: String): Action[AnyContent] = Action { + Status(statusCode)("EXECUTED") + } + +} diff --git a/dd-smoke-tests/play-2.6/build.gradle b/dd-smoke-tests/play-2.6/build.gradle index 2c50adfcb96..48a6b161a3f 100644 --- a/dd-smoke-tests/play-2.6/build.gradle +++ b/dd-smoke-tests/play-2.6/build.gradle @@ -65,6 +65,7 @@ dependencies { implementation group: 'io.opentracing', name: 'opentracing-util', version: '0.32.0' testImplementation project(':dd-smoke-tests') + testImplementation project(':dd-smoke-tests:appsec') } configurations.testImplementation { diff --git a/dd-smoke-tests/play-2.6/conf/routes b/dd-smoke-tests/play-2.6/conf/routes index e9abf0cc6db..0520cfe1842 100644 --- a/dd-smoke-tests/play-2.6/conf/routes +++ b/dd-smoke-tests/play-2.6/conf/routes @@ -6,3 +6,6 @@ # An example controller showing a sample home page GET /welcomej controllers.JController.doGet(id: Int ?= 0) GET /welcomes controllers.SController.doGet(id: Option[Int]) + +# AppSec endpoints for testing +GET /api_security/sampling/:statusCode controllers.AppSecController.apiSecuritySampling(statusCode: Int, test: String) 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 new file mode 100644 index 00000000000..9c98fb7616a --- /dev/null +++ b/dd-smoke-tests/play-2.6/src/test/groovy/datadog/smoketest/AppSecPlaySmokeTest.groovy @@ -0,0 +1,123 @@ +package datadog.smoketest + +import datadog.smoketest.appsec.AbstractAppSecServerSmokeTest +import datadog.trace.agent.test.utils.OkHttpUtils +import okhttp3.Request +import okhttp3.Response +import spock.lang.Shared + +import java.nio.file.Files + +import static java.util.concurrent.TimeUnit.SECONDS + +abstract class AppSecPlaySmokeTest extends AbstractAppSecServerSmokeTest { + + @Shared + File playDirectory = new File("${buildDirectory}/stage/main") + + @Override + ProcessBuilder createProcessBuilder() { + // If the server is not shut down correctly, this file can be left there and will block + // the start of a new test + def runningPid = new File(playDirectory.getPath(), "RUNNING_PID") + if (runningPid.exists()) { + runningPid.delete() + } + def command = isWindows() ? 'main.bat' : 'main' + ProcessBuilder processBuilder = + new ProcessBuilder("${playDirectory}/bin/${command}") + processBuilder.directory(playDirectory) + processBuilder.environment().put("JAVA_OPTS", + (defaultAppSecProperties + defaultJavaProperties).collect({ it.replace(' ', '\\ ')}).join(" ") + + " -Dconfig.file=${playDirectory}/conf/application.conf" + + " -Dhttp.port=${httpPort}" + + " -Dhttp.address=127.0.0.1" + + " -Dplay.server.provider=${serverProvider()}" + + " -Ddd.writer.type=MultiWriter:TraceStructureWriter:${output.getAbsolutePath()},DDAgentWriter") + return processBuilder + } + + @Override + File createTemporaryFile() { + new File("${buildDirectory}/tmp/trace-structure-play-2.6-appsec-${serverProviderName()}.out") + } + + abstract String serverProviderName() + + abstract String serverProvider() + + 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.headers') + } + + + // Ensure to clean up server and not only the shell script that starts it + def cleanupSpec() { + def pid = runningServerPid() + if (pid) { + def commands = isWindows() ? ['taskkill', '/PID', pid, '/T', '/F'] : ['kill', '-9', pid] + new ProcessBuilder(commands).start().waitFor(10, SECONDS) + } + } + + def runningServerPid() { + def runningPid = new File(playDirectory.getPath(), 'RUNNING_PID') + if (runningPid.exists()) { + return Files.lines(runningPid.toPath()).findAny().orElse(null) + } + } + + static isWindows() { + return System.getProperty('os.name').toLowerCase().contains("win") + } + + static class Akka extends AppSecPlaySmokeTest { + + @Override + String serverProviderName() { + return "akka-http" + } + + @Override + String serverProvider() { + return "play.core.server.AkkaHttpServerProvider" + } + } + + static class Netty extends AppSecPlaySmokeTest { + @Override + String serverProviderName() { + return "netty" + } + + @Override + String serverProvider() { + return "play.core.server.NettyServerProvider" + } + } +} diff --git a/dd-smoke-tests/play-2.7/app/controllers/AppSecController.scala b/dd-smoke-tests/play-2.7/app/controllers/AppSecController.scala new file mode 100644 index 00000000000..cad6476d2ea --- /dev/null +++ b/dd-smoke-tests/play-2.7/app/controllers/AppSecController.scala @@ -0,0 +1,13 @@ +package controllers + +import javax.inject._ +import play.api.mvc._ + +@Singleton +class AppSecController @Inject() (cc: ControllerComponents) extends AbstractController(cc) { + + def apiSecuritySampling(statusCode: Int, test: String): Action[AnyContent] = Action { + Status(statusCode)("EXECUTED") + } + +} diff --git a/dd-smoke-tests/play-2.7/build.gradle b/dd-smoke-tests/play-2.7/build.gradle index ac64c0f6f01..eaa36eaaccf 100644 --- a/dd-smoke-tests/play-2.7/build.gradle +++ b/dd-smoke-tests/play-2.7/build.gradle @@ -65,6 +65,7 @@ dependencies { implementation group: 'io.opentracing', name: 'opentracing-util', version: '0.32.0' testImplementation project(':dd-smoke-tests') + testImplementation project(':dd-smoke-tests:appsec') } configurations.testImplementation { diff --git a/dd-smoke-tests/play-2.7/conf/routes b/dd-smoke-tests/play-2.7/conf/routes index e9abf0cc6db..0520cfe1842 100644 --- a/dd-smoke-tests/play-2.7/conf/routes +++ b/dd-smoke-tests/play-2.7/conf/routes @@ -6,3 +6,6 @@ # An example controller showing a sample home page GET /welcomej controllers.JController.doGet(id: Int ?= 0) GET /welcomes controllers.SController.doGet(id: Option[Int]) + +# AppSec endpoints for testing +GET /api_security/sampling/:statusCode controllers.AppSecController.apiSecuritySampling(statusCode: Int, test: String) 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 new file mode 100644 index 00000000000..360cf042f6f --- /dev/null +++ b/dd-smoke-tests/play-2.7/src/test/groovy/datadog/smoketest/AppSecPlaySmokeTest.groovy @@ -0,0 +1,122 @@ +package datadog.smoketest + +import datadog.smoketest.appsec.AbstractAppSecServerSmokeTest +import datadog.trace.agent.test.utils.OkHttpUtils +import okhttp3.Request +import okhttp3.Response +import spock.lang.Shared + +import java.nio.file.Files + +import static java.util.concurrent.TimeUnit.SECONDS + +abstract class AppSecPlaySmokeTest extends AbstractAppSecServerSmokeTest { + + @Shared + File playDirectory = new File("${buildDirectory}/stage/main") + + @Override + ProcessBuilder createProcessBuilder() { + // If the server is not shut down correctly, this file can be left there and will block + // the start of a new test + def runningPid = new File(playDirectory.getPath(), "RUNNING_PID") + if (runningPid.exists()) { + runningPid.delete() + } + def command = isWindows() ? 'main.bat' : 'main' + ProcessBuilder processBuilder = + new ProcessBuilder("${playDirectory}/bin/${command}") + processBuilder.environment().put("JAVA_OPTS", + (defaultAppSecProperties + defaultJavaProperties).collect({ it.replace(' ', '\\ ')}).join(" ") + + " -Dconfig.file=${playDirectory}/conf/application.conf" + + " -Dhttp.port=${httpPort}" + + " -Dhttp.address=127.0.0.1" + + " -Dplay.server.provider=${serverProvider()}" + + " -Ddd.writer.type=MultiWriter:TraceStructureWriter:${output.getAbsolutePath()},DDAgentWriter") + return processBuilder + } + + @Override + File createTemporaryFile() { + new File("${buildDirectory}/tmp/trace-structure-play-2.7-appsec-${serverProviderName()}.out") + } + + abstract String serverProviderName() + + abstract String serverProvider() + + 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.headers') + } + + // Ensure to clean up server and not only the shell script that starts it + def cleanupSpec() { + def pid = runningServerPid() + if (pid) { + def commands = isWindows() ? ['taskkill', '/PID', pid, '/T', '/F'] : ['kill', '-9', pid] + new ProcessBuilder(commands).start().waitFor(10, SECONDS) + } + } + + def runningServerPid() { + def runningPid = new File(playDirectory.getPath(), 'RUNNING_PID') + if (runningPid.exists()) { + return Files.lines(runningPid.toPath()).findAny().orElse(null) + } + } + + static isWindows() { + return System.getProperty('os.name').toLowerCase().contains('win') + } + + static class Akka extends AppSecPlaySmokeTest { + + @Override + String serverProviderName() { + return "akka-http" + } + + @Override + String serverProvider() { + return "play.core.server.AkkaHttpServerProvider" + } + } + + static class Netty extends AppSecPlaySmokeTest { + @Override + String serverProviderName() { + return "netty" + } + + @Override + String serverProvider() { + return "play.core.server.NettyServerProvider" + } + } + +} diff --git a/dd-smoke-tests/play-2.8/app/controllers/AppSecController.scala b/dd-smoke-tests/play-2.8/app/controllers/AppSecController.scala new file mode 100644 index 00000000000..cad6476d2ea --- /dev/null +++ b/dd-smoke-tests/play-2.8/app/controllers/AppSecController.scala @@ -0,0 +1,13 @@ +package controllers + +import javax.inject._ +import play.api.mvc._ + +@Singleton +class AppSecController @Inject() (cc: ControllerComponents) extends AbstractController(cc) { + + def apiSecuritySampling(statusCode: Int, test: String): Action[AnyContent] = Action { + Status(statusCode)("EXECUTED") + } + +} diff --git a/dd-smoke-tests/play-2.8/build.gradle b/dd-smoke-tests/play-2.8/build.gradle index acea44b532a..60381e29daa 100644 --- a/dd-smoke-tests/play-2.8/build.gradle +++ b/dd-smoke-tests/play-2.8/build.gradle @@ -64,6 +64,7 @@ dependencies { implementation group: 'io.opentracing', name: 'opentracing-util', version: '0.32.0' testImplementation project(':dd-smoke-tests') + testImplementation project(':dd-smoke-tests:appsec') } configurations.testImplementation { diff --git a/dd-smoke-tests/play-2.8/conf/routes b/dd-smoke-tests/play-2.8/conf/routes index e9abf0cc6db..0520cfe1842 100644 --- a/dd-smoke-tests/play-2.8/conf/routes +++ b/dd-smoke-tests/play-2.8/conf/routes @@ -6,3 +6,6 @@ # An example controller showing a sample home page GET /welcomej controllers.JController.doGet(id: Int ?= 0) GET /welcomes controllers.SController.doGet(id: Option[Int]) + +# AppSec endpoints for testing +GET /api_security/sampling/:statusCode controllers.AppSecController.apiSecuritySampling(statusCode: Int, test: String) 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 new file mode 100644 index 00000000000..66077178b7a --- /dev/null +++ b/dd-smoke-tests/play-2.8/src/test/groovy/datadog/smoketest/AppSecPlaySmokeTest.groovy @@ -0,0 +1,123 @@ +package datadog.smoketest + +import datadog.smoketest.appsec.AbstractAppSecServerSmokeTest +import datadog.trace.agent.test.utils.OkHttpUtils +import okhttp3.Request +import okhttp3.Response +import spock.lang.Shared + +import java.nio.file.Files + +import static java.util.concurrent.TimeUnit.SECONDS + +abstract class AppSecPlaySmokeTest extends AbstractAppSecServerSmokeTest { + + @Shared + File playDirectory = new File("${buildDirectory}/stage/main") + + @Override + ProcessBuilder createProcessBuilder() { + // If the server is not shut down correctly, this file can be left there and will block + // the start of a new test + def runningPid = new File(playDirectory.getPath(), "RUNNING_PID") + if (runningPid.exists()) { + runningPid.delete() + } + def command = isWindows() ? 'main.bat' : 'main' + ProcessBuilder processBuilder = + new ProcessBuilder("${playDirectory}/bin/${command}") + processBuilder.directory(playDirectory) + processBuilder.environment().put("JAVA_OPTS", + (defaultAppSecProperties + defaultJavaProperties).collect({ it.replace(' ', '\\ ')}).join(" ") + + " -Dconfig.file=${playDirectory}/conf/application.conf" + + " -Dhttp.port=${httpPort}" + + " -Dhttp.address=127.0.0.1" + + " -Dplay.server.provider=${serverProvider()}" + + " -Ddd.writer.type=MultiWriter:TraceStructureWriter:${output.getAbsolutePath()},DDAgentWriter") + return processBuilder + } + + @Override + File createTemporaryFile() { + new File("${buildDirectory}/tmp/trace-structure-play-2.8-appsec-${serverProviderName()}.out") + } + + abstract String serverProviderName() + + abstract String serverProvider() + + 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.headers') + } + + // Ensure to clean up server and not only the shell script that starts it + def cleanupSpec() { + def pid = runningServerPid() + if (pid) { + def commands = isWindows() ? ['taskkill', '/PID', pid, '/T', '/F'] : ['kill', '-9', pid] + new ProcessBuilder(commands).start().waitFor(10, SECONDS) + } + } + + def runningServerPid() { + def runningPid = new File(playDirectory.getPath(), 'RUNNING_PID') + if (runningPid.exists()) { + return Files.lines(runningPid.toPath()).findAny().orElse(null) + } + } + + static isWindows() { + return System.getProperty('os.name').toLowerCase().contains('win') + } + + static class Akka extends AppSecPlaySmokeTest { + + @Override + String serverProviderName() { + return "akka-http" + } + + @Override + String serverProvider() { + return "play.core.server.AkkaHttpServerProvider" + } + } + + static class Netty extends AppSecPlaySmokeTest { + @Override + String serverProviderName() { + return "netty" + } + + @Override + String serverProvider() { + return "play.core.server.NettyServerProvider" + } + } + +} diff --git a/internal-api/src/main/java/datadog/trace/api/gateway/Events.java b/internal-api/src/main/java/datadog/trace/api/gateway/Events.java index d840cad01c3..11a19eedcb7 100644 --- a/internal-api/src/main/java/datadog/trace/api/gateway/Events.java +++ b/internal-api/src/main/java/datadog/trace/api/gateway/Events.java @@ -312,6 +312,16 @@ public EventType>> shellCmd() { return (EventType>>) SHELL_CMD; } + static final int HTTP_ROUTE_ID = 26; + + @SuppressWarnings("rawtypes") + private static final EventType HTTP_ROUTE = new ET<>("http.route", HTTP_ROUTE_ID); + + @SuppressWarnings("unchecked") + public EventType> httpRoute() { + return (EventType>) HTTP_ROUTE; + } + static final int MAX_EVENTS = nextId.get(); private static final class ET extends EventType { diff --git a/internal-api/src/main/java/datadog/trace/api/gateway/InstrumentationGateway.java b/internal-api/src/main/java/datadog/trace/api/gateway/InstrumentationGateway.java index cd5219f1dfc..ac20fc5997f 100644 --- a/internal-api/src/main/java/datadog/trace/api/gateway/InstrumentationGateway.java +++ b/internal-api/src/main/java/datadog/trace/api/gateway/InstrumentationGateway.java @@ -7,6 +7,7 @@ import static datadog.trace.api.gateway.Events.GRAPHQL_SERVER_REQUEST_MESSAGE_ID; import static datadog.trace.api.gateway.Events.GRPC_SERVER_METHOD_ID; import static datadog.trace.api.gateway.Events.GRPC_SERVER_REQUEST_MESSAGE_ID; +import static datadog.trace.api.gateway.Events.HTTP_ROUTE_ID; import static datadog.trace.api.gateway.Events.LOGIN_EVENT_ID; import static datadog.trace.api.gateway.Events.MAX_EVENTS; import static datadog.trace.api.gateway.Events.NETWORK_CONNECTION_ID; @@ -374,6 +375,7 @@ public Flow apply(RequestContext ctx, Integer status) { } }; case DATABASE_CONNECTION_ID: + case HTTP_ROUTE_ID: return (C) new BiConsumer() { @Override diff --git a/internal-api/src/test/java/datadog/trace/api/gateway/InstrumentationGatewayTest.java b/internal-api/src/test/java/datadog/trace/api/gateway/InstrumentationGatewayTest.java index 8f2840d4308..5cbede406d1 100644 --- a/internal-api/src/test/java/datadog/trace/api/gateway/InstrumentationGatewayTest.java +++ b/internal-api/src/test/java/datadog/trace/api/gateway/InstrumentationGatewayTest.java @@ -223,6 +223,8 @@ public void testNormalCalls() { cbp.getCallback(events.execCmd()).apply(null, null); ss.registerCallback(events.shellCmd(), callback); cbp.getCallback(events.shellCmd()).apply(null, null); + ss.registerCallback(events.httpRoute(), callback); + cbp.getCallback(events.httpRoute()).accept(null, null); assertThat(callback.count).isEqualTo(Events.MAX_EVENTS); } @@ -293,6 +295,8 @@ public void testThrowableBlocking() { cbp.getCallback(events.execCmd()).apply(null, null); ss.registerCallback(events.shellCmd(), throwback); cbp.getCallback(events.shellCmd()).apply(null, null); + ss.registerCallback(events.httpRoute(), throwback); + cbp.getCallback(events.httpRoute()).accept(null, null); assertThat(throwback.count).isEqualTo(Events.MAX_EVENTS); }