diff --git a/dd-smoke-tests/appsec/lambda-sca/build.gradle b/dd-smoke-tests/appsec/lambda-sca/build.gradle new file mode 100644 index 00000000000..8a499fa9629 --- /dev/null +++ b/dd-smoke-tests/appsec/lambda-sca/build.gradle @@ -0,0 +1,23 @@ +plugins { + id 'dd-trace-java.module.smoke-test' +} + +description = 'Lambda SCA reachability smoke test' + +dependencies { + compileOnly project(':dd-trace-api') + implementation group: 'com.amazonaws', name: 'aws-lambda-java-core', version: '1.2.3' + runtimeOnly group: 'com.github.junrar', name: 'junrar', version: '7.5.5' + testImplementation project(':dd-smoke-tests') +} + +// Launch with the runtime classpath: Junrar must retain its own JAR code source for SCA. +tasks.withType(Test).configureEach { + dependsOn tasks.named('classes') + jvmArgumentProviders.add(new CommandLineArgumentProvider() { + @Override + Iterable asArguments() { + return ["-Ddatadog.smoketest.lambda.classpath=${sourceSets.main.runtimeClasspath.asPath}"] + } + }) +} diff --git a/dd-smoke-tests/appsec/lambda-sca/gradle.lockfile b/dd-smoke-tests/appsec/lambda-sca/gradle.lockfile new file mode 100644 index 00000000000..b1c98b05c25 --- /dev/null +++ b/dd-smoke-tests/appsec/lambda-sca/gradle.lockfile @@ -0,0 +1,128 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +# To regenerate this file, run: ./gradlew :dd-smoke-tests:appsec:lambda-sca:dependencies --write-locks +cafe.cryptography:curve25519-elisabeth:0.1.0=testRuntimeClasspath +cafe.cryptography:ed25519-elisabeth:0.1.0=testRuntimeClasspath +ch.qos.logback:logback-classic:1.2.13=testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.2.13=testCompileClasspath,testRuntimeClasspath +com.amazonaws:aws-lambda-java-core:1.2.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.blogspot.mydailyjava:weak-lock-free:0.17=testCompileClasspath,testRuntimeClasspath +com.datadoghq.okhttp3:okhttp:3.12.15=testCompileClasspath,testRuntimeClasspath +com.datadoghq.okio:okio:1.17.6=testCompileClasspath,testRuntimeClasspath +com.datadoghq:dd-instrument-java:0.0.6=testCompileClasspath,testRuntimeClasspath +com.datadoghq:dd-javac-plugin-client:0.2.2=testCompileClasspath,testRuntimeClasspath +com.datadoghq:java-dogstatsd-client:4.4.5=testRuntimeClasspath +com.datadoghq:sketches-java:0.8.3=testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.10.3=testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-api:3.4.2=testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.4.2=testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport:3.4.2=testCompileClasspath,testRuntimeClasspath +com.github.javaparser:javaparser-core:3.25.6=codenarc +com.github.jnr:jffi:1.3.15=testRuntimeClasspath +com.github.jnr:jnr-a64asm:1.0.0=testRuntimeClasspath +com.github.jnr:jnr-constants:0.10.4=testRuntimeClasspath +com.github.jnr:jnr-enxio:0.32.20=testRuntimeClasspath +com.github.jnr:jnr-ffi:2.2.19=testRuntimeClasspath +com.github.jnr:jnr-posix:3.1.22=testRuntimeClasspath +com.github.jnr:jnr-unixsocket:0.38.25=testRuntimeClasspath +com.github.jnr:jnr-x86asm:1.0.2=testRuntimeClasspath +com.github.junrar:junrar:7.5.5=runtimeClasspath,testRuntimeClasspath +com.github.spotbugs:spotbugs-annotations:4.10.4=compileClasspath,spotbugs +com.github.spotbugs:spotbugs:4.10.4=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.code.findbugs:jsr305:3.0.2=compileClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +com.google.code.gson:gson:2.14.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.48.0=spotbugs +com.google.guava:failureaccess:1.0.3=testCompileClasspath,testRuntimeClasspath +com.google.guava:guava:33.6.0-jre=testCompileClasspath,testRuntimeClasspath +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=testCompileClasspath,testRuntimeClasspath +com.google.j2objc:j2objc-annotations:3.1=testCompileClasspath,testRuntimeClasspath +com.google.re2j:re2j:1.8=testRuntimeClasspath +com.squareup.moshi:moshi:1.11.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:logging-interceptor:3.12.12=testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:3.12.12=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio:1.17.5=testCompileClasspath,testRuntimeClasspath +com.thoughtworks.qdox:qdox:1.12.1=codenarc +commons-fileupload:commons-fileupload:1.5=testCompileClasspath,testRuntimeClasspath +commons-io:commons-io:2.11.0=testCompileClasspath,testRuntimeClasspath +commons-io:commons-io:2.21.0=spotbugs +de.thetaphi:forbiddenapis:3.11=compileClasspath,testCompileClasspath,testRuntimeClasspath +io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath +io.sqreen:libsqreen:17.5.0=testRuntimeClasspath +javax.servlet:javax.servlet-api:3.1.0=testCompileClasspath,testRuntimeClasspath +jaxen:jaxen:2.0.6=spotbugs +junit:junit:4.13.2=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy-agent:1.18.12=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.18.12=testCompileClasspath,testRuntimeClasspath +net.java.dev.jna:jna-platform:5.8.0=testRuntimeClasspath +net.java.dev.jna:jna:5.13.0=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.10=spotbugs +org.apache.ant:ant-antlr:1.10.14=codenarc +org.apache.ant:ant-junit:1.10.14=codenarc +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-compress:1.24.0=testCompileClasspath,testRuntimeClasspath +org.apache.commons:commons-lang3:3.20.0=spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.logging.log4j:log4j-api:2.26.1=spotbugs +org.apache.logging.log4j:log4j-core:2.26.1=spotbugs +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath,testRuntimeClasspath +org.codehaus.groovy:groovy-ant:3.0.23=codenarc +org.codehaus.groovy:groovy-docgenerator:3.0.23=codenarc +org.codehaus.groovy:groovy-groovydoc:3.0.23=codenarc +org.codehaus.groovy:groovy-json:3.0.23=codenarc +org.codehaus.groovy:groovy-json:3.0.25=testCompileClasspath,testRuntimeClasspath +org.codehaus.groovy:groovy-templates:3.0.23=codenarc +org.codehaus.groovy:groovy-xml:3.0.23=codenarc +org.codehaus.groovy:groovy:3.0.23=codenarc +org.codehaus.groovy:groovy:3.0.25=testCompileClasspath,testRuntimeClasspath +org.codenarc:CodeNarc:3.7.0=codenarc +org.dom4j:dom4j:2.2.0=spotbugs +org.gmetrics:GMetrics:2.1.0=codenarc +org.hamcrest:hamcrest-core:1.3=testCompileClasspath,testRuntimeClasspath +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.jctools:jctools-core-jdk11:4.0.6=testRuntimeClasspath +org.jctools:jctools-core:4.0.6=testRuntimeClasspath +org.jetbrains:annotations:17.0.0=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.0=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.14.1=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.14.1=testRuntimeClasspath +org.junit.platform:junit-platform-runner:1.14.1=testRuntimeClasspath +org.junit.platform:junit-platform-suite-api:1.14.1=testRuntimeClasspath +org.junit.platform:junit-platform-suite-commons:1.14.1=testRuntimeClasspath +org.junit:junit-bom:5.14.1=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-core:4.4.0=testRuntimeClasspath +org.msgpack:msgpack-core:0.8.24=testRuntimeClasspath +org.objenesis:objenesis:3.3=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-analysis:9.7.1=testRuntimeClasspath +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.7.1=testRuntimeClasspath +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.7.1=testRuntimeClasspath +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm-util:9.7.1=testRuntimeClasspath +org.ow2.asm:asm:9.10.1=spotbugs,testCompileClasspath,testRuntimeClasspath +org.rnorth.duct-tape:duct-tape:1.0.8=testCompileClasspath,testRuntimeClasspath +org.slf4j:jcl-over-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:log4j-over-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:1.7.30=compileClasspath +org.slf4j:slf4j-api:1.7.36=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-api:2.0.18=spotbugs +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.spockframework:spock-bom:2.4-groovy-3.0=testCompileClasspath,testRuntimeClasspath +org.spockframework:spock-core:2.4-groovy-3.0=testCompileClasspath,testRuntimeClasspath +org.tabletest:tabletest-junit:1.2.2=testCompileClasspath,testRuntimeClasspath +org.tabletest:tabletest-parser:1.2.1=testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers:1.21.4=testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=spotbugs +empty=annotationProcessor,spotbugsPlugins,testAnnotationProcessor diff --git a/dd-smoke-tests/appsec/lambda-sca/src/main/java/datadog/smoketest/lambda/LambdaScaApplication.java b/dd-smoke-tests/appsec/lambda-sca/src/main/java/datadog/smoketest/lambda/LambdaScaApplication.java new file mode 100644 index 00000000000..9dc0c7b1753 --- /dev/null +++ b/dd-smoke-tests/appsec/lambda-sca/src/main/java/datadog/smoketest/lambda/LambdaScaApplication.java @@ -0,0 +1,105 @@ +package datadog.smoketest.lambda; + +import com.amazonaws.services.lambda.runtime.Context; +import com.sun.net.httpserver.HttpServer; +import datadog.trace.api.GlobalTracer; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.lang.reflect.Proxy; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.Executors; + +/** Drives a real streaming Lambda handler and stubs the extension's invocation protocol. */ +public final class LambdaScaApplication { + public static void main(String[] args) throws Exception { + HttpServer server = + HttpServer.create(new InetSocketAddress("127.0.0.1", Integer.parseInt(args[0])), 0); + // Invocation instrumentation calls the extension while the /invoke request is still running. + server.setExecutor(Executors.newCachedThreadPool()); + server.createContext( + "/lambda/start-invocation", + exchange -> { + exchange.getResponseHeaders().add("x-datadog-trace-id", "123"); + exchange.getResponseHeaders().add("x-datadog-span-id", "456"); + exchange.getResponseHeaders().add("x-datadog-sampling-priority", "1"); + exchange.sendResponseHeaders(200, -1); + exchange.close(); + }); + server.createContext( + "/lambda/end-invocation", + exchange -> { + System.out.println( + "LAMBDA_END " + + exchange.getRequestHeaders().getFirst("lambda-runtime-aws-request-id")); + exchange.sendResponseHeaders(200, -1); + exchange.close(); + }); + // The setter belongs to the agent's isolated classloader, not the fixture's classpath. + GlobalTracer.get() + .getClass() + .getClassLoader() + .loadClass("datadog.trace.lambda.LambdaHandler") + .getMethod("setExtensionBaseUrl", String.class) + .invoke(null, "http://127.0.0.1:" + server.getAddress().getPort()); + // This endpoint only loads the class. The test waits for advisory telemetry before invoking it. + server.createContext( + "/load-dependency", + exchange -> { + try { + Class.forName("com.github.junrar.LocalFolderExtractor"); + exchange.sendResponseHeaders(200, -1); + } catch (ClassNotFoundException failure) { + failure.printStackTrace(); + exchange.sendResponseHeaders(500, -1); + } finally { + exchange.close(); + } + }); + LambdaScaHandler handler = new LambdaScaHandler(); + server.createContext( + "/invoke/", + exchange -> { + String invocation = exchange.getRequestURI().getPath().substring("/invoke/".length()); + Context context = + (Context) + Proxy.newProxyInstance( + Context.class.getClassLoader(), + new Class[] {Context.class}, + (proxy, method, arguments) -> { + switch (method.getName()) { + case "getAwsRequestId": + return invocation; + case "getRemainingTimeInMillis": + return 30_000; + case "getMemoryLimitInMB": + return 1024; + case "equals": + return proxy == arguments[0]; + case "hashCode": + return System.identityHashCode(proxy); + case "toString": + return "LambdaContext[requestId=" + invocation + "]"; + default: + return null; + } + }); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try { + handler.handleRequest( + new ByteArrayInputStream(invocation.getBytes(StandardCharsets.UTF_8)), + output, + context); + byte[] response = output.toByteArray(); + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + } catch (Exception failure) { + failure.printStackTrace(); + exchange.sendResponseHeaders(500, -1); + } finally { + exchange.close(); + } + }); + server.start(); + } +} diff --git a/dd-smoke-tests/appsec/lambda-sca/src/main/java/datadog/smoketest/lambda/LambdaScaHandler.java b/dd-smoke-tests/appsec/lambda-sca/src/main/java/datadog/smoketest/lambda/LambdaScaHandler.java new file mode 100644 index 00000000000..d24af6a093f --- /dev/null +++ b/dd-smoke-tests/appsec/lambda-sca/src/main/java/datadog/smoketest/lambda/LambdaScaHandler.java @@ -0,0 +1,46 @@ +package datadog.smoketest.lambda; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestStreamHandler; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; + +/** Calls the watched method inside an instrumented Lambda invocation. */ +public final class LambdaScaHandler implements RequestStreamHandler { + @Override + public void handleRequest(InputStream input, OutputStream output, Context context) + throws IOException { + try { + warmInvocation(); + output.write("OK".getBytes(StandardCharsets.UTF_8)); + } catch (ReflectiveOperationException failure) { + throw new IOException(failure); + } + } + + private static void warmInvocation() throws ReflectiveOperationException { + Class extractor = Class.forName("com.github.junrar.LocalFolderExtractor"); + Constructor constructor = extractor.getDeclaredConstructor(File.class); + constructor.setAccessible(true); + Object instance = constructor.newInstance(new File(System.getProperty("java.io.tmpdir"))); + Method method = + extractor.getDeclaredMethod( + "createDirectory", Class.forName("com.github.junrar.rarfile.FileHeader")); + method.setAccessible(true); + try { + // LambdaScaSmokeTest asserts the exact line of this call; update it if this line moves. + method.invoke(instance, (Object) null); + } catch (InvocationTargetException failure) { + // Reaching method entry is sufficient; no archive extraction or filesystem write is needed. + if (!(failure.getCause() instanceof NullPointerException)) { + throw failure; + } + } + } +} diff --git a/dd-smoke-tests/appsec/lambda-sca/src/test/java/datadog/smoketest/LambdaScaSmokeTest.java b/dd-smoke-tests/appsec/lambda-sca/src/test/java/datadog/smoketest/LambdaScaSmokeTest.java new file mode 100644 index 00000000000..6987b615869 --- /dev/null +++ b/dd-smoke-tests/appsec/lambda-sca/src/test/java/datadog/smoketest/LambdaScaSmokeTest.java @@ -0,0 +1,119 @@ +package datadog.smoketest; + +import static datadog.smoketest.backend.AgentBackend.testAgent; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.test.util.PollingConditions; +import groovy.json.JsonSlurper; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * Verifies SCA reachability inside a Lambda handler with AppSec enabled and disabled. The disabled + * configuration also verifies that SCA initializes independently of AppSec. + */ +class LambdaScaSmokeTest { + @RegisterExtension static final SmokeServerApp scaOnlyApp = createApp(false); + + @RegisterExtension static final SmokeServerApp appsecApp = createApp(true); + + private static SmokeServerApp createApp(boolean appsecEnabled) { + return SmokeServerApp.named(appsecEnabled ? "lambda-sca-appsec" : "lambda-sca-only") + .mainClass( + "datadog.smoketest.lambda.LambdaScaApplication", + System.getProperty("datadog.smoketest.lambda.classpath")) + .args("${app.httpPort}") + .env("AWS_LAMBDA_FUNCTION_NAME", "sca-smoke") + .env("_HANDLER", "datadog.smoketest.lambda.LambdaScaHandler::handleRequest") + .jvmArgs("-Ddd.appsec.sca.enabled=true", "-Ddd.appsec.enabled=" + appsecEnabled) + .backend(testAgent()) + .build(); + } + + @Test + void reportsAVulnerableCallInsideTheLambdaHandlerWithScaAlone() { + assertReachability(scaOnlyApp); + } + + @Test + void reportsAVulnerableCallInsideTheLambdaHandlerWithAppSecEnabled() { + assertReachability(appsecApp); + } + + private static void assertReachability(SmokeServerApp app) { + assertEquals(200, app.get("/load-dependency")); + // Advisory telemetry is emitted after deferred retransformation has completed. No watched + // method has executed yet, so this establishes readiness without a sleep or a warm-up call. + app.backend() + .telemetry() + .waitForFlat( + event -> + advisories(event).stream() + .anyMatch(advisory -> Collections.emptyList().equals(advisory.get("reached")))); + + assertEquals(200, app.get("/invoke/warm")); + new PollingConditions(30) + .eventually( + () -> + assertTrue( + app.backend().traces().getTraces().stream() + .flatMap(trace -> trace.getSpans().stream()) + .anyMatch( + span -> + "dd-tracer-serverless-span".equals(span.getName()) + && "serverless".equals(span.getType()) + && "warm".equals(span.getMeta().get("request_id"))), + "expected serverless invocation span for request warm")); + app.backend() + .telemetry() + .waitForFlat( + event -> + reachedCallsites(event).stream() + .anyMatch(hit -> "warmInvocation".equals(hit.get("symbol")))); + + Map caller = + app.backend().telemetry().getFlatMessages().stream() + .flatMap(event -> reachedCallsites(event).stream()) + .filter(hit -> "warmInvocation".equals(hit.get("symbol"))) + .findFirst() + .orElseThrow(() -> new AssertionError("expected Lambda caller was not reported")); + assertEquals("datadog.smoketest.lambda.LambdaScaHandler", caller.get("path")); + assertEquals("warmInvocation", caller.get("symbol")); + assertTrue(caller.get("line") instanceof Number, "caller line must be numeric"); + assertEquals(38, ((Number) caller.get("line")).intValue()); + } + + @SuppressWarnings("unchecked") + private static List> reachedCallsites(Map event) { + List> hits = new ArrayList<>(); + for (Map advisory : advisories(event)) { + hits.addAll((List>) advisory.get("reached")); + } + return hits; + } + + @SuppressWarnings("unchecked") + private static List> advisories(Map event) { + List> values = new ArrayList<>(); + if (!"app-dependencies-loaded".equals(event.get("request_type"))) return values; + Map payload = (Map) event.get("payload"); + for (Map dependency : (List>) payload.get("dependencies")) { + if (!"junrar".equals(dependency.get("name")) || !"7.5.5".equals(dependency.get("version"))) + continue; + Object metadata = dependency.get("metadata"); + if (!(metadata instanceof List)) continue; + for (Map entry : (List>) metadata) { + if (!"reachability".equals(entry.get("type"))) continue; + Map value = + (Map) new JsonSlurper().parseText((String) entry.get("value")); + if ("GHSA-hf5p-q87m-crj7".equals(value.get("id"))) values.add(value); + } + } + return values; + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 263f51e3f16..2f74269e13a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -270,6 +270,7 @@ include( ":dd-smoke-tests:wildfly", ":dd-smoke-tests:appsec", ":dd-smoke-tests:appsec:spring-tomcat7", + ":dd-smoke-tests:appsec:lambda-sca", ":dd-smoke-tests:appsec:springboot", ":dd-smoke-tests:appsec:springboot-grpc", ":dd-smoke-tests:appsec:springboot-graphql",