diff --git a/dd-smoke-tests/asm-standalone-billing/build.gradle b/dd-smoke-tests/asm-standalone-billing/build.gradle new file mode 100644 index 00000000000..d5f5774ef34 --- /dev/null +++ b/dd-smoke-tests/asm-standalone-billing/build.gradle @@ -0,0 +1,31 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '2.7.15' + id 'io.spring.dependency-management' version '1.0.15.RELEASE' + id 'java-test-fixtures' +} + +apply from: "$rootDir/gradle/java.gradle" +description = 'ASM Standalone Billing Tests.' + +java { + sourceCompatibility = '1.8' +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation group: 'io.opentracing', name: 'opentracing-api', version: '0.32.0' + implementation group: 'io.opentracing', name: 'opentracing-util', version: '0.32.0' + implementation project(':dd-trace-api') + testImplementation project(':dd-smoke-tests') + testImplementation(testFixtures(project(":dd-smoke-tests:iast-util"))) +} + +tasks.withType(Test).configureEach { + dependsOn "bootJar" + jvmArgs "-Ddatadog.smoketest.springboot.shadowJar.path=${tasks.bootJar.archiveFile.get()}" +} diff --git a/dd-smoke-tests/asm-standalone-billing/src/main/java/datadog/smoketest/asmstandalonebilling/AppConfig.java b/dd-smoke-tests/asm-standalone-billing/src/main/java/datadog/smoketest/asmstandalonebilling/AppConfig.java new file mode 100644 index 00000000000..3db04b37581 --- /dev/null +++ b/dd-smoke-tests/asm-standalone-billing/src/main/java/datadog/smoketest/asmstandalonebilling/AppConfig.java @@ -0,0 +1,25 @@ +package datadog.smoketest.asmstandalonebilling; + +import java.util.EnumSet; +import javax.servlet.ServletContext; +import javax.servlet.ServletException; +import javax.servlet.SessionTrackingMode; +import org.springframework.boot.web.servlet.ServletContextInitializer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class AppConfig { + @Bean + public ServletContextInitializer servletContextInitializer() { + return new SessionTrackingConfig(); + } + + private class SessionTrackingConfig implements ServletContextInitializer { + @Override + public void onStartup(ServletContext servletContext) throws ServletException { + EnumSet sessionTrackingModes = EnumSet.of(SessionTrackingMode.COOKIE); + servletContext.setSessionTrackingModes(sessionTrackingModes); + } + } +} diff --git a/dd-smoke-tests/asm-standalone-billing/src/main/java/datadog/smoketest/asmstandalonebilling/Controller.java b/dd-smoke-tests/asm-standalone-billing/src/main/java/datadog/smoketest/asmstandalonebilling/Controller.java new file mode 100644 index 00000000000..e8db5f60eda --- /dev/null +++ b/dd-smoke-tests/asm-standalone-billing/src/main/java/datadog/smoketest/asmstandalonebilling/Controller.java @@ -0,0 +1,82 @@ +package datadog.smoketest.asmstandalonebilling; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import io.opentracing.Span; +import io.opentracing.util.GlobalTracer; +import java.io.IOException; +import javax.servlet.http.HttpServletResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestTemplate; + +@RestController +@RequestMapping("/rest-api") +public class Controller { + + @GetMapping("/greetings") + public String greetings(@RequestParam(name = "forceKeep", required = false) boolean forceKeep) { + if (forceKeep) { + forceKeepSpan(); + } + return "Hello I'm service " + System.getProperty("dd.service.name"); + } + + @GetMapping("/appsec/{id}") + public String pathParam( + @PathVariable("id") String id, + @RequestParam(name = "url", required = false) String url, + @RequestParam(name = "forceKeep", required = false) boolean forceKeep) { + if (forceKeep) { + forceKeepSpan(); + } + if (url != null) { + RestTemplate restTemplate = new RestTemplate(); + return restTemplate.getForObject(url, String.class); + } + return id; + } + + @GetMapping("/iast") + @SuppressFBWarnings + public void write( + @RequestParam(name = "injection", required = false) String injection, + @RequestParam(name = "url", required = false) String url, + @RequestParam(name = "forceKeep", required = false) boolean forceKeep, + final HttpServletResponse response) { + if (forceKeep) { + forceKeepSpan(); + } + if (injection != null) { + try { + response.getWriter().write(injection); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + if (url != null) { + RestTemplate restTemplate = new RestTemplate(); + restTemplate.getForObject(url, String.class); + } + } + + /** + * @GetMapping("/forcekeep") public String forceKeep() { return "Span " + forceKeepSpan() + " will + * be kept alive"; } @GetMapping("/call") public String call( @RequestParam(name = "url", required + * = false) String url, @RequestParam(name = "forceKeep", required = false) boolean forceKeep) { + * if (forceKeep) { forceKeepSpan(); } if (url != null) { RestTemplate restTemplate = new + * RestTemplate(); return restTemplate.getForObject(url, String.class); } return "No url + * provided"; } + */ + private String forceKeepSpan() { + // TODO: Configure the keep alive in dd-trace-api + final Span span = GlobalTracer.get().activeSpan(); + if (span != null) { + span.setTag("manual.keep", true); + return span.context().toSpanId(); + } + return null; + } +} diff --git a/dd-smoke-tests/asm-standalone-billing/src/main/java/datadog/smoketest/asmstandalonebilling/SpringbootApplication.java b/dd-smoke-tests/asm-standalone-billing/src/main/java/datadog/smoketest/asmstandalonebilling/SpringbootApplication.java new file mode 100644 index 00000000000..0ac36d7d2f7 --- /dev/null +++ b/dd-smoke-tests/asm-standalone-billing/src/main/java/datadog/smoketest/asmstandalonebilling/SpringbootApplication.java @@ -0,0 +1,11 @@ +package datadog.smoketest.asmstandalonebilling; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SpringbootApplication { + public static void main(String[] args) { + SpringApplication.run(SpringbootApplication.class, args); + } +} diff --git a/dd-smoke-tests/asm-standalone-billing/src/test/groovy/datadog/smoketest/asmstandalonebilling/AbstractAsmStandaloneBillingSmokeTest.groovy b/dd-smoke-tests/asm-standalone-billing/src/test/groovy/datadog/smoketest/asmstandalonebilling/AbstractAsmStandaloneBillingSmokeTest.groovy new file mode 100644 index 00000000000..201236f9023 --- /dev/null +++ b/dd-smoke-tests/asm-standalone-billing/src/test/groovy/datadog/smoketest/asmstandalonebilling/AbstractAsmStandaloneBillingSmokeTest.groovy @@ -0,0 +1,63 @@ +package datadog.smoketest.asmstandalonebilling + +import datadog.smoketest.AbstractServerSmokeTest +import datadog.trace.api.sampling.PrioritySampling +import datadog.trace.test.agent.decoder.DecodedTrace + +abstract class AbstractAsmStandaloneBillingSmokeTest extends AbstractServerSmokeTest { + + @Override + File createTemporaryFile(int processIndex) { + return null + } + + @Override + String logLevel() { + return 'debug' + } + + @Override + Closure decodedTracesCallback() { + return {} // force traces decoding + } + + protected ProcessBuilder createProcess(String[] properties){ + createProcess(-1, properties) + } + + + protected ProcessBuilder createProcess(int processIndex, String[] properties){ + def port = processIndex == -1 ? httpPort : httpPorts[processIndex] + String springBootShadowJar = System.getProperty("datadog.smoketest.springboot.shadowJar.path") + List command = [] + command.add(javaPath()) + command.addAll(defaultJavaProperties) + command.addAll(properties) + command.addAll((String[]) ['-jar', springBootShadowJar, "--server.port=${port}"]) + ProcessBuilder processBuilder = new ProcessBuilder(command) + processBuilder.directory(new File(buildDirectory)) + // Spring will print all environment variables to the log, which may pollute it and affect log assertions. + processBuilder.environment().clear() + return processBuilder + } + + protected DecodedTrace getServiceTrace(String serviceName) { + return traces.find { trace -> + trace.spans.find { span -> + span.service == serviceName + } + } + } + + protected checkRootSpanPrioritySampling(DecodedTrace trace, byte priority) { + return trace.spans[0].metrics['_sampling_priority_v1'] == priority + } + + protected hasAppsecPropagationTag(DecodedTrace trace) { + return trace.spans[0].meta['_dd.p.appsec'] == "1" + } + + protected hasApmDisabledTag(DecodedTrace trace) { + return trace.spans[0].metrics['_dd.apm.enabled'] == 0 + } +} diff --git a/dd-smoke-tests/asm-standalone-billing/src/test/groovy/datadog/smoketest/asmstandalonebilling/AsmStandaloneBillingSamplingSmokeTest.groovy b/dd-smoke-tests/asm-standalone-billing/src/test/groovy/datadog/smoketest/asmstandalonebilling/AsmStandaloneBillingSamplingSmokeTest.groovy new file mode 100644 index 00000000000..981624a7bda --- /dev/null +++ b/dd-smoke-tests/asm-standalone-billing/src/test/groovy/datadog/smoketest/asmstandalonebilling/AsmStandaloneBillingSamplingSmokeTest.groovy @@ -0,0 +1,69 @@ +package datadog.smoketest.asmstandalonebilling + +import datadog.trace.api.sampling.PrioritySampling +import okhttp3.Request + +class AsmStandaloneBillingSamplingSmokeTest extends AbstractAsmStandaloneBillingSmokeTest { + + @Override + ProcessBuilder createProcessBuilder(){ + final String[] processProperties = [ + "-Ddd.experimental.appsec.standalone.enabled=true", + "-Ddd.iast.enabled=true", + "-Ddd.iast.detection.mode=FULL", + "-Ddd.iast.debug.enabled=true", + "-Ddd.service.name=asm-standalone-billing-sampling-spring-smoketest-app", + ] + return createProcess(processProperties) + } + + void 'test force keep call using time sampling'() { + setup: + final vulnerableUrl = "http://localhost:${httpPorts[0]}/rest-api/iast?injection=xss" + final vulnerableRequest = new Request.Builder().url(vulnerableUrl).get().build() + final forceKeepUrl = "http://localhost:${httpPorts[0]}/rest-api/greetings?forceKeep=true" + final forceKeepRequest = new Request.Builder().url(forceKeepUrl).get().build() + final noForceKeepUrl = "http://localhost:${httpPorts[0]}/rest-api/greetings" + final noForceKeepRequest = new Request.Builder().url(noForceKeepUrl).get().build() + + when: "firs request with ASM events" + final vulnerableResponse = client.newCall(vulnerableRequest).execute() + + then: "First trace should have a root span with USER_KEEP sampling priority due to ASM events" + vulnerableResponse.successful + waitForTraceCount(1) + assert traces.size() == 1 + checkRootSpanPrioritySampling(traces[0], PrioritySampling.USER_KEEP) + hasAppsecPropagationTag(traces[0]) + + when: "Request without ASM events and no force kept span" + final noForceKeepResponse = client.newCall(noForceKeepRequest).execute() + + then: "This trace should enter into the sampling mechanism and have a root span with SAMPLER_KEEP sampling priority as it's the first span checked in a minute" + noForceKeepResponse.successful + waitForTraceCount(2) + assert traces.size() == 2 + checkRootSpanPrioritySampling(traces[1], PrioritySampling.SAMPLER_KEEP) + !hasAppsecPropagationTag(traces[1]) + + when: "Request without ASM events and force kept span" + final forceKeepResponse = client.newCall(forceKeepRequest).execute() + + then: "This trace should have a root span with USER_KEEP sampling priority as although it's not the first span checked in a minute, it's force kept'" + forceKeepResponse.successful + waitForTraceCount(3) + assert traces.size() == 3 + checkRootSpanPrioritySampling(traces[2], PrioritySampling.USER_KEEP) + !hasAppsecPropagationTag(traces[2]) + + when: "Second request without ASM events and no force kept span" + final noForceKeepResponse2 = client.newCall(noForceKeepRequest).execute() + + then: "This trace should enter into the sampling mechanism and have a root span with SAMPLER_DROP sampling priority as it's not the first span checked in a minute" + noForceKeepResponse2.successful + waitForTraceCount(4) + assert traces.size() == 4 + checkRootSpanPrioritySampling(traces[3], PrioritySampling.SAMPLER_DROP) + !hasAppsecPropagationTag(traces[3]) + } +} diff --git a/dd-smoke-tests/asm-standalone-billing/src/test/groovy/datadog/smoketest/asmstandalonebilling/AsmStandaloneBillingSmokeTest.groovy b/dd-smoke-tests/asm-standalone-billing/src/test/groovy/datadog/smoketest/asmstandalonebilling/AsmStandaloneBillingSmokeTest.groovy new file mode 100644 index 00000000000..afd5310d7f8 --- /dev/null +++ b/dd-smoke-tests/asm-standalone-billing/src/test/groovy/datadog/smoketest/asmstandalonebilling/AsmStandaloneBillingSmokeTest.groovy @@ -0,0 +1,115 @@ +package datadog.smoketest.asmstandalonebilling + +import okhttp3.Request + +class AsmStandaloneBillingSmokeTest extends AbstractAsmStandaloneBillingSmokeTest { + + private static final String standAloneBillingServiceName = "asm-standalone-billing-smoketest-app" + private static final String apmEnabledServiceName = "apm-enabled-smoketest-app" + + static final String[] standAloneBillingProperties = [ + "-Ddd.experimental.appsec.standalone.enabled=true", + "-Ddd.iast.enabled=true", + "-Ddd.iast.detection.mode=FULL", + "-Ddd.iast.debug.enabled=true", + "-Ddd.appsec.enabled=true", + "-Ddd.trace.tracer.metrics.enabled=true", + "-Ddd.service.name=${standAloneBillingServiceName}", + ] + + static final String[] apmEnabledProperties = ["-Ddd.service.name=${apmEnabledServiceName}", "-Ddd.trace.tracer.metrics.enabled=true",] + + protected int numberOfProcesses() { + return 2 + } + + @Override + ProcessBuilder createProcessBuilder(int processIndex) { + if(processIndex == 0){ + return createProcess(processIndex, standAloneBillingProperties) + } else { + return createProcess(processIndex, apmEnabledProperties) + } + } + + void 'When APM is disabled, numeric tag _dd.apm.enabled:0 must be added to the metrics map of the service entry spans.'() { + setup: + final url1 = "http://localhost:${httpPorts[0]}/rest-api/greetings" + final request1 = new Request.Builder().url(url1).get().build() + final url2 = "http://localhost:${httpPorts[1]}/rest-api/greetings" + final request2 = new Request.Builder().url(url2).get().build() + + when: + final response1 = client.newCall(request1).execute() + final response2 = client.newCall(request2).execute() + + then: + response1.successful + response2.successful + waitForTraceCount(2) + hasApmDisabledTag(getServiceTrace(standAloneBillingServiceName)) + !hasApmDisabledTag(getServiceTrace(apmEnabledServiceName)) + } + + void 'When APM is disabled, libraries must completely disable the generation of APM trace metrics'(){ + setup: + final url1 = "http://localhost:${httpPorts[0]}/rest-api/greetings" + final request1 = new Request.Builder().url(url1).get().build() + + when: + client.newCall(request1).execute() + + then: 'Check if Datadog-Client-Computed-Stats header is present and set to true, Disabling the metrics computation agent-side' + waitForTraceCount(1) + def computedStatsHeader = server.lastRequest.headers.get('Datadog-Client-Computed-Stats') + assert computedStatsHeader != null && computedStatsHeader == 'true' + + then:'metrics should be disabled' + checkLogPostExit { log -> + if (log.contains('datadog.trace.agent.common.metrics.MetricsAggregatorFactory - tracer metrics disabled')) { + return true + } + return false + } + } + + void 'test _dd.p.appsec propagation for appsec event'() { + setup: + final downstreamUrl = "http://localhost:${httpPorts[1]}/rest-api/greetings" + final url = localUrl + "url=${downstreamUrl}" + final request = new Request.Builder().url(url).get().build() + + when: "Request to an endpoint that triggers ASM events and then calls another endpoint" + final response1 = client.newCall(request).execute() + + then: "Both traces should have a root span with _dd.p.appsec=1 tag" + response1.successful + waitForTraceCount(2) + assert traces.size() == 2 + hasAppsecPropagationTag(traces.get(0)) + hasAppsecPropagationTag(traces.get(1)) + + where: + localUrl << [ + "http://localhost:${httpPorts[0]}/rest-api/appsec/appscan_fingerprint?", + "http://localhost:${httpPorts[0]}/rest-api/iast?injection=vulnerable&" + ] + } + + /* + void 'test _dd.p.appsec propagation for iast event'() { + setup: + final downstreamUrl = "http://localhost:${httpPorts[1]}/rest-api/greetings" + final url = "http://localhost:${httpPorts[0]}/rest-api/iast?injection=vulnerable&url=${downstreamUrl}" + final request = new Request.Builder().url(url1).get().build() + when: "Request to an endpoint that triggers ASM events and then calls another endpoint" + final response1 = client.newCall(request1).execute() + then: "Both traces should have a root span with _dd.p.appsec=1 tag" + response1.successful + waitForTraceCount(2) + assert traces.size() == 2 + hasAppsecPropagationTag(traces.get(0)) + hasAppsecPropagationTag(traces.get(1)) + } + */ +} diff --git a/settings.gradle b/settings.gradle index e43ec454e67..5d327f489fe 100644 --- a/settings.gradle +++ b/settings.gradle @@ -86,6 +86,7 @@ include ':utils:version-utils' // smoke tests include ':dd-smoke-tests:armeria-grpc' +include ':dd-smoke-tests:asm-standalone-billing' include ':dd-smoke-tests:cli' include ':dd-smoke-tests:custom-systemloader' include ':dd-smoke-tests:dynamic-config' @@ -452,3 +453,5 @@ include ':dd-java-agent:benchmark' include ':dd-java-agent:benchmark-integration' include ':dd-java-agent:benchmark-integration:jetty-perftest' include ':dd-java-agent:benchmark-integration:play-perftest' +include 'dd-smoke-tests:asm-standalone-billing' +