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
+ }
+ }
}