diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/Agent.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/Agent.java index b622ef2490d..ae485555666 100644 --- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/Agent.java +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/Agent.java @@ -659,7 +659,7 @@ public void execute() { resumeRemoteComponents(); } - maybeStartAppSec(scoClass, sco); + maybeStartAppSec(instrumentation, scoClass, sco); maybeStartCiVisibility(instrumentation, scoClass, sco); maybeStartLLMObs(instrumentation, scoClass, sco); // start debugger before remote config to subscribe to it before starting to poll @@ -973,7 +973,7 @@ private static void maybeStartAiGuard() { } } - private static void maybeStartAppSec(Class scoClass, Object o) { + private static void maybeStartAppSec(Instrumentation inst, Class scoClass, Object o) { try { // event tracking SDK must be available for customers even if AppSec is fully disabled @@ -990,7 +990,7 @@ private static void maybeStartAppSec(Class scoClass, Object o) { try { SubscriptionService ss = AgentTracer.get().getSubscriptionService(RequestContextSlot.APPSEC); - startAppSec(ss, scoClass, o); + startAppSec(inst, ss, scoClass, o); } catch (Exception e) { log.error("Error starting AppSec System", e); } @@ -998,13 +998,15 @@ private static void maybeStartAppSec(Class scoClass, Object o) { StaticEventLogger.end("AppSec"); } - private static void startAppSec(SubscriptionService ss, Class scoClass, Object sco) { + private static void startAppSec( + Instrumentation inst, SubscriptionService ss, Class scoClass, Object sco) { try { final Class appSecSysClass = AGENT_CLASSLOADER.loadClass("com.datadog.appsec.AppSecSystem"); final Method appSecInstallerMethod = - appSecSysClass.getMethod("start", SubscriptionService.class, scoClass); - appSecInstallerMethod.invoke(null, ss, sco); + appSecSysClass.getMethod( + "start", Instrumentation.class, SubscriptionService.class, scoClass); + appSecInstallerMethod.invoke(null, inst, ss, sco); } catch (final Throwable ex) { log.warn("Not starting AppSec subsystem: {}", ex.getMessage()); } diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/appsec/AppSecSCADetector.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/appsec/AppSecSCADetector.java new file mode 100644 index 00000000000..7ec37adac6d --- /dev/null +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/appsec/AppSecSCADetector.java @@ -0,0 +1,129 @@ +package datadog.trace.bootstrap.instrumentation.appsec; + +import datadog.trace.api.gateway.RequestContext; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.AgentTracer; +import datadog.trace.util.stacktrace.StackTraceEvent; +import datadog.trace.util.stacktrace.StackTraceFrame; +import datadog.trace.util.stacktrace.StackUtils; +import java.util.Collections; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * SCA (Supply Chain Analysis) detection handler. + * + *

This class is called from instrumented bytecode when vulnerable library methods are invoked. + * It must be in the bootstrap classloader to be accessible from any instrumented class. + * + *

Adds vulnerability metadata to the root span for backend reporting and logs detections at + * debug level. + */ +public class AppSecSCADetector { + + private static final Logger log = LoggerFactory.getLogger(AppSecSCADetector.class); + + private static final String METASTRUCT_SCA = "sca"; + + private static final String PREFIX = "_dd.appsec.sca."; + + /** + * Called when a vulnerable method is invoked. + * + *

This method is invoked from instrumented bytecode injected by {@code AppSecSCATransformer}. + * + * @param className The internal class name (e.g., "com/example/Foo") + * @param methodName The method name + * @param descriptor The method descriptor + * @param advisory The advisory ID (e.g., "GHSA-77xx-rxvh-q682"), may be null + * @param cve The CVE ID (e.g., "CVE-2022-41853"), may be null + */ + public static void onMethodInvocation( + String className, String methodName, String descriptor, String advisory, String cve) { + try { + // Convert internal class name to binary name for readability + String binaryClassName = className.replace('/', '.'); + + // Get the active span and add tags to root span + AgentSpan activeSpan = AgentTracer.activeSpan(); + if (activeSpan != null) { + AgentSpan rootSpan = activeSpan.getLocalRootSpan(); + if (rootSpan != null) { + // Tag the root span with SCA detection metadata + rootSpan.setTag(PREFIX + "class", binaryClassName); + rootSpan.setTag(PREFIX + "method", methodName); + + if (advisory != null) { + rootSpan.setTag(PREFIX + "advisory", advisory); + } + if (cve != null) { + rootSpan.setTag(PREFIX + "cve", cve); + } + + // Capture and add stack trace using IAST's system + String stackId = addSCAStackTrace(rootSpan); + if (stackId != null) { + rootSpan.setTag(PREFIX + "stack_id", stackId); + } + } + } + + // Log at debug level + log.debug( + "SCA detection: {} - Vulnerable method invoked: {}#{}", cve, binaryClassName, methodName); + + // TODO: Future enhancements: + // - Report Location + // - Report multiple vulnerabilities per request + // - Implement rate limiting to avoid log spam + // - Add sampling for high-frequency methods + + } catch (Throwable t) { + // Never throw from instrumented callback - would break application + // Silently ignore errors + log.debug("Error in SCA detection handler", t); + } + } + + /** + * Captures and adds the current stack trace to the meta struct using IAST's system. + * + *

Uses the same stacktrace mechanism as IAST to store frames as structured data in the meta + * struct, which will be serialized as an array in the backend. + * + * @param span the span to attach the stack trace to + * @return the stack ID if successful, null otherwise + */ + private static String addSCAStackTrace(AgentSpan span) { + try { + final RequestContext reqCtx = span.getRequestContext(); + if (reqCtx == null) { + return null; + } + + // Generate user code stack trace (filters out Datadog internal frames) + List frames = StackUtils.generateUserCodeStackTrace(); + if (frames == null || frames.isEmpty()) { + return null; + } + + // Create a stack trace event with a unique ID + // Use timestamp + thread ID to create a reasonably unique ID + String stackId = "sca_" + System.currentTimeMillis() + "_" + Thread.currentThread().getId(); + StackTraceEvent stackTraceEvent = + new StackTraceEvent(frames, StackTraceEvent.DEFAULT_LANGUAGE, stackId, null); + + // Add to meta struct using the same system as IAST + StackUtils.addStacktraceEventsToMetaStruct( + reqCtx, METASTRUCT_SCA, Collections.singletonList(stackTraceEvent)); + + return stackId; + + } catch (Throwable t) { + // Never throw from instrumented callback - would break application + log.debug("Failed to capture SCA stack trace", t); + return null; + } + } +} diff --git a/dd-java-agent/agent-bootstrap/src/test/groovy/datadog/trace/bootstrap/instrumentation/appsec/AppSecSCADetectorTest.groovy b/dd-java-agent/agent-bootstrap/src/test/groovy/datadog/trace/bootstrap/instrumentation/appsec/AppSecSCADetectorTest.groovy new file mode 100644 index 00000000000..5f9ac6aee53 --- /dev/null +++ b/dd-java-agent/agent-bootstrap/src/test/groovy/datadog/trace/bootstrap/instrumentation/appsec/AppSecSCADetectorTest.groovy @@ -0,0 +1,246 @@ +package datadog.trace.bootstrap.instrumentation.appsec + +import datadog.trace.api.gateway.RequestContext +import datadog.trace.bootstrap.instrumentation.api.AgentSpan +import datadog.trace.bootstrap.instrumentation.api.AgentTracer +import datadog.trace.test.util.DDSpecification +import spock.lang.Shared + +class AppSecSCADetectorTest extends DDSpecification { + + @Shared + protected static final AgentTracer.TracerAPI ORIGINAL_TRACER = AgentTracer.get() + + AgentTracer.TracerAPI tracer + AgentSpan activeSpan + AgentSpan rootSpan + RequestContext reqCtx + + void setup() { + // Mock the tracer and spans + rootSpan = Mock(AgentSpan) + activeSpan = Mock(AgentSpan) { + getLocalRootSpan() >> rootSpan + } + tracer = Mock(AgentTracer.TracerAPI) { + activeSpan() >> activeSpan + } + reqCtx = Mock(RequestContext) + + // Register mock tracer + AgentTracer.forceRegister(tracer) + } + + void cleanup() { + // Restore original tracer + AgentTracer.forceRegister(ORIGINAL_TRACER) + } + + def "onMethodInvocation adds tags to root span with advisory and cve"() { + given: + String className = "com/example/VulnerableClass" + String methodName = "vulnerableMethod" + String descriptor = "()V" + String advisory = "GHSA-xxxx-yyyy-zzzz" + String cve = "CVE-2024-0001" + + and: + rootSpan.getRequestContext() >> reqCtx + + when: + AppSecSCADetector.onMethodInvocation(className, methodName, descriptor, advisory, cve) + + then: + 1 * rootSpan.setTag("_dd.appsec.sca.class", "com.example.VulnerableClass") + 1 * rootSpan.setTag("_dd.appsec.sca.method", methodName) + 1 * rootSpan.setTag("_dd.appsec.sca.advisory", advisory) + 1 * rootSpan.setTag("_dd.appsec.sca.cve", cve) + // Note: stack_id may or may not be set depending on whether generateUserCodeStackTrace succeeds + (0..1) * rootSpan.setTag("_dd.appsec.sca.stack_id", _) + } + + def "onMethodInvocation adds tags without advisory"() { + given: + String className = "com/example/VulnerableClass" + String methodName = "vulnerableMethod" + String descriptor = "()V" + String advisory = null + String cve = "CVE-2024-0001" + + and: + rootSpan.getRequestContext() >> reqCtx + + when: + AppSecSCADetector.onMethodInvocation(className, methodName, descriptor, advisory, cve) + + then: + 1 * rootSpan.setTag("_dd.appsec.sca.class", "com.example.VulnerableClass") + 1 * rootSpan.setTag("_dd.appsec.sca.method", methodName) + 0 * rootSpan.setTag("_dd.appsec.sca.advisory", _) + 1 * rootSpan.setTag("_dd.appsec.sca.cve", cve) + (0..1) * rootSpan.setTag("_dd.appsec.sca.stack_id", _) + } + + def "onMethodInvocation adds tags without cve"() { + given: + String className = "com/example/VulnerableClass" + String methodName = "vulnerableMethod" + String descriptor = "()V" + String advisory = "GHSA-xxxx-yyyy-zzzz" + String cve = null + + and: + rootSpan.getRequestContext() >> reqCtx + + when: + AppSecSCADetector.onMethodInvocation(className, methodName, descriptor, advisory, cve) + + then: + 1 * rootSpan.setTag("_dd.appsec.sca.class", "com.example.VulnerableClass") + 1 * rootSpan.setTag("_dd.appsec.sca.method", methodName) + 1 * rootSpan.setTag("_dd.appsec.sca.advisory", advisory) + 0 * rootSpan.setTag("_dd.appsec.sca.cve", _) + (0..1) * rootSpan.setTag("_dd.appsec.sca.stack_id", _) + } + + def "onMethodInvocation handles no active span gracefully"() { + given: + String className = "com/example/VulnerableClass" + String methodName = "vulnerableMethod" + String descriptor = "()V" + String advisory = "GHSA-xxxx-yyyy-zzzz" + String cve = "CVE-2024-0001" + + and: + // Mock tracer to return null for activeSpan + def nullTracer = Mock(AgentTracer.TracerAPI) { + activeSpan() >> null + } + AgentTracer.forceRegister(nullTracer) + + when: + AppSecSCADetector.onMethodInvocation(className, methodName, descriptor, advisory, cve) + + then: + notThrown(Exception) + // No span interactions expected + } + + def "onMethodInvocation handles no root span gracefully"() { + given: + String className = "com/example/VulnerableClass" + String methodName = "vulnerableMethod" + String descriptor = "()V" + String advisory = "GHSA-xxxx-yyyy-zzzz" + String cve = "CVE-2024-0001" + + and: + // Mock activeSpan to return null for getLocalRootSpan + def nullRootActiveSpan = Mock(AgentSpan) { + getLocalRootSpan() >> null + } + def nullRootTracer = Mock(AgentTracer.TracerAPI) { + activeSpan() >> nullRootActiveSpan + } + AgentTracer.forceRegister(nullRootTracer) + + when: + AppSecSCADetector.onMethodInvocation(className, methodName, descriptor, advisory, cve) + + then: + notThrown(Exception) + // No span setTag interactions expected + } + + def "onMethodInvocation handles no request context gracefully"() { + given: + String className = "com/example/VulnerableClass" + String methodName = "vulnerableMethod" + String descriptor = "()V" + String advisory = "GHSA-xxxx-yyyy-zzzz" + String cve = "CVE-2024-0001" + + and: + def noReqCtxRootSpan = Mock(AgentSpan) { + getRequestContext() >> null + } + def noReqCtxActiveSpan = Mock(AgentSpan) { + getLocalRootSpan() >> noReqCtxRootSpan + } + def noReqCtxTracer = Mock(AgentTracer.TracerAPI) { + activeSpan() >> noReqCtxActiveSpan + } + AgentTracer.forceRegister(noReqCtxTracer) + + when: + AppSecSCADetector.onMethodInvocation(className, methodName, descriptor, advisory, cve) + + then: + notThrown(Exception) + // Tags should still be set even without request context + 1 * noReqCtxRootSpan.setTag("_dd.appsec.sca.class", "com.example.VulnerableClass") + 1 * noReqCtxRootSpan.setTag("_dd.appsec.sca.method", methodName) + 1 * noReqCtxRootSpan.setTag("_dd.appsec.sca.advisory", advisory) + 1 * noReqCtxRootSpan.setTag("_dd.appsec.sca.cve", cve) + // Stack ID should not be set if there's no request context + 0 * noReqCtxRootSpan.setTag("_dd.appsec.sca.stack_id", _) + } + + def "onMethodInvocation converts internal class name to binary name"() { + given: + String className = "com/example/nested/VulnerableClass" + String methodName = "vulnerableMethod" + String descriptor = "()V" + String advisory = "GHSA-xxxx-yyyy-zzzz" + String cve = "CVE-2024-0001" + + and: + rootSpan.getRequestContext() >> reqCtx + + when: + AppSecSCADetector.onMethodInvocation(className, methodName, descriptor, advisory, cve) + + then: + 1 * rootSpan.setTag("_dd.appsec.sca.class", "com.example.nested.VulnerableClass") + } + + def "onMethodInvocation handles exceptions gracefully"() { + given: + String className = "com/example/VulnerableClass" + String methodName = "vulnerableMethod" + String descriptor = "()V" + String advisory = "GHSA-xxxx-yyyy-zzzz" + String cve = "CVE-2024-0001" + + and: + rootSpan.getRequestContext() >> { throw new RuntimeException("Test exception") } + + when: + AppSecSCADetector.onMethodInvocation(className, methodName, descriptor, advisory, cve) + + then: + notThrown(Exception) + } + + def "onMethodInvocation handles both null advisory and cve"() { + given: + String className = "com/example/VulnerableClass" + String methodName = "vulnerableMethod" + String descriptor = "()V" + String advisory = null + String cve = null + + and: + rootSpan.getRequestContext() >> reqCtx + + when: + AppSecSCADetector.onMethodInvocation(className, methodName, descriptor, advisory, cve) + + then: + 1 * rootSpan.setTag("_dd.appsec.sca.class", "com.example.VulnerableClass") + 1 * rootSpan.setTag("_dd.appsec.sca.method", methodName) + 0 * rootSpan.setTag("_dd.appsec.sca.advisory", _) + 0 * rootSpan.setTag("_dd.appsec.sca.cve", _) + (0..1) * rootSpan.setTag("_dd.appsec.sca.stack_id", _) + } +} diff --git a/dd-java-agent/appsec/build.gradle b/dd-java-agent/appsec/build.gradle index 559ae10eebe..fbddd9c7897 100644 --- a/dd-java-agent/appsec/build.gradle +++ b/dd-java-agent/appsec/build.gradle @@ -17,6 +17,7 @@ dependencies { implementation project(':telemetry') implementation group: 'io.sqreen', name: 'libsqreen', version: '17.2.0' implementation libs.moshi + implementation libs.bundles.asm testImplementation libs.bytebuddy testImplementation project(':remote-config:remote-config-core') @@ -80,6 +81,7 @@ ext { 'com.datadog.appsec.AppSecModule.AppSecModuleActivationException', 'com.datadog.appsec.event.ReplaceableEventProducerService', 'com.datadog.appsec.api.security.ApiSecuritySampler.NoOp', + 'com.datadog.appsec.config.AppSecSCAConfig', ] excludedClassesBranchCoverage = [ 'com.datadog.appsec.gateway.GatewayBridge', diff --git a/dd-java-agent/appsec/src/jmh/java/datadog/appsec/benchmark/AppSecBenchmark.java b/dd-java-agent/appsec/src/jmh/java/datadog/appsec/benchmark/AppSecBenchmark.java index ee1b028672f..204237492e7 100644 --- a/dd-java-agent/appsec/src/jmh/java/datadog/appsec/benchmark/AppSecBenchmark.java +++ b/dd-java-agent/appsec/src/jmh/java/datadog/appsec/benchmark/AppSecBenchmark.java @@ -74,7 +74,8 @@ public void setUp() throws URISyntaxException { sharedCommunicationObjects.setFeaturesDiscovery( new StubDDAgentFeaturesDiscovery(sharedCommunicationObjects.agentHttpClient)); - AppSecSystem.start(ss, sharedCommunicationObjects); + // Pass null for Instrumentation since SCA is not needed for this benchmark + AppSecSystem.start(null, ss, sharedCommunicationObjects); uri = new URIDefaultDataAdapter(new URI("http://localhost:8080/test")); } diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/AppSecSystem.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/AppSecSystem.java index 992e7dddace..073ad720679 100644 --- a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/AppSecSystem.java +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/AppSecSystem.java @@ -46,9 +46,12 @@ public class AppSecSystem { private static final AtomicBoolean API_SECURITY_INITIALIZED = new AtomicBoolean(false); private static volatile ApiSecuritySampler API_SECURITY_SAMPLER = new ApiSecuritySampler.NoOp(); - public static void start(SubscriptionService gw, SharedCommunicationObjects sco) { + public static void start( + java.lang.instrument.Instrumentation inst, + SubscriptionService gw, + SharedCommunicationObjects sco) { try { - doStart(gw, sco); + doStart(inst, gw, sco); } catch (AbortStartupException ase) { throw ase; } catch (RuntimeException | Error e) { @@ -58,7 +61,10 @@ public static void start(SubscriptionService gw, SharedCommunicationObjects sco) } } - private static void doStart(SubscriptionService gw, SharedCommunicationObjects sco) { + private static void doStart( + java.lang.instrument.Instrumentation inst, + SubscriptionService gw, + SharedCommunicationObjects sco) { final Config config = Config.get(); ProductActivation appSecEnabledConfig = config.getAppSecActivation(); if (appSecEnabledConfig == ProductActivation.FULLY_DISABLED) { @@ -97,6 +103,9 @@ private static void doStart(SubscriptionService gw, SharedCommunicationObjects s setActive(appSecEnabledConfig == ProductActivation.FULLY_ENABLED); + // Initialize SCA instrumentation before subscribing to Remote Config + APP_SEC_CONFIG_SERVICE.setInstrumentation(inst); + APP_SEC_CONFIG_SERVICE.maybeSubscribeConfigPolling(); Blocking.setBlockingService(new BlockingServiceImpl(REPLACEABLE_EVENT_PRODUCER)); diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/config/AppSecConfigServiceImpl.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/config/AppSecConfigServiceImpl.java index fae17c9531d..9854e29fd15 100644 --- a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/config/AppSecConfigServiceImpl.java +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/config/AppSecConfigServiceImpl.java @@ -21,6 +21,7 @@ import static datadog.remoteconfig.Capabilities.CAPABILITY_ASM_RASP_SQLI; import static datadog.remoteconfig.Capabilities.CAPABILITY_ASM_RASP_SSRF; import static datadog.remoteconfig.Capabilities.CAPABILITY_ASM_REQUEST_BLOCKING; +import static datadog.remoteconfig.Capabilities.CAPABILITY_ASM_SCA_VULNERABILITY_DETECTION; import static datadog.remoteconfig.Capabilities.CAPABILITY_ASM_SESSION_FINGERPRINT; import static datadog.remoteconfig.Capabilities.CAPABILITY_ASM_TRACE_TAGGING_RULES; import static datadog.remoteconfig.Capabilities.CAPABILITY_ASM_TRUSTED_IPS; @@ -103,6 +104,7 @@ public class AppSecConfigServiceImpl implements AppSecConfigService { private boolean hasUserWafConfig; private boolean defaultConfigActivated; private final AtomicBoolean subscribedToRulesAndData = new AtomicBoolean(); + private final AtomicBoolean subscribedToSCA = new AtomicBoolean(); private final Set usedDDWafConfigKeys = Collections.newSetFromMap(new ConcurrentHashMap<>()); private final Set ignoredConfigKeys = @@ -110,6 +112,8 @@ public class AppSecConfigServiceImpl implements AppSecConfigService { private final String DEFAULT_WAF_CONFIG_RULE = "ASM_DD/default"; private String currentRuleVersion; private List modulesToUpdateVersionIn; + private volatile AppSecSCAConfig currentSCAConfig; + private AppSecSCAInstrumentationUpdater scaInstrumentationUpdater; public AppSecConfigServiceImpl( Config tracerConfig, @@ -124,6 +128,33 @@ public AppSecConfigServiceImpl( } } + /** + * Sets the Instrumentation instance for SCA hot instrumentation. Must be called before {@link + * #maybeSubscribeConfigPolling()} for SCA to work. + * + * @param instrumentation the Java Instrumentation API instance + */ + public void setInstrumentation(java.lang.instrument.Instrumentation instrumentation) { + if (instrumentation == null) { + log.debug("Instrumentation is null, SCA hot instrumentation will not be available"); + return; + } + + if (!instrumentation.isRetransformClassesSupported()) { + log.warn( + "SCA requires retransformation support, but it's not available in this JVM. " + + "SCA vulnerability detection will not work."); + return; + } + + try { + this.scaInstrumentationUpdater = new AppSecSCAInstrumentationUpdater(instrumentation); + log.debug("SCA instrumentation updater initialized successfully"); + } catch (Exception e) { + log.debug("Failed to initialize SCA instrumentation updater", e); + } + } + private void subscribeConfigurationPoller() { // see also close() method subscribeAsmFeatures(); @@ -134,6 +165,8 @@ private void subscribeConfigurationPoller() { log.debug("Will not subscribe to ASM, ASM_DD and ASM_DATA (AppSec custom rules in use)"); } + subscribeSCA(); + this.configurationPoller.addConfigurationEndListener(applyRemoteConfigListener); } @@ -345,6 +378,68 @@ private void subscribeAsmFeatures() { this.configurationPoller.addCapabilities(CAPABILITY_ASM_AUTO_USER_INSTRUM_MODE); } + /** + * Subscribes to SCA configuration from Remote Config. Receives instrumentation targets for + * vulnerability detection in third-party dependencies. + * + *

TODO: Remove debugging URL support once backend properly implements + */ + private void subscribeSCA() { + if (subscribedToSCA.compareAndSet(false, true)) { + log.debug("Subscribing to DEBUG Remote Config product"); + this.configurationPoller.addListener( + Product.DEBUG, + AppSecSCAConfigDeserializer.INSTANCE, + (configKey, newConfig, hinter) -> { + if (newConfig == null) { + log.debug("Received removal for SCA config key: {}", configKey); + currentSCAConfig = null; + triggerSCAInstrumentationUpdate(null); + } else { + log.debug( + "Received SCA config update for key: {} - vulnerabilities: {}", + configKey, + newConfig.vulnerabilities != null ? newConfig.vulnerabilities.size() : 0); + currentSCAConfig = newConfig; + triggerSCAInstrumentationUpdate(newConfig); + } + }); + this.configurationPoller.addCapabilities(CAPABILITY_ASM_SCA_VULNERABILITY_DETECTION); + log.info("Successfully subscribed to SCA Remote Config product"); + } + } + + /** Unsubscribes from SCA Remote Config product and clears current configuration. */ + private void unsubscribeSCA() { + if (subscribedToSCA.compareAndSet(true, false)) { + log.debug("Unsubscribing from DEBUG Remote Config product"); + this.configurationPoller.removeListeners(Product.DEBUG); + this.configurationPoller.removeCapabilities(CAPABILITY_ASM_SCA_VULNERABILITY_DETECTION); + currentSCAConfig = null; + log.info("Successfully unsubscribed from SCA Remote Config product"); + } + } + + /** + * Triggers SCA instrumentation update when configuration changes. + * + * @param newConfig the new SCA configuration, or null to remove instrumentation + */ + private void triggerSCAInstrumentationUpdate(AppSecSCAConfig newConfig) { + if (scaInstrumentationUpdater == null) { + log.debug( + "SCA instrumentation updater not initialized. " + + "Call setInstrumentation() before subscribing to enable SCA."); + return; + } + + try { + scaInstrumentationUpdater.onConfigUpdate(newConfig); + } catch (Exception e) { + log.debug("Error updating SCA instrumentation", e); + } + } + private void distributeSubConfigurations( String key, AppSecModuleConfigurer.Reconfiguration reconfiguration) { maybeInitializeDefaultConfig(); @@ -547,6 +642,7 @@ public void close() { this.configurationPoller.removeListeners(Product.ASM_DATA); this.configurationPoller.removeListeners(Product.ASM); this.configurationPoller.removeListeners(Product.ASM_FEATURES); + unsubscribeSCA(); this.configurationPoller.removeConfigurationEndListener(applyRemoteConfigListener); this.subscribedToRulesAndData.set(false); this.configurationPoller.stop(); diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/config/AppSecSCAConfig.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/config/AppSecSCAConfig.java new file mode 100644 index 00000000000..d14865bc3a6 --- /dev/null +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/config/AppSecSCAConfig.java @@ -0,0 +1,69 @@ +package com.datadog.appsec.config; + +import com.squareup.moshi.Json; +import java.util.List; + +/** + * Configuration model for SCA vulnerability detection. Received via Remote Config in the ASM_SCA + * product. + * + *

This configuration enables dynamic instrumentation of third-party dependencies to detect and + * report known vulnerabilities at runtime. Each vulnerability specifies: + */ +public class AppSecSCAConfig { + + @Json(name = "vulnerabilities") + public List vulnerabilities; + + public static class Vulnerability { + /** GitHub Security Advisory ID (e.g., "GHSA-24rp-q3w6-vc56"). */ + @Json(name = "advisory") + public String advisory; + + /** CVE identifier (e.g., "CVE-2024-1597"). */ + @Json(name = "cve") + public String cve; + + /** + * The vulnerable internal code location to instrument. This is where the actual vulnerability + * exists in the dependency. + */ + @Json(name = "vulnerable_internal_code") + public CodeLocation vulnerableInternalCode; + + /** + * External entrypoint(s) that can trigger the vulnerability. These are the public API methods + * that users call which eventually reach the vulnerable code. + */ + @Json(name = "external_entrypoint") + public ExternalEntrypoint externalEntrypoint; + } + + /** Represents a code location (class + method) to instrument. */ + public static class CodeLocation { + /** + * Fully qualified class name in binary format (e.g., + * "org.postgresql.core.v3.SimpleParameterList"). + */ + @Json(name = "class") + public String className; + + /** Method name (e.g., "toString"). */ + @Json(name = "method") + public String methodName; + } + + /** Represents external entrypoint(s) for a vulnerability. */ + public static class ExternalEntrypoint { + /** + * Fully qualified class name in binary format (e.g., + * "org.postgresql.jdbc.PgPreparedStatement"). + */ + @Json(name = "class") + public String className; + + /** List of method names that serve as entrypoints (e.g., ["executeQuery", "executeUpdate"]). */ + @Json(name = "methods") + public List methods; + } +} diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/config/AppSecSCAConfigDeserializer.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/config/AppSecSCAConfigDeserializer.java new file mode 100644 index 00000000000..1066c373c15 --- /dev/null +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/config/AppSecSCAConfigDeserializer.java @@ -0,0 +1,47 @@ +package com.datadog.appsec.config; + +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.Moshi; +import com.squareup.moshi.Types; +import datadog.remoteconfig.ConfigurationDeserializer; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.lang.reflect.Type; +import java.util.List; +import okio.BufferedSource; +import okio.Okio; + +/** + * Deserializer for SCA configuration from Remote Config. + * + *

Converts JSON payload from Remote Config into typed AppSecSCAConfig objects. The backend sends + * vulnerabilities as a direct JSON array: [{"advisory": "...", "cve": "...", ...}] + */ +public class AppSecSCAConfigDeserializer implements ConfigurationDeserializer { + + public static final AppSecSCAConfigDeserializer INSTANCE = new AppSecSCAConfigDeserializer(); + + private static final Type VULNERABILITY_LIST_TYPE = + Types.newParameterizedType(List.class, AppSecSCAConfig.Vulnerability.class); + private static final JsonAdapter> VULNERABILITY_LIST_ADAPTER = + new Moshi.Builder().build().adapter(VULNERABILITY_LIST_TYPE); + + private AppSecSCAConfigDeserializer() {} + + @Override + public AppSecSCAConfig deserialize(byte[] content) throws IOException { + if (content == null || content.length == 0) { + return null; + } + + // Backend sends vulnerabilities as a JSON array: [...] + BufferedSource source = Okio.buffer(Okio.source(new ByteArrayInputStream(content))); + List vulnerabilities = + VULNERABILITY_LIST_ADAPTER.fromJson(source); + + // Wrap the list in an AppSecSCAConfig object + AppSecSCAConfig config = new AppSecSCAConfig(); + config.vulnerabilities = vulnerabilities; + return config; + } +} diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/config/AppSecSCAInstrumentationUpdater.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/config/AppSecSCAInstrumentationUpdater.java new file mode 100644 index 00000000000..62b8ef2bd95 --- /dev/null +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/config/AppSecSCAInstrumentationUpdater.java @@ -0,0 +1,172 @@ +package com.datadog.appsec.config; + +import java.lang.instrument.Instrumentation; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Handles dynamic instrumentation updates SCA vulnerability detection. + * + *

This class receives SCA configuration updates from Remote Config and triggers retransformation + * of classes that match the instrumentation targets. + */ +public class AppSecSCAInstrumentationUpdater { + + private static final Logger log = LoggerFactory.getLogger(AppSecSCAInstrumentationUpdater.class); + + private final Instrumentation instrumentation; + + private volatile AppSecSCAConfig currentConfig; + private AppSecSCATransformer currentTransformer; + + public AppSecSCAInstrumentationUpdater(Instrumentation instrumentation) { + if (instrumentation == null) { + throw new IllegalArgumentException("Instrumentation cannot be null"); + } + if (!instrumentation.isRetransformClassesSupported()) { + throw new IllegalStateException( + "SCA requires retransformation support, but it's not available in this JVM"); + } + this.instrumentation = instrumentation; + } + + /** + * Called when SCA configuration is updated via Remote Config. + * + *

Updates the current config reference that the persistent transformer reads via supplier. The + * transformer remains installed and will automatically instrument any new classes that load. + * + * @param newConfig the new SCA configuration, or null if config was removed + */ + public synchronized void onConfigUpdate(AppSecSCAConfig newConfig) { + AppSecSCAConfig oldConfig = currentConfig; + currentConfig = newConfig; + + if (newConfig == null) { + log.debug("SCA config removed, instrumentation will remain until JVM restart"); + return; + } + + if (newConfig.vulnerabilities == null || newConfig.vulnerabilities.isEmpty()) { + log.debug("SCA config has no vulnerabilities, instrumentation will remain until JVM restart"); + return; + } + + log.debug( + "Applying SCA instrumentation for {} vulnerabilities", newConfig.vulnerabilities.size()); + + applyInstrumentation(oldConfig, newConfig); + } + + private void applyInstrumentation(AppSecSCAConfig oldConfig, AppSecSCAConfig newConfig) { + // Install transformer on first config if not already installed + if (currentTransformer == null) { + log.debug("Installing SCA transformer (will use config dynamically)"); + // Transformer uses supplier to get current config - no need to reinstall on updates + currentTransformer = new AppSecSCATransformer(() -> currentConfig); + instrumentation.addTransformer(currentTransformer, true); + } + + // Determine which classes need to be retransformed (only NEW targets) + Set newTargetClassNames = extractTargetClassNames(newConfig); + Set oldTargetClassNames = + oldConfig != null ? extractTargetClassNames(oldConfig) : new HashSet<>(); + + // Only retransform classes for NEW targets (additive-only approach) + Set classesToRetransform = new HashSet<>(newTargetClassNames); + classesToRetransform.removeAll(oldTargetClassNames); // Remove already instrumented targets + + if (classesToRetransform.isEmpty()) { + log.debug("No new target classes to retransform"); + return; + } + + // Find loaded classes that match NEW targets + List> loadedClassesToRetransform = findLoadedClasses(classesToRetransform); + + if (loadedClassesToRetransform.isEmpty()) { + log.debug( + "No loaded classes match new SCA targets yet ({} new targets, they may load later)", + classesToRetransform.size()); + return; + } + + // Trigger retransformation for already loaded classes with NEW targets + log.info( + "Retransforming {} loaded classes for {} new SCA targets", + loadedClassesToRetransform.size(), + classesToRetransform.size()); + retransformClasses(loadedClassesToRetransform); + } + + private Set extractTargetClassNames(AppSecSCAConfig config) { + Set classNames = new HashSet<>(); + + if (config == null || config.vulnerabilities == null) { + return classNames; + } + + for (AppSecSCAConfig.Vulnerability vulnerability : config.vulnerabilities) { + // Extract external entrypoint class we decide to instrument only the external entrypoint + if (vulnerability.externalEntrypoint != null + && vulnerability.externalEntrypoint.className != null + && !vulnerability.externalEntrypoint.className.isEmpty()) { + // className is already in binary format (org.foo.Bar), no conversion needed + classNames.add(vulnerability.externalEntrypoint.className); + } + } + + return classNames; + } + + private List> findLoadedClasses(Set targetClassNames) { + List> matchedClasses = new ArrayList<>(); + + Class[] loadedClasses = instrumentation.getAllLoadedClasses(); + log.debug("Scanning {} loaded classes for SCA targets", loadedClasses.length); + + for (Class clazz : loadedClasses) { + if (targetClassNames.contains(clazz.getName())) { + if (!instrumentation.isModifiableClass(clazz)) { + log.debug("Class {} matches target but is not modifiable", clazz.getName()); + continue; + } + matchedClasses.add(clazz); + log.debug("Found loaded class matching SCA target: {}", clazz.getName()); + } + } + + return matchedClasses; + } + + private void retransformClasses(List> classes) { + for (Class clazz : classes) { + try { + log.debug("Retransforming class: {}", clazz.getName()); + instrumentation.retransformClasses(clazz); + } catch (Exception e) { + log.error("Failed to retransform class: {}", clazz.getName(), e); + } catch (Throwable t) { + log.error("Throwable during retransformation of class: {}", clazz.getName(), t); + } + } + } + + /** + * Gets the current SCA configuration. + * + * @return the current config, or null if none is active + */ + public AppSecSCAConfig getCurrentConfig() { + return currentConfig; + } + + /** For testing: checks if a transformer is currently installed. */ + boolean hasTransformer() { + return currentTransformer != null; + } +} diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/config/AppSecSCATransformer.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/config/AppSecSCATransformer.java new file mode 100644 index 00000000000..9085a7ef992 --- /dev/null +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/config/AppSecSCATransformer.java @@ -0,0 +1,250 @@ +package com.datadog.appsec.config; + +import java.lang.instrument.ClassFileTransformer; +import java.lang.instrument.IllegalClassFormatException; +import java.security.ProtectionDomain; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Supplier; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * ClassFileTransformer for SCA vulnerability detection. + * + *

Instruments methods specified in the SCA configuration to detect when vulnerable third-party + * library methods are called at runtime. + * + *

This transformer uses a Supplier to access the current configuration, allowing it to + * automatically use updated configurations without needing to be reinstalled. + * + *

This is a POC implementation that logs method invocations. Future versions will report to the + * Datadog backend with vulnerability details. + */ +public class AppSecSCATransformer implements ClassFileTransformer { + + private static final Logger log = LoggerFactory.getLogger(AppSecSCATransformer.class); + + private final Supplier configSupplier; + + /** + * Creates a new SCA transformer that reads configuration dynamically. + * + * @param configSupplier supplier that provides the current SCA configuration + */ + public AppSecSCATransformer(Supplier configSupplier) { + this.configSupplier = configSupplier; + log.debug("Created SCA transformer with dynamic config supplier"); + } + + @Override + public byte[] transform( + ClassLoader loader, + String className, + Class classBeingRedefined, + ProtectionDomain protectionDomain, + byte[] classfileBuffer) + throws IllegalClassFormatException { + + if (className == null) { + return null; + } + + // Get current configuration dynamically + AppSecSCAConfig config = configSupplier.get(); + if (config == null || config.vulnerabilities == null || config.vulnerabilities.isEmpty()) { + return null; // No configuration or no vulnerabilities + } + + // Check if this class is a target in the current config + TargetMethods targetMethods = findTargetMethodsForClass(config, className); + if (targetMethods == null) { + return null; // Not a target class + } + + try { + log.debug("Instrumenting SCA target class: {}", className); + return instrumentClass(classfileBuffer, className, targetMethods); + } catch (Exception e) { + log.debug("Failed to instrument SCA target class: {}", className, e); + return null; // Return null to keep original bytecode + } + } + + /** + * Finds target methods for a specific class in the current configuration. + * + * @param config the current SCA configuration + * @param className the internal class name (e.g., "org/foo/Bar") + * @return TargetMethods if this class is a target, null otherwise + */ + private TargetMethods findTargetMethodsForClass(AppSecSCAConfig config, String className) { + // Convert internal format (org/foo/Bar) to binary format (org.foo.Bar) + String binaryClassName = className.replace('/', '.'); + + TargetMethods targetMethods = null; + + for (AppSecSCAConfig.Vulnerability vulnerability : config.vulnerabilities) { + // Check if this class is an external entrypoint + if (vulnerability.externalEntrypoint != null + && vulnerability.externalEntrypoint.className != null + && vulnerability.externalEntrypoint.className.equals(binaryClassName) + && vulnerability.externalEntrypoint.methods != null + && !vulnerability.externalEntrypoint.methods.isEmpty()) { + + if (targetMethods == null) { + targetMethods = new TargetMethods(vulnerability); + } + + // Add all methods from the external entrypoint (it's a list) + for (String methodName : vulnerability.externalEntrypoint.methods) { + if (methodName != null && !methodName.isEmpty()) { + targetMethods.addMethod(methodName); + } + } + } + } + + return targetMethods; + } + + private byte[] instrumentClass( + byte[] originalBytecode, String className, TargetMethods targetMethods) { + ClassReader reader = new ClassReader(originalBytecode); + ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_FRAMES); + + ClassVisitor visitor = new SCAClassVisitor(writer, className, targetMethods); + + try { + reader.accept(visitor, ClassReader.EXPAND_FRAMES); + byte[] transformedBytecode = writer.toByteArray(); + log.debug("Successfully instrumented SCA target class: {}", className); + return transformedBytecode; + } catch (Exception e) { + log.debug("Error during ASM transformation for class: {}", className, e); + return null; + } + } + + /** ASM ClassVisitor that instruments methods matching SCA targets. */ + private static class SCAClassVisitor extends ClassVisitor { + private final String className; + private final TargetMethods targetMethods; + + SCAClassVisitor(ClassVisitor cv, String className, TargetMethods targetMethods) { + super(Opcodes.ASM9, cv); + this.className = className; + this.targetMethods = targetMethods; + } + + @Override + public MethodVisitor visitMethod( + int access, String name, String descriptor, String signature, String[] exceptions) { + MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions); + + // Check if this method is a target + if (targetMethods.contains(name)) { + log.debug("Instrumenting SCA target method: {}::{}", className, name); + AppSecSCAConfig.Vulnerability vulnerability = targetMethods.getVulnerability(); + return new SCAMethodVisitor(mv, className, name, descriptor, vulnerability); + } + + return mv; + } + } + + /** ASM MethodVisitor that injects SCA detection logic at method entry. */ + private static class SCAMethodVisitor extends MethodVisitor { + private final String className; + private final String methodName; + private final String descriptor; + private final AppSecSCAConfig.Vulnerability vulnerability; + + SCAMethodVisitor( + MethodVisitor mv, + String className, + String methodName, + String descriptor, + AppSecSCAConfig.Vulnerability vulnerability) { + super(Opcodes.ASM9, mv); + this.className = className; + this.methodName = methodName; + this.descriptor = descriptor; + this.vulnerability = vulnerability; + } + + @Override + public void visitCode() { + // Inject logging call at method entry + // This is POC code - in production this would call a detection handler + injectSCADetectionCall(); + super.visitCode(); + } + + private void injectSCADetectionCall() { + // Generate bytecode equivalent to: + // AppSecSCADetector.onMethodInvocation("className", "methodName", "descriptor", "advisory", + // "cve"); + + // Load the class name + mv.visitLdcInsn(className); + + // Load the method name + mv.visitLdcInsn(methodName); + + // Load the descriptor + mv.visitLdcInsn(descriptor); + + // Load the advisory (GHSA ID) + String advisory = vulnerability != null ? vulnerability.advisory : null; + if (advisory != null) { + mv.visitLdcInsn(advisory); + } else { + mv.visitInsn(Opcodes.ACONST_NULL); + } + + // Load the CVE ID + String cve = vulnerability != null ? vulnerability.cve : null; + if (cve != null) { + mv.visitLdcInsn(cve); + } else { + mv.visitInsn(Opcodes.ACONST_NULL); + } + + // Call the static detection method in bootstrap classloader + mv.visitMethodInsn( + Opcodes.INVOKESTATIC, + "datadog/trace/bootstrap/instrumentation/appsec/AppSecSCADetector", + "onMethodInvocation", + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V", + false); + } + } + + /** Helper class to store target methods and vulnerability metadata for a class. */ + private static class TargetMethods { + private final AppSecSCAConfig.Vulnerability vulnerability; + private final Map methods = new HashMap<>(); + + TargetMethods(AppSecSCAConfig.Vulnerability vulnerability) { + this.vulnerability = vulnerability; + } + + void addMethod(String methodName) { + methods.put(methodName, Boolean.TRUE); + } + + boolean contains(String methodName) { + return methods.containsKey(methodName); + } + + AppSecSCAConfig.Vulnerability getVulnerability() { + return vulnerability; + } + } +} diff --git a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/AppSecSystemSpecification.groovy b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/AppSecSystemSpecification.groovy index 96d70b90329..99653ed26ab 100644 --- a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/AppSecSystemSpecification.groovy +++ b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/AppSecSystemSpecification.groovy @@ -33,6 +33,10 @@ import static datadog.trace.api.gateway.Events.EVENTS class AppSecSystemSpecification extends DDSpecification { SubscriptionService subService = Mock() ConfigurationPoller poller = Mock() + java.lang.instrument.Instrumentation inst = Mock { + isRetransformClassesSupported() >> true + getAllLoadedClasses() >> ([] as Class[]) + } def cleanup() { AppSecSystem.stop() @@ -40,7 +44,7 @@ class AppSecSystemSpecification extends DDSpecification { void 'registers powerwaf module'() { when: - AppSecSystem.start(subService, sharedCommunicationObjects()) + AppSecSystem.start(inst, subService, sharedCommunicationObjects()) then: 'ddwaf' in AppSecSystem.startedModulesInfo @@ -51,7 +55,7 @@ class AppSecSystemSpecification extends DDSpecification { injectSysConfig('dd.appsec.rules', '/file/that/does/not/exist') when: - AppSecSystem.start(subService, sharedCommunicationObjects()) + AppSecSystem.start(inst, subService, sharedCommunicationObjects()) then: def exception = thrown(AbortStartupException) @@ -66,7 +70,7 @@ class AppSecSystemSpecification extends DDSpecification { rebuildConfig() when: 'starting the AppSec system' - AppSecSystem.start(subService, sharedCommunicationObjects()) + AppSecSystem.start(inst, subService, sharedCommunicationObjects()) then: 'an AbortStartupException should be thrown' def exception = thrown(AbortStartupException) @@ -88,7 +92,7 @@ class AppSecSystemSpecification extends DDSpecification { injectSysConfig('dd.appsec.ipheader', 'foo-bar') when: - AppSecSystem.start(subService, sharedCommunicationObjects()) + AppSecSystem.start(inst, subService, sharedCommunicationObjects()) requestEndedCB.apply(requestContext, span) then: @@ -110,7 +114,7 @@ class AppSecSystemSpecification extends DDSpecification { rebuildConfig() when: - AppSecSystem.start(subService, sharedCommunicationObjects()) + AppSecSystem.start(inst, subService, sharedCommunicationObjects()) then: thrown AbortStartupException @@ -126,7 +130,7 @@ class AppSecSystemSpecification extends DDSpecification { ConfigurationEndListener savedConfEndListener when: - AppSecSystem.start(subService, sharedCommunicationObjects()) + AppSecSystem.start(inst, subService, sharedCommunicationObjects()) EventProducerService initialEPS = AppSecSystem.REPLACEABLE_EVENT_PRODUCER.cur then: diff --git a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/config/AppSecConfigServiceImplSpecification.groovy b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/config/AppSecConfigServiceImplSpecification.groovy index e42b82a4b7d..0e986b63698 100644 --- a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/config/AppSecConfigServiceImplSpecification.groovy +++ b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/config/AppSecConfigServiceImplSpecification.groovy @@ -99,6 +99,7 @@ class AppSecConfigServiceImplSpecification extends DDSpecification { 1 * poller.addListener(Product.ASM_FEATURES, _, _) 1 * poller.addListener(Product.ASM, _) 1 * poller.addListener(Product.ASM_DATA, _) + 1 * poller.addListener(Product.DEBUG, _, _) 1 * poller.addConfigurationEndListener(_) 0 * poller.addListener(*_) 0 * poller.addCapabilities(CAPABILITY_ASM_ACTIVATION) @@ -135,6 +136,7 @@ class AppSecConfigServiceImplSpecification extends DDSpecification { then: 2 * config.getAppSecActivation() >> ProductActivation.ENABLED_INACTIVE 1 * poller.addListener(Product.ASM_FEATURES, _, _) + 1 * poller.addListener(Product.DEBUG, _, _) 1 * poller.addConfigurationEndListener(_) 0 * poller.addListener(*_) } @@ -211,11 +213,13 @@ class AppSecConfigServiceImplSpecification extends DDSpecification { listeners.savedFeaturesDeserializer = it[1] listeners.savedFeaturesListener = it[2] } + 1 * poller.addListener(Product.DEBUG, _, _) 1 * poller.addConfigurationEndListener(_) >> { listeners.savedConfEndListener = it[0] } 1 * poller.addCapabilities(CAPABILITY_ASM_ACTIVATION) 1 * poller.addCapabilities(CAPABILITY_ASM_AUTO_USER_INSTRUM_MODE) + 1 * poller.addCapabilities({ it & datadog.remoteconfig.Capabilities.CAPABILITY_ASM_SCA_VULNERABILITY_DETECTION }) 0 * poller._ when: @@ -252,11 +256,13 @@ class AppSecConfigServiceImplSpecification extends DDSpecification { listeners.savedFeaturesDeserializer = it[1] listeners.savedFeaturesListener = it[2] } + 1 * poller.addListener(Product.DEBUG, _, _) 1 * poller.addConfigurationEndListener(_) >> { listeners.savedConfEndListener = it[0] } 1 * poller.addCapabilities(CAPABILITY_ASM_ACTIVATION) 1 * poller.addCapabilities(CAPABILITY_ASM_AUTO_USER_INSTRUM_MODE) + 1 * poller.addCapabilities({ it & datadog.remoteconfig.Capabilities.CAPABILITY_ASM_SCA_VULNERABILITY_DETECTION }) 0 * poller._ when: @@ -416,11 +422,13 @@ class AppSecConfigServiceImplSpecification extends DDSpecification { listeners.savedFeaturesDeserializer = it[1] listeners.savedFeaturesListener = it[2] } + 1 * poller.addListener(Product.DEBUG, _, _) 1 * poller.addConfigurationEndListener(_) >> { listeners.savedConfEndListener = it[0] } 1 * poller.addCapabilities(CAPABILITY_ASM_ACTIVATION) 1 * poller.addCapabilities(CAPABILITY_ASM_AUTO_USER_INSTRUM_MODE) + 1 * poller.addCapabilities({ it & datadog.remoteconfig.Capabilities.CAPABILITY_ASM_SCA_VULNERABILITY_DETECTION }) 0 * poller._ when: @@ -553,6 +561,8 @@ class AppSecConfigServiceImplSpecification extends DDSpecification { | CAPABILITY_ASM_HEADER_FINGERPRINT | CAPABILITY_ASM_TRACE_TAGGING_RULES | CAPABILITY_ASM_EXTENDED_DATA_COLLECTION) + 1 * poller.removeListeners(Product.DEBUG) + 1 * poller.removeCapabilities({ it & datadog.remoteconfig.Capabilities.CAPABILITY_ASM_SCA_VULNERABILITY_DETECTION }) 4 * poller.removeListeners(_) 1 * poller.removeConfigurationEndListener(_) 1 * poller.stop() @@ -776,6 +786,74 @@ class AppSecConfigServiceImplSpecification extends DDSpecification { noExceptionThrown() } + void 'subscribes to ASM_SCA product when configuration poller is active'() { + setup: + appSecConfigService.init() + AppSecSystem.active = false + config.getAppSecActivation() >> ProductActivation.ENABLED_INACTIVE + + when: + appSecConfigService.maybeSubscribeConfigPolling() + + then: + 1 * poller.addListener(Product.DEBUG, AppSecSCAConfigDeserializer.INSTANCE, _) + 1 * poller.addCapabilities({ it & datadog.remoteconfig.Capabilities.CAPABILITY_ASM_SCA_VULNERABILITY_DETECTION }) + } + + void 'unsubscribes from ASM_SCA product on close'() { + setup: + appSecConfigService.init() + AppSecSystem.active = false + config.getAppSecActivation() >> ProductActivation.ENABLED_INACTIVE + appSecConfigService.maybeSubscribeConfigPolling() + + when: + appSecConfigService.close() + + then: + 1 * poller.removeListeners(Product.DEBUG) + 1 * poller.removeCapabilities({ it & datadog.remoteconfig.Capabilities.CAPABILITY_ASM_SCA_VULNERABILITY_DETECTION }) + } + + void 'SCA listener is registered with correct deserializer and capability'() { + given: + appSecConfigService.init() + AppSecSystem.active = false + config.getAppSecActivation() >> ProductActivation.ENABLED_INACTIVE + + when: + appSecConfigService.maybeSubscribeConfigPolling() + + then: + // Verify SCA listener is registered with the correct deserializer + 1 * poller.addListener(Product.DEBUG, AppSecSCAConfigDeserializer.INSTANCE, _) + // Verify SCA capability is advertised + 1 * poller.addCapabilities({ it & datadog.remoteconfig.Capabilities.CAPABILITY_ASM_SCA_VULNERABILITY_DETECTION }) + } + + void 'SCA deserializer handles array format correctly'() { + when: 'deserialize array format from backend' + def arrayFormatJson = ''' + [ + { + "advisory": "GHSA-xxxx-yyyy-zzzz", + "cve": "CVE-2024-0001", + "external_entrypoint": { + "class": "com.example.VulnerableClass", + "methods": ["vulnerableMethod"] + } + } + ] + ''' + def config = AppSecSCAConfigDeserializer.INSTANCE.deserialize(arrayFormatJson.bytes) + + then: + config != null + config.vulnerabilities.size() == 1 + config.vulnerabilities[0].advisory == "GHSA-xxxx-yyyy-zzzz" + config.vulnerabilities[0].cve == "CVE-2024-0001" + } + private static AppSecFeatures autoUserInstrum(String mode) { return new AppSecFeatures().tap { features -> diff --git a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/config/AppSecSCAConfigDeserializerTest.groovy b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/config/AppSecSCAConfigDeserializerTest.groovy new file mode 100644 index 00000000000..4a9ae7d78e9 --- /dev/null +++ b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/config/AppSecSCAConfigDeserializerTest.groovy @@ -0,0 +1,166 @@ +package com.datadog.appsec.config + +import spock.lang.Specification + +class AppSecSCAConfigDeserializerTest extends Specification { + + def "deserializes valid JSON array from backend"() { + given: + def json = ''' + [ + { + "advisory": "GHSA-24rp-q3w6-vc56", + "cve": "CVE-2024-1597", + "vulnerable_internal_code": { + "class": "org.postgresql.core.v3.SimpleParameterList", + "method": "toString" + }, + "external_entrypoint": { + "class": "org.postgresql.jdbc.PgPreparedStatement", + "methods": ["executeQuery", "executeUpdate", "execute"] + } + } + ] + ''' + def bytes = json.bytes + + when: + def config = AppSecSCAConfigDeserializer.INSTANCE.deserialize(bytes) + + then: + config != null + config.vulnerabilities.size() == 1 + config.vulnerabilities[0].advisory == "GHSA-24rp-q3w6-vc56" + config.vulnerabilities[0].cve == "CVE-2024-1597" + config.vulnerabilities[0].vulnerableInternalCode.className == "org.postgresql.core.v3.SimpleParameterList" + config.vulnerabilities[0].vulnerableInternalCode.methodName == "toString" + config.vulnerabilities[0].externalEntrypoint.className == "org.postgresql.jdbc.PgPreparedStatement" + config.vulnerabilities[0].externalEntrypoint.methods == ["executeQuery", "executeUpdate", "execute"] + } + + def "returns null for null content"() { + when: + def config = AppSecSCAConfigDeserializer.INSTANCE.deserialize(null) + + then: + config == null + } + + def "returns null for empty byte array"() { + when: + def config = AppSecSCAConfigDeserializer.INSTANCE.deserialize(new byte[0]) + + then: + config == null + } + + def "deserializes empty array"() { + given: + def json = '[]' + def bytes = json.bytes + + when: + def config = AppSecSCAConfigDeserializer.INSTANCE.deserialize(bytes) + + then: + config != null + config.vulnerabilities != null + config.vulnerabilities.isEmpty() + } + + def "handles multiple vulnerabilities"() { + given: + def json = ''' + [ + { + "advisory": "GHSA-1111-2222-3333", + "cve": "CVE-2024-0001", + "vulnerable_internal_code": { + "class": "com.example.Class1", + "method": "method1" + } + }, + { + "advisory": "GHSA-4444-5555-6666", + "cve": "CVE-2024-0002", + "vulnerable_internal_code": { + "class": "com.example.Class2", + "method": "method2" + } + }, + { + "advisory": "GHSA-7777-8888-9999", + "cve": "CVE-2024-0003", + "vulnerable_internal_code": { + "class": "com.example.Class3", + "method": "method3" + } + } + ] + ''' + def bytes = json.bytes + + when: + def config = AppSecSCAConfigDeserializer.INSTANCE.deserialize(bytes) + + then: + config != null + config.vulnerabilities.size() == 3 + + config.vulnerabilities[0].advisory == "GHSA-1111-2222-3333" + config.vulnerabilities[0].cve == "CVE-2024-0001" + config.vulnerabilities[0].vulnerableInternalCode.className == "com.example.Class1" + config.vulnerabilities[0].vulnerableInternalCode.methodName == "method1" + + config.vulnerabilities[1].advisory == "GHSA-4444-5555-6666" + config.vulnerabilities[1].cve == "CVE-2024-0002" + config.vulnerabilities[1].vulnerableInternalCode.className == "com.example.Class2" + config.vulnerabilities[1].vulnerableInternalCode.methodName == "method2" + + config.vulnerabilities[2].advisory == "GHSA-7777-8888-9999" + config.vulnerabilities[2].cve == "CVE-2024-0003" + config.vulnerabilities[2].vulnerableInternalCode.className == "com.example.Class3" + config.vulnerabilities[2].vulnerableInternalCode.methodName == "method3" + } + + def "INSTANCE is a singleton"() { + expect: + AppSecSCAConfigDeserializer.INSTANCE === AppSecSCAConfigDeserializer.INSTANCE + } + + def "deserializes complete vulnerability with all fields"() { + given: + def json = ''' + [ + { + "advisory": "GHSA-77xx-rxvh-q682", + "cve": "CVE-2022-41853", + "vulnerable_internal_code": { + "class": "org.hsqldb.Routine", + "method": "getMethods" + }, + "external_entrypoint": { + "class": "org.hsqldb.jdbc.JDBCStatement", + "methods": ["execute", "executeQuery", "executeUpdate"] + }, + "description": "HSQLDB RCE vulnerability" + } + ] + ''' + def bytes = json.bytes + + when: + def config = AppSecSCAConfigDeserializer.INSTANCE.deserialize(bytes) + + then: + config != null + config.vulnerabilities != null + config.vulnerabilities.size() == 1 + config.vulnerabilities[0].advisory == "GHSA-77xx-rxvh-q682" + config.vulnerabilities[0].cve == "CVE-2022-41853" + config.vulnerabilities[0].vulnerableInternalCode.className == "org.hsqldb.Routine" + config.vulnerabilities[0].vulnerableInternalCode.methodName == "getMethods" + config.vulnerabilities[0].externalEntrypoint.className == "org.hsqldb.jdbc.JDBCStatement" + config.vulnerabilities[0].externalEntrypoint.methods == ["execute", "executeQuery", "executeUpdate"] + } +} diff --git a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/config/AppSecSCAConfigTest.groovy b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/config/AppSecSCAConfigTest.groovy new file mode 100644 index 00000000000..c6f2c3be927 --- /dev/null +++ b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/config/AppSecSCAConfigTest.groovy @@ -0,0 +1,154 @@ +package com.datadog.appsec.config + +import com.squareup.moshi.Moshi +import spock.lang.Specification + +class AppSecSCAConfigTest extends Specification { + + def "deserializes valid SCA config with vulnerabilities"() { + given: + def json = ''' + { + "vulnerabilities": [ + { + "advisory": "GHSA-xxxx-yyyy-zzzz", + "cve": "CVE-2024-0001", + "vulnerable_internal_code": { + "class": "org.springframework.web.client.RestTemplate", + "method": "execute" + }, + "external_entrypoint": { + "class": "org.springframework.web.RestTemplate", + "methods": ["getForObject", "postForObject"] + } + }, + { + "advisory": "GHSA-aaaa-bbbb-cccc", + "cve": "CVE-2024-0002", + "vulnerable_internal_code": { + "class": "com.fasterxml.jackson.databind.ObjectMapper", + "method": "readValue" + }, + "external_entrypoint": { + "class": "com.fasterxml.jackson.databind.ObjectMapper", + "methods": ["readValue"] + } + } + ] + } + ''' + + when: + def adapter = new Moshi.Builder().build().adapter(AppSecSCAConfig) + def config = adapter.fromJson(json) + + then: + config != null + config.vulnerabilities != null + config.vulnerabilities.size() == 2 + + config.vulnerabilities[0].advisory == "GHSA-xxxx-yyyy-zzzz" + config.vulnerabilities[0].cve == "CVE-2024-0001" + config.vulnerabilities[0].vulnerableInternalCode.className == "org.springframework.web.client.RestTemplate" + config.vulnerabilities[0].vulnerableInternalCode.methodName == "execute" + config.vulnerabilities[0].externalEntrypoint.className == "org.springframework.web.RestTemplate" + config.vulnerabilities[0].externalEntrypoint.methods == ["getForObject", "postForObject"] + + config.vulnerabilities[1].advisory == "GHSA-aaaa-bbbb-cccc" + config.vulnerabilities[1].cve == "CVE-2024-0002" + config.vulnerabilities[1].vulnerableInternalCode.className == "com.fasterxml.jackson.databind.ObjectMapper" + config.vulnerabilities[1].vulnerableInternalCode.methodName == "readValue" + } + + def "deserializes SCA config with empty vulnerabilities"() { + given: + def json = ''' + { + "vulnerabilities": [] + } + ''' + + when: + def adapter = new Moshi.Builder().build().adapter(AppSecSCAConfig) + def config = adapter.fromJson(json) + + then: + config != null + config.vulnerabilities != null + config.vulnerabilities.isEmpty() + } + + def "deserializes minimal SCA config"() { + given: + def json = ''' + { + "vulnerabilities": [ + { + "advisory": "GHSA-1234-5678-90ab", + "cve": "CVE-2024-9999", + "vulnerable_internal_code": { + "class": "com.example.Vulnerable", + "method": "badMethod" + } + } + ] + } + ''' + + when: + def adapter = new Moshi.Builder().build().adapter(AppSecSCAConfig) + def config = adapter.fromJson(json) + + then: + config != null + config.vulnerabilities != null + config.vulnerabilities.size() == 1 + config.vulnerabilities[0].advisory == "GHSA-1234-5678-90ab" + config.vulnerabilities[0].cve == "CVE-2024-9999" + } + + def "handles empty JSON object"() { + given: + def json = '{}' + + when: + def adapter = new Moshi.Builder().build().adapter(AppSecSCAConfig) + def config = adapter.fromJson(json) + + then: + config != null + config.vulnerabilities == null + } + + def "deserializes Vulnerability correctly"() { + given: + def json = ''' + { + "advisory": "GHSA-test-1234-abcd", + "cve": "CVE-2024-1597", + "vulnerable_internal_code": { + "class": "org.postgresql.core.v3.SimpleParameterList", + "method": "toString" + }, + "external_entrypoint": { + "class": "org.postgresql.jdbc.PgPreparedStatement", + "methods": ["executeQuery", "executeUpdate", "execute"] + } + } + ''' + + when: + def adapter = new Moshi.Builder().build().adapter(AppSecSCAConfig.Vulnerability) + def vulnerability = adapter.fromJson(json) + + then: + vulnerability != null + vulnerability.advisory == "GHSA-test-1234-abcd" + vulnerability.cve == "CVE-2024-1597" + vulnerability.vulnerableInternalCode.className == "org.postgresql.core.v3.SimpleParameterList" + vulnerability.vulnerableInternalCode.methodName == "toString" + vulnerability.externalEntrypoint.className == "org.postgresql.jdbc.PgPreparedStatement" + vulnerability.externalEntrypoint.methods.size() == 3 + vulnerability.externalEntrypoint.methods == ["executeQuery", "executeUpdate", "execute"] + } +} \ No newline at end of file diff --git a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/config/AppSecSCAInstrumentationUpdaterTest.groovy b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/config/AppSecSCAInstrumentationUpdaterTest.groovy new file mode 100644 index 00000000000..c1f7868dc95 --- /dev/null +++ b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/config/AppSecSCAInstrumentationUpdaterTest.groovy @@ -0,0 +1,311 @@ +package com.datadog.appsec.config + +import datadog.trace.test.util.DDSpecification + +import java.lang.instrument.Instrumentation + +class AppSecSCAInstrumentationUpdaterTest extends DDSpecification { + + Instrumentation instrumentation + + void setup() { + instrumentation = Mock(Instrumentation) { + isRetransformClassesSupported() >> true + } + } + + def "constructor throws exception when instrumentation is null"() { + when: + new AppSecSCAInstrumentationUpdater(null) + + then: + thrown(IllegalArgumentException) + } + + def "constructor throws exception when retransformation is not supported"() { + given: + def unsupportedInstrumentation = Mock(Instrumentation) { + isRetransformClassesSupported() >> false + } + + when: + new AppSecSCAInstrumentationUpdater(unsupportedInstrumentation) + + then: + thrown(IllegalStateException) + } + + def "constructor succeeds with valid instrumentation"() { + when: + def updater = new AppSecSCAInstrumentationUpdater(instrumentation) + + then: + updater != null + updater.getCurrentConfig() == null + !updater.hasTransformer() + } + + def "onConfigUpdate with null config does not install transformer"() { + given: + def updater = new AppSecSCAInstrumentationUpdater(instrumentation) + + when: + updater.onConfigUpdate(null) + + then: + 0 * instrumentation.addTransformer(_, _) + 0 * instrumentation.retransformClasses(_) + updater.getCurrentConfig() == null + !updater.hasTransformer() + } + + def "onConfigUpdate with empty vulnerabilities does not install transformer"() { + given: + def updater = new AppSecSCAInstrumentationUpdater(instrumentation) + def config = new AppSecSCAConfig(vulnerabilities: []) + + when: + updater.onConfigUpdate(config) + + then: + 0 * instrumentation.addTransformer(_, _) + 0 * instrumentation.retransformClasses(_) + updater.getCurrentConfig() == config + !updater.hasTransformer() + } + + def "onConfigUpdate with valid config installs transformer"() { + given: + def updater = new AppSecSCAInstrumentationUpdater(instrumentation) + def config = createConfigWithOneVulnerability("com.example.VulnerableClass") + instrumentation.getAllLoadedClasses() >> [] + + when: + updater.onConfigUpdate(config) + + then: + 1 * instrumentation.addTransformer(_, true) + updater.getCurrentConfig() == config + updater.hasTransformer() + } + + def "onConfigUpdate retransforms loaded classes matching targets"() { + given: + def updater = new AppSecSCAInstrumentationUpdater(instrumentation) + // Use String as a real class for testing + def targetClassName = "java.lang.String" + def config = createConfigWithOneVulnerability(targetClassName) + + instrumentation.getAllLoadedClasses() >> [String] + instrumentation.isModifiableClass(String) >> true + + when: + updater.onConfigUpdate(config) + + then: + 1 * instrumentation.addTransformer(_, true) + 1 * instrumentation.retransformClasses(String) + } + + def "onConfigUpdate does not retransform non-modifiable classes"() { + given: + def updater = new AppSecSCAInstrumentationUpdater(instrumentation) + def targetClassName = "java.lang.String" + def config = createConfigWithOneVulnerability(targetClassName) + + instrumentation.getAllLoadedClasses() >> [String] + instrumentation.isModifiableClass(String) >> false + + when: + updater.onConfigUpdate(config) + + then: + 1 * instrumentation.addTransformer(_, true) + 0 * instrumentation.retransformClasses(_) + } + + def "onConfigUpdate does not retransform classes that don't match targets"() { + given: + def updater = new AppSecSCAInstrumentationUpdater(instrumentation) + def config = createConfigWithOneVulnerability("java.lang.String") + + // Use Integer which does NOT match the target (String) + instrumentation.getAllLoadedClasses() >> [Integer] + + when: + updater.onConfigUpdate(config) + + then: + 1 * instrumentation.addTransformer(_, true) + 0 * instrumentation.retransformClasses(_) + } + + def "onConfigUpdate only retransforms NEW targets (additive-only approach)"() { + given: + def updater = new AppSecSCAInstrumentationUpdater(instrumentation) + def class1 = "java.lang.String" + def class2 = "java.lang.Integer" + + when: + // First config with one vulnerability + def config1 = createConfigWithOneVulnerability(class1) + instrumentation.getAllLoadedClasses() >> [String] + instrumentation.isModifiableClass(String) >> true + updater.onConfigUpdate(config1) + + then: + // Transformer was installed on first config + 1 * instrumentation.addTransformer(_, true) + 1 * instrumentation.retransformClasses(String) + + when: + // Second config adds another vulnerability + def config2 = createConfigWithTwoVulnerabilities(class1, class2) + instrumentation.getAllLoadedClasses() >> [String, Integer] + instrumentation.isModifiableClass(Integer) >> true + updater.onConfigUpdate(config2) + + then: + // Transformer should NOT be installed again + 0 * instrumentation.addTransformer(_, _) + // Only the NEW class (Integer) should be retransformed + 1 * instrumentation.retransformClasses(Integer) + // String should NOT be retransformed again + 0 * instrumentation.retransformClasses(String) + } + + def "onConfigUpdate handles retransformation exceptions gracefully"() { + given: + def updater = new AppSecSCAInstrumentationUpdater(instrumentation) + def targetClassName = "java.lang.String" + def config = createConfigWithOneVulnerability(targetClassName) + + instrumentation.getAllLoadedClasses() >> [String] + instrumentation.isModifiableClass(String) >> true + instrumentation.retransformClasses(String) >> { throw new RuntimeException("Test exception") } + + when: + updater.onConfigUpdate(config) + + then: + notThrown(Exception) + 1 * instrumentation.addTransformer(_, true) + } + + def "onConfigUpdate with null config after valid config keeps transformer installed"() { + given: + def updater = new AppSecSCAInstrumentationUpdater(instrumentation) + instrumentation.getAllLoadedClasses() >> [] + + when: + // First, apply valid config + def config = createConfigWithOneVulnerability("java.lang.String") + updater.onConfigUpdate(config) + + then: + // Verify transformer was installed + 1 * instrumentation.addTransformer(_, true) + + when: + // Then remove config + updater.onConfigUpdate(null) + + then: + // Transformer should never be removed + 0 * instrumentation.removeTransformer(_) + updater.getCurrentConfig() == null + updater.hasTransformer() // Transformer still installed + } + + def "onConfigUpdate with multiple vulnerabilities extracts all target classes"() { + given: + def updater = new AppSecSCAInstrumentationUpdater(instrumentation) + def class1 = "java.lang.String" + def class2 = "java.lang.Integer" + def config = createConfigWithTwoVulnerabilities(class1, class2) + + instrumentation.getAllLoadedClasses() >> [String, Integer] + instrumentation.isModifiableClass(_) >> true + + when: + updater.onConfigUpdate(config) + + then: + 1 * instrumentation.addTransformer(_, true) + 1 * instrumentation.retransformClasses(String) + 1 * instrumentation.retransformClasses(Integer) + } + + def "getCurrentConfig returns current configuration"() { + given: + def updater = new AppSecSCAInstrumentationUpdater(instrumentation) + def config = createConfigWithOneVulnerability("com.example.VulnerableClass") + instrumentation.getAllLoadedClasses() >> [] + + when: + updater.onConfigUpdate(config) + + then: + updater.getCurrentConfig() == config + } + + def "hasTransformer returns false before any config update"() { + given: + def updater = new AppSecSCAInstrumentationUpdater(instrumentation) + + expect: + !updater.hasTransformer() + } + + def "hasTransformer returns true after valid config update"() { + given: + def updater = new AppSecSCAInstrumentationUpdater(instrumentation) + def config = createConfigWithOneVulnerability("com.example.VulnerableClass") + instrumentation.getAllLoadedClasses() >> [] + + when: + updater.onConfigUpdate(config) + + then: + updater.hasTransformer() + } + + // Helper methods to create test configs + + private AppSecSCAConfig createConfigWithOneVulnerability(String className) { + def entrypoint = new AppSecSCAConfig.ExternalEntrypoint( + className: className, + methods: ["vulnerableMethod"] + ) + def vulnerability = new AppSecSCAConfig.Vulnerability( + advisory: "GHSA-xxxx-yyyy-zzzz", + cve: "CVE-2024-0001", + externalEntrypoint: entrypoint + ) + return new AppSecSCAConfig(vulnerabilities: [vulnerability]) + } + + private AppSecSCAConfig createConfigWithTwoVulnerabilities(String className1, String className2) { + def entrypoint1 = new AppSecSCAConfig.ExternalEntrypoint( + className: className1, + methods: ["vulnerableMethod1"] + ) + def vulnerability1 = new AppSecSCAConfig.Vulnerability( + advisory: "GHSA-xxxx-yyyy-zzzz", + cve: "CVE-2024-0001", + externalEntrypoint: entrypoint1 + ) + + def entrypoint2 = new AppSecSCAConfig.ExternalEntrypoint( + className: className2, + methods: ["vulnerableMethod2"] + ) + def vulnerability2 = new AppSecSCAConfig.Vulnerability( + advisory: "GHSA-aaaa-bbbb-cccc", + cve: "CVE-2024-0002", + externalEntrypoint: entrypoint2 + ) + + return new AppSecSCAConfig(vulnerabilities: [vulnerability1, vulnerability2]) + } +} diff --git a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/config/AppSecSCATransformerTest.groovy b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/config/AppSecSCATransformerTest.groovy new file mode 100644 index 00000000000..730fc32c3ac --- /dev/null +++ b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/config/AppSecSCATransformerTest.groovy @@ -0,0 +1,415 @@ +package com.datadog.appsec.config + +import datadog.trace.test.util.DDSpecification +import org.objectweb.asm.ClassWriter +import org.objectweb.asm.Opcodes + +import java.util.function.Supplier + +class AppSecSCATransformerTest extends DDSpecification { + + Supplier configSupplier + + void setup() { + configSupplier = Mock(Supplier) + } + + def "constructor creates transformer with config supplier"() { + when: + def transformer = new AppSecSCATransformer(configSupplier) + + then: + transformer != null + } + + def "transform returns null when className is null"() { + given: + def transformer = new AppSecSCATransformer(configSupplier) + def classfileBuffer = createSimpleClassBytecode() + + when: + def result = transformer.transform(null, null, null, null, classfileBuffer) + + then: + result == null + 0 * configSupplier.get() + } + + def "transform returns null when config is null"() { + given: + def transformer = new AppSecSCATransformer(configSupplier) + def classfileBuffer = createSimpleClassBytecode() + configSupplier.get() >> null + + when: + def result = transformer.transform(null, "java/lang/String", null, null, classfileBuffer) + + then: + result == null + } + + def "transform returns null when config has no vulnerabilities"() { + given: + def transformer = new AppSecSCATransformer(configSupplier) + def classfileBuffer = createSimpleClassBytecode() + def config = new AppSecSCAConfig(vulnerabilities: []) + configSupplier.get() >> config + + when: + def result = transformer.transform(null, "java/lang/String", null, null, classfileBuffer) + + then: + result == null + } + + def "transform returns null when config has null vulnerabilities"() { + given: + def transformer = new AppSecSCATransformer(configSupplier) + def classfileBuffer = createSimpleClassBytecode() + def config = new AppSecSCAConfig(vulnerabilities: null) + configSupplier.get() >> config + + when: + def result = transformer.transform(null, "java/lang/String", null, null, classfileBuffer) + + then: + result == null + } + + def "transform returns null when class is not a target"() { + given: + def transformer = new AppSecSCATransformer(configSupplier) + def classfileBuffer = createSimpleClassBytecode() + def config = createConfigWithOneVulnerability("com.example.VulnerableClass") + configSupplier.get() >> config + + when: + def result = transformer.transform(null, "java/lang/String", null, null, classfileBuffer) + + then: + result == null + } + + def "transform instruments when class is a target with external entrypoint"() { + given: + def transformer = new AppSecSCATransformer(configSupplier) + def targetClassName = "com/example/VulnerableClass" + def classfileBuffer = createClassBytecodeWithMethod("vulnerableMethod") + def config = createConfigWithOneVulnerability("com.example.VulnerableClass") + configSupplier.get() >> config + + when: + def result = transformer.transform(null, targetClassName, null, null, classfileBuffer) + + then: + result != null + result != classfileBuffer // Should return modified bytecode + } + + def "transform converts internal class name to binary format"() { + given: + def transformer = new AppSecSCATransformer(configSupplier) + def targetClassName = "com/example/nested/VulnerableClass" + def classfileBuffer = createClassBytecodeWithMethod("vulnerableMethod") + // Config uses binary format + def config = createConfigWithOneVulnerability("com.example.nested.VulnerableClass") + configSupplier.get() >> config + + when: + def result = transformer.transform(null, targetClassName, null, null, classfileBuffer) + + then: + result != null + result != classfileBuffer + } + + def "transform handles multiple vulnerabilities for same class"() { + given: + def transformer = new AppSecSCATransformer(configSupplier) + def targetClassName = "com/example/VulnerableClass" + def classfileBuffer = createClassBytecodeWithMethods(["method1", "method2"]) + + // Create config with multiple vulnerabilities targeting the same class + def entrypoint1 = new AppSecSCAConfig.ExternalEntrypoint( + className: "com.example.VulnerableClass", + methods: ["method1"] + ) + def vulnerability1 = new AppSecSCAConfig.Vulnerability( + advisory: "GHSA-xxxx-1111-zzzz", + cve: "CVE-2024-0001", + externalEntrypoint: entrypoint1 + ) + + def entrypoint2 = new AppSecSCAConfig.ExternalEntrypoint( + className: "com.example.VulnerableClass", + methods: ["method2"] + ) + def vulnerability2 = new AppSecSCAConfig.Vulnerability( + advisory: "GHSA-xxxx-2222-zzzz", + cve: "CVE-2024-0002", + externalEntrypoint: entrypoint2 + ) + + def config = new AppSecSCAConfig(vulnerabilities: [vulnerability1, vulnerability2]) + configSupplier.get() >> config + + when: + def result = transformer.transform(null, targetClassName, null, null, classfileBuffer) + + then: + result != null + result != classfileBuffer + } + + def "transform handles vulnerability with null advisory"() { + given: + def transformer = new AppSecSCATransformer(configSupplier) + def targetClassName = "com/example/VulnerableClass" + def classfileBuffer = createClassBytecodeWithMethod("vulnerableMethod") + + def entrypoint = new AppSecSCAConfig.ExternalEntrypoint( + className: "com.example.VulnerableClass", + methods: ["vulnerableMethod"] + ) + def vulnerability = new AppSecSCAConfig.Vulnerability( + advisory: null, + cve: "CVE-2024-0001", + externalEntrypoint: entrypoint + ) + def config = new AppSecSCAConfig(vulnerabilities: [vulnerability]) + configSupplier.get() >> config + + when: + def result = transformer.transform(null, targetClassName, null, null, classfileBuffer) + + then: + result != null + result != classfileBuffer + } + + def "transform handles vulnerability with null cve"() { + given: + def transformer = new AppSecSCATransformer(configSupplier) + def targetClassName = "com/example/VulnerableClass" + def classfileBuffer = createClassBytecodeWithMethod("vulnerableMethod") + + def entrypoint = new AppSecSCAConfig.ExternalEntrypoint( + className: "com.example.VulnerableClass", + methods: ["vulnerableMethod"] + ) + def vulnerability = new AppSecSCAConfig.Vulnerability( + advisory: "GHSA-xxxx-yyyy-zzzz", + cve: null, + externalEntrypoint: entrypoint + ) + def config = new AppSecSCAConfig(vulnerabilities: [vulnerability]) + configSupplier.get() >> config + + when: + def result = transformer.transform(null, targetClassName, null, null, classfileBuffer) + + then: + result != null + result != classfileBuffer + } + + def "transform returns null when class bytecode is invalid"() { + given: + def transformer = new AppSecSCATransformer(configSupplier) + def targetClassName = "com/example/VulnerableClass" + def invalidBytecode = "invalid bytecode".bytes // Invalid class file + def config = createConfigWithOneVulnerability("com.example.VulnerableClass") + configSupplier.get() >> config + + when: + def result = transformer.transform(null, targetClassName, null, null, invalidBytecode) + + then: + result == null // Should return null on error, not throw + } + + def "transform handles entrypoint with empty methods list"() { + given: + def transformer = new AppSecSCATransformer(configSupplier) + def targetClassName = "com/example/VulnerableClass" + def classfileBuffer = createSimpleClassBytecode() + + def entrypoint = new AppSecSCAConfig.ExternalEntrypoint( + className: "com.example.VulnerableClass", + methods: [] // Empty methods list + ) + def vulnerability = new AppSecSCAConfig.Vulnerability( + advisory: "GHSA-xxxx-yyyy-zzzz", + cve: "CVE-2024-0001", + externalEntrypoint: entrypoint + ) + def config = new AppSecSCAConfig(vulnerabilities: [vulnerability]) + configSupplier.get() >> config + + when: + def result = transformer.transform(null, targetClassName, null, null, classfileBuffer) + + then: + result == null // No methods to instrument + } + + def "transform handles entrypoint with null methods"() { + given: + def transformer = new AppSecSCATransformer(configSupplier) + def targetClassName = "com/example/VulnerableClass" + def classfileBuffer = createSimpleClassBytecode() + + def entrypoint = new AppSecSCAConfig.ExternalEntrypoint( + className: "com.example.VulnerableClass", + methods: null // Null methods + ) + def vulnerability = new AppSecSCAConfig.Vulnerability( + advisory: "GHSA-xxxx-yyyy-zzzz", + cve: "CVE-2024-0001", + externalEntrypoint: entrypoint + ) + def config = new AppSecSCAConfig(vulnerabilities: [vulnerability]) + configSupplier.get() >> config + + when: + def result = transformer.transform(null, targetClassName, null, null, classfileBuffer) + + then: + result == null // No methods to instrument + } + + def "transform ignores null or empty method names in methods list"() { + given: + def transformer = new AppSecSCATransformer(configSupplier) + def targetClassName = "com/example/VulnerableClass" + def classfileBuffer = createClassBytecodeWithMethod("validMethod") + + def entrypoint = new AppSecSCAConfig.ExternalEntrypoint( + className: "com.example.VulnerableClass", + methods: [null, "", "validMethod"] // Mix of invalid and valid + ) + def vulnerability = new AppSecSCAConfig.Vulnerability( + advisory: "GHSA-xxxx-yyyy-zzzz", + cve: "CVE-2024-0001", + externalEntrypoint: entrypoint + ) + def config = new AppSecSCAConfig(vulnerabilities: [vulnerability]) + configSupplier.get() >> config + + when: + def result = transformer.transform(null, targetClassName, null, null, classfileBuffer) + + then: + result != null // Should still instrument the valid method + result != classfileBuffer + } + + def "transform uses dynamic config from supplier on each invocation"() { + given: + def transformer = new AppSecSCATransformer(configSupplier) + def targetClassName = "com/example/VulnerableClass" + def classfileBuffer = createClassBytecodeWithMethod("vulnerableMethod") + + def config1 = createConfigWithOneVulnerability("com.example.OtherClass") + def config2 = createConfigWithOneVulnerability("com.example.VulnerableClass") + + // Configure mock to return different configs on consecutive calls + configSupplier.get() >>> [config1, config2] + + when: + // First call - config1 doesn't match + def result1 = transformer.transform(null, targetClassName, null, null, classfileBuffer) + + then: + result1 == null + + when: + // Second call - config2 matches + def result2 = transformer.transform(null, targetClassName, null, null, classfileBuffer) + + then: + result2 != null + result2 != classfileBuffer + } + + // Helper methods + + private AppSecSCAConfig createConfigWithOneVulnerability(String className) { + def entrypoint = new AppSecSCAConfig.ExternalEntrypoint( + className: className, + methods: ["vulnerableMethod"] + ) + def vulnerability = new AppSecSCAConfig.Vulnerability( + advisory: "GHSA-xxxx-yyyy-zzzz", + cve: "CVE-2024-0001", + externalEntrypoint: entrypoint + ) + return new AppSecSCAConfig(vulnerabilities: [vulnerability]) + } + + /** + * Creates a simple valid class bytecode for testing. + * Equivalent to: public class TestClass { public void testMethod() {} } + */ + private byte[] createSimpleClassBytecode() { + ClassWriter cw = new ClassWriter(0) + cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "TestClass", null, "java/lang/Object", null) + + // Add constructor + def mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null) + mv.visitCode() + mv.visitVarInsn(Opcodes.ALOAD, 0) + mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false) + mv.visitInsn(Opcodes.RETURN) + mv.visitMaxs(1, 1) + mv.visitEnd() + + // Add test method + mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "testMethod", "()V", null, null) + mv.visitCode() + mv.visitInsn(Opcodes.RETURN) + mv.visitMaxs(0, 1) + mv.visitEnd() + + cw.visitEnd() + return cw.toByteArray() + } + + /** + * Creates class bytecode with a specific method name. + * Equivalent to: public class TestClass { public void [methodName]() {} } + */ + private byte[] createClassBytecodeWithMethod(String methodName) { + return createClassBytecodeWithMethods([methodName]) + } + + /** + * Creates class bytecode with multiple specific method names. + * Equivalent to: public class TestClass { public void method1() {} public void method2() {} ... } + */ + private byte[] createClassBytecodeWithMethods(List methodNames) { + ClassWriter cw = new ClassWriter(0) + cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "TestClass", null, "java/lang/Object", null) + + // Add constructor + def mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null) + mv.visitCode() + mv.visitVarInsn(Opcodes.ALOAD, 0) + mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false) + mv.visitInsn(Opcodes.RETURN) + mv.visitMaxs(1, 1) + mv.visitEnd() + + // Add specified methods + for (String methodName : methodNames) { + mv = cw.visitMethod(Opcodes.ACC_PUBLIC, methodName, "()V", null, null) + mv.visitCode() + mv.visitInsn(Opcodes.RETURN) + mv.visitMaxs(0, 1) + mv.visitEnd() + } + + cw.visitEnd() + return cw.toByteArray() + } +} diff --git a/dd-smoke-tests/dynamic-config/build.gradle b/dd-smoke-tests/dynamic-config/build.gradle index 2a3c2df5fed..169ffc32d40 100644 --- a/dd-smoke-tests/dynamic-config/build.gradle +++ b/dd-smoke-tests/dynamic-config/build.gradle @@ -11,6 +11,7 @@ dependencies { implementation group: 'io.opentracing', name: 'opentracing-api', version: '0.32.0' implementation group: 'io.opentracing', name: 'opentracing-util', version: '0.32.0' implementation libs.slf4j + implementation libs.jackson.databind testImplementation project(':dd-smoke-tests') } diff --git a/dd-smoke-tests/dynamic-config/src/main/java/datadog/smoketest/dynamicconfig/ScaApplication.java b/dd-smoke-tests/dynamic-config/src/main/java/datadog/smoketest/dynamicconfig/ScaApplication.java new file mode 100644 index 00000000000..c5af25acbf7 --- /dev/null +++ b/dd-smoke-tests/dynamic-config/src/main/java/datadog/smoketest/dynamicconfig/ScaApplication.java @@ -0,0 +1,46 @@ +package datadog.smoketest.dynamicconfig; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.concurrent.TimeUnit; + +/** + * Simple test application for SCA smoke tests. + * + *

This application uses Jackson's ObjectMapper which will be instrumented by SCA to detect + * vulnerable method invocations. + */ +public class ScaApplication { + + public static final long TIMEOUT_IN_SECONDS = 15; + + public static void main(String[] args) throws Exception { + // Load a class that could be targeted by SCA instrumentation + // This ensures the class is loaded and available for retransformation + ObjectMapper mapper = new ObjectMapper(); + + System.out.println("ScaApplication started with ObjectMapper: " + mapper.getClass().getName()); + System.out.println("READY_FOR_INSTRUMENTATION"); + + // Wait for Remote Config to send SCA configuration and apply instrumentation + System.out.println("Waiting for SCA configuration..."); + Thread.sleep(TimeUnit.SECONDS.toMillis(5)); + + // Now invoke the target method that should be instrumented + System.out.println("INVOKING_TARGET_METHOD"); + try { + // This should trigger SCA detection if instrumentation is working + String json = "{\"name\":\"test\"}"; + mapper.readValue(json, Object.class); + System.out.println("METHOD_INVOCATION_DONE"); + } catch (Exception e) { + System.err.println("Error invoking target method: " + e.getMessage()); + e.printStackTrace(); + } + + // Wait a bit more to allow logs to be flushed + Thread.sleep(TimeUnit.SECONDS.toMillis(2)); + + System.out.println("ScaApplication finished"); + System.exit(0); + } +} diff --git a/dd-smoke-tests/dynamic-config/src/test/groovy/datadog/smoketest/ScaSmokeTest.groovy b/dd-smoke-tests/dynamic-config/src/test/groovy/datadog/smoketest/ScaSmokeTest.groovy new file mode 100644 index 00000000000..3d63ae8264a --- /dev/null +++ b/dd-smoke-tests/dynamic-config/src/test/groovy/datadog/smoketest/ScaSmokeTest.groovy @@ -0,0 +1,168 @@ +package datadog.smoketest + +import datadog.remoteconfig.Capabilities +import datadog.remoteconfig.Product +import datadog.smoketest.dynamicconfig.ScaApplication + +/** + * Smoke test for Supply Chain Analysis (SCA) via Remote Config. + * + * Tests that: + * 1. ASM_SCA product subscription is reported + * 2. CAPABILITY_ASM_SCA_VULNERABILITY_DETECTION capability is reported + * 3. SCA configuration is received and processed + */ +class ScaSmokeTest extends AbstractSmokeTest { + + @Override + ProcessBuilder createProcessBuilder() { + def command = [javaPath()] + command += defaultJavaProperties.toList() + command += [ + '-Ddd.appsec.enabled=true', + '-Ddd.remote_config.enabled=true', + "-Ddd.remote_config.url=http://localhost:${server.address.port}/v0.7/config".toString(), + '-Ddd.remote_config.poll_interval.seconds=1', + '-Ddd.profiling.enabled=false', + // Enable debug logging for SCA components + '-Ddatadog.slf4j.simpleLogger.log.com.datadog.appsec=info', + '-Ddatadog.slf4j.simpleLogger.log.datadog.remoteconfig=debug', + '-cp', + System.getProperty('datadog.smoketest.shadowJar.path'), + ScaApplication.name + ] + + final processBuilder = new ProcessBuilder(command) + processBuilder.directory(new File(buildDirectory)) + } + + void 'test SCA subscription and capability reporting'() { + when: 'AppSec is started with SCA support' + final request = waitForRcClientRequest { req -> + decodeProducts(req).contains(Product.DEBUG) + } + + then: 'ASM_SCA product should be reported' + final products = decodeProducts(request) + assert products.contains(Product.DEBUG) + + and: 'SCA vulnerability detection capability should be reported' + final capabilities = decodeCapabilities(request) + assert hasCapability(capabilities, Capabilities.CAPABILITY_ASM_SCA_VULNERABILITY_DETECTION) + } + + void 'test SCA config processing'() { + given: 'A sample SCA configuration with instrumentation targets' + final scaConfig = ''' +{ + "enabled": true, + "instrumentation_targets": [ + { + "class_name": "com/fasterxml/jackson/databind/ObjectMapper", + "method_name": "readValue", + "method_descriptor": "(Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object;", + "vulnerability": { + "cve_id": "CVE-2020-EXAMPLE", + "severity": "HIGH" + } + } + ] +} +''' + + when: 'AppSec is started' + waitForRcClientRequest { req -> + decodeProducts(req).contains(Product.DEBUG) + } + + and: 'SCA configuration is sent via Remote Config' + setRemoteConfig('datadog/2/ASM_SCA/sca_test_config/config', scaConfig) + + then: 'The application should process the config without errors' + // Wait a few seconds for config processing + sleep(3000) + + and: 'Process should be running without crashing' + // If there were errors, the process would have crashed + assert testedProcess.alive + } + + //TODO fix it + + // void 'test complete SCA instrumentation and detection flow'() { + // given: 'A sample SCA configuration targeting ObjectMapper.readValue' + // final scaConfig = ''' + // { + // "enabled": true, + // "instrumentation_targets": [ + // { + // "class_name": "com/fasterxml/jackson/databind/ObjectMapper", + // "method_name": "readValue", + // "method_descriptor": "(Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object;" + // } + // ] + // } + // ''' + // + // when: 'AppSec is started and subscribes to SCA' + // waitForRcClientRequest { req -> + // decodeProducts(req).contains(Product.DEBUG) + // } + // + // and: 'Application signals it is ready for instrumentation' + // def ready = isLogPresent { it.contains('READY_FOR_INSTRUMENTATION') } + // assert ready, 'Application should signal readiness' + // + // and: 'SCA configuration is sent via Remote Config' + // setRemoteConfig('datadog/2/ASM_SCA/sca_test_config/config', scaConfig) + // + // and: 'Poller receives the new configuration' + // // Wait for next RC poll to pick up the config + // sleep(2000) + // + // then: 'Instrumentation is applied and logged' + // // Check for instrumentation-related logs + // def configReceived = isLogPresent { it.contains('Successfully subscribed to ASM_SCA') } + // assert configReceived, 'Expected SCA subscription log' + // + // // If retransformation happens, the process should be alive + // assert testedProcess.alive + // + // when: 'Application invokes the instrumented method' + // def methodInvoked = isLogPresent { it.contains('INVOKING_TARGET_METHOD') } + // assert methodInvoked, 'Application should invoke target method' + // + // then: 'SCA detection callback is triggered and logged' + // def detectionFound = isLogPresent { String log -> + // log.contains('[SCA DETECTION] Vulnerable method invoked') && + // log.contains('ObjectMapper') && + // log.contains('readValue') + // } + // assert detectionFound, 'SCA detection should have been triggered' + // + // and: 'Method invocation completes successfully' + // def invocationDone = isLogPresent { it.contains('METHOD_INVOCATION_DONE') } + // assert invocationDone, 'Method invocation should complete' + // + // and: 'Process should be running without errors' + // // Process stays alive until all tests finish + // assert testedProcess.alive + // } + + private static Set decodeProducts(final Map request) { + return request.client.products.collect { Product.valueOf(it) } + } + + private static long decodeCapabilities(final Map request) { + final clientCapabilities = request.client.capabilities as byte[] + long capabilities = 0L + for (int i = 0; i < clientCapabilities.length; i++) { + capabilities |= (clientCapabilities[i] & 0xFFL) << ((clientCapabilities.length - i - 1) * 8) + } + return capabilities + } + + private static boolean hasCapability(final long capabilities, final long test) { + return (capabilities & test) > 0 + } +} diff --git a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Capabilities.java b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Capabilities.java index ce76e59000a..db33e1facb9 100644 --- a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Capabilities.java +++ b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Capabilities.java @@ -47,4 +47,6 @@ public interface Capabilities { long CAPABILITY_ASM_EXTENDED_DATA_COLLECTION = 1L << 44; long CAPABILITY_APM_TRACING_MULTICONFIG = 1L << 45; long CAPABILITY_FFE_FLAG_CONFIGURATION_RULES = 1L << 46; + // Supply Chain Analysis - Vulnerability Detection via dynamic instrumentation + long CAPABILITY_ASM_SCA_VULNERABILITY_DETECTION = 1L << 47; } diff --git a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Product.java b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Product.java index c018205d958..7a39d293c2c 100644 --- a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Product.java +++ b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Product.java @@ -11,6 +11,7 @@ public enum Product { ASM, ASM_DATA, ASM_FEATURES, + DEBUG, FFE_FLAGS, _UNKNOWN, } diff --git a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/DefaultConfigurationPoller.java b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/DefaultConfigurationPoller.java index 43863d1699b..1617b7e788e 100644 --- a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/DefaultConfigurationPoller.java +++ b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/DefaultConfigurationPoller.java @@ -401,6 +401,8 @@ private void handleAgentResponse(ResponseBody body) { Map> parsedKeysByProduct = new HashMap<>(); + boolean appliedAny = false; + for (String configKey : fleetResponse.getClientConfigs()) { try { ParsedConfigKey parsedConfigKey = ParsedConfigKey.parse(configKey); @@ -413,13 +415,20 @@ private void handleAgentResponse(ResponseBody body) { + parsedConfigKey.getProductName() + " is not being handled"); } + // TODO(POC): Log when we detect SCA configs from DEBUG endpoint + // Backend serves SCA configs via: GET /api/unstable/remote-config/debug/configs/SCA_{id} + // These arrive as DEBUG product with "SCA_" prefix in config ID. + if (product == Product.DEBUG + && "DEBUG".equalsIgnoreCase(parsedConfigKey.getProductName()) + && parsedConfigKey.getConfigId().startsWith("SCA_")) { + log.debug("POC: Detected SCA config from DEBUG endpoint: {}", configKey); + } parsedKeysByProduct.computeIfAbsent(product, k -> new ArrayList<>()).add(parsedConfigKey); } catch (ReportableException e) { errors.add(e); } } - boolean appliedAny = false; for (Map.Entry entry : productStates.entrySet()) { Product product = entry.getKey(); ProductState state = entry.getValue(); diff --git a/remote-config/remote-config-core/src/test/groovy/datadog/remoteconfig/DefaultConfigurationPollerSpecification.groovy b/remote-config/remote-config-core/src/test/groovy/datadog/remoteconfig/DefaultConfigurationPollerSpecification.groovy index 17689fa16cb..aa7e20c1d58 100644 --- a/remote-config/remote-config-core/src/test/groovy/datadog/remoteconfig/DefaultConfigurationPollerSpecification.groovy +++ b/remote-config/remote-config-core/src/test/groovy/datadog/remoteconfig/DefaultConfigurationPollerSpecification.groovy @@ -1689,4 +1689,220 @@ class DefaultConfigurationPollerSpecification extends DDSpecification { version: 23337393 ] )) + + void 'POC: handles DEBUG product with SCA_ prefix'() { + setup: + def scaConfigContent = '{"enabled":true,"instrumentation_targets":[{"class_name":"com/fasterxml/jackson/databind/ObjectMapper","method_name":"readValue"}]}' + def scaConfigKey = 'datadog/2/DEBUG/SCA_my_service_123/config' + def scaConfigHash = String.format('%064x', new BigInteger(1, MessageDigest.getInstance('SHA-256').digest(scaConfigContent.getBytes('UTF-8')))) + def respBody = JsonOutput.toJson( + client_configs: [scaConfigKey], + roots: [], + target_files: [ + [ + path: scaConfigKey, + raw: Base64.encoder.encodeToString(scaConfigContent.getBytes('UTF-8')) + ] + ], + targets: signAndBase64EncodeTargets( + signed: [ + expires: '2022-09-17T12:49:15Z', + spec_version: '1.0.0', + targets: [ + (scaConfigKey): [ + custom: [v: 1], + hashes: [ + sha256: scaConfigHash + ], + length: scaConfigContent.size(), + ] + ], + version: 1 + ] + )) + + ConfigurationChangesTypedListener scaListener = Mock() + + when: + poller.addListener(Product.DEBUG, + { SLURPER.parse(it) } as ConfigurationDeserializer, + scaListener) + poller.start() + + then: + 1 * scheduler.scheduleAtFixedRate(_, poller, 0, DEFAULT_POLL_PERIOD, TimeUnit.MILLISECONDS) >> { task = it[0]; scheduled } + + when: + task.run(poller) + + then: + 1 * okHttpClient.newCall(_ as Request) >> { request = it[0]; call } + 1 * call.execute() >> { buildOKResponse(respBody) } + 1 * scaListener.accept(scaConfigKey, _, _ as PollingRateHinter) + 0 * _._ + + when: + task.run(poller) + + then: + 1 * okHttpClient.newCall(_ as Request) >> { request = it[0]; call } + 1 * call.execute() >> { buildOKResponse(respBody) } + 0 * _._ + + def body = parseBody(request.body()) + with(body.client.state.config_states[0]) { + id == 'SCA_my_service_123' + product == 'DEBUG' + version == 1 + } + } + + void 'POC: DEBUG product without SCA_ prefix throws error'() { + setup: + def debugConfigKey = 'datadog/2/DEBUG/some_other_config/config' + def respBody = JsonOutput.toJson( + client_configs: [debugConfigKey], + roots: [], + target_files: [ + [ + path: debugConfigKey, + raw: Base64.encoder.encodeToString('{"test":"data"}'.getBytes('UTF-8')) + ] + ], + targets: signAndBase64EncodeTargets( + signed: [ + expires: '2022-09-17T12:49:15Z', + spec_version: '1.0.0', + targets: [ + (debugConfigKey): [ + custom: [v: 1], + hashes: [sha256: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'], + length: 15, + ] + ], + version: 1 + ] + )) + + when: + poller.addListener(Product.ASM_DD, + { SLURPER.parse(it) } as ConfigurationDeserializer, + { Object[] args -> } as ConfigurationChangesTypedListener) + poller.start() + + then: + 1 * scheduler.scheduleAtFixedRate(_, poller, 0, DEFAULT_POLL_PERIOD, TimeUnit.MILLISECONDS) >> { task = it[0]; scheduled } + + when: + task.run(poller) + + then: + 1 * okHttpClient.newCall(_ as Request) >> { request = it[0]; call } + 1 * call.execute() >> { buildOKResponse(respBody) } + 0 * _._ + + when: + task.run(poller) + + then: + 1 * okHttpClient.newCall(_ as Request) >> { request = it[0]; call } + 1 * call.execute() >> { buildOKResponse(SAMPLE_RESP_BODY) } + 0 * _._ + + def body = parseBody(request.body()) + with(body.client.state) { + has_error == true + error == 'Told to handle config key datadog/2/DEBUG/some_other_config/config, but the product DEBUG is not being handled' + } + } + + void 'POC: multiple products including DEBUG with SCA_ are handled correctly'() { + setup: + def scaConfigContent = '{"enabled":true}' + def scaConfigKey = 'datadog/2/DEBUG/SCA_service/config' + def scaConfigHash = String.format('%064x', new BigInteger(1, MessageDigest.getInstance('SHA-256').digest(scaConfigContent.getBytes('UTF-8')))) + def asmConfigKey = 'employee/ASM_DD/1.recommended.json/config' + def respBody = JsonOutput.toJson( + client_configs: [asmConfigKey, scaConfigKey], + roots: [], + target_files: [ + [ + path: asmConfigKey, + raw: Base64.encoder.encodeToString(SAMPLE_APPSEC_CONFIG.getBytes('UTF-8')) + ], + [ + path: scaConfigKey, + raw: Base64.encoder.encodeToString(scaConfigContent.getBytes('UTF-8')) + ] + ], + targets: signAndBase64EncodeTargets( + signed: [ + expires: '2022-09-17T12:49:15Z', + spec_version: '1.0.0', + targets: [ + (asmConfigKey): [ + custom: [v: 1], + hashes: [sha256: '6302258236e6051216b950583ec7136d946b463c17cbe64384ba5d566324819'], + length: 919, + ], + (scaConfigKey): [ + custom: [v: 1], + hashes: [ + sha256: scaConfigHash + ], + length: scaConfigContent.size(), + ] + ], + version: 1 + ] + )) + + ConfigurationChangesTypedListener asmListener = Mock() + ConfigurationChangesTypedListener scaListener = Mock() + + when: + poller.addListener(Product.ASM_DD, + { SLURPER.parse(it) } as ConfigurationDeserializer, + asmListener) + poller.addListener(Product.DEBUG, + { SLURPER.parse(it) } as ConfigurationDeserializer, + scaListener) + poller.start() + + then: + 1 * scheduler.scheduleAtFixedRate(_, poller, 0, DEFAULT_POLL_PERIOD, TimeUnit.MILLISECONDS) >> { task = it[0]; scheduled } + + when: + task.run(poller) + + then: + 1 * okHttpClient.newCall(_ as Request) >> { request = it[0]; call } + 1 * call.execute() >> { buildOKResponse(respBody) } + 1 * asmListener.accept(asmConfigKey, _, _ as PollingRateHinter) + 1 * scaListener.accept(scaConfigKey, _, _ as PollingRateHinter) + 0 * _._ + + when: + task.run(poller) + + then: + 1 * okHttpClient.newCall(_ as Request) >> { request = it[0]; call } + 1 * call.execute() >> { buildOKResponse(respBody) } + 0 * _._ + + def body = parseBody(request.body()) + body.client.state.config_states.size() == 2 + def asmConfig = body.client.state.config_states.find { it.product == 'ASM_DD' } + def scaConfig = body.client.state.config_states.find { it.product == 'DEBUG' } + with(asmConfig) { + id == '1.recommended.json' + product == 'ASM_DD' + version == 1 + } + with(scaConfig) { + id == 'SCA_service' + product == 'DEBUG' + version == 1 + } + } }