diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8efd03d9498..db0432dfc4f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -87,6 +87,8 @@ /dd-trace-api/src/main/java/datadog/trace/api/EventTracker.java @DataDog/asm-java /internal-api/src/main/java/datadog/trace/api/gateway/ @DataDog/asm-java /internal-api/src/main/java/datadog/trace/api/http/ @DataDog/asm-java +/internal-api/src/main/java/datadog/trace/api/telemetry/ScaReachability* @DataDog/asm-java +/telemetry/src/main/java/datadog/telemetry/sca/ @DataDog/asm-java **/appsec/ @DataDog/asm-java **/*CallSite*.java @DataDog/asm-java **/*CallSite*.groovy @DataDog/asm-java diff --git a/.gitignore b/.gitignore index efe0ddbf28b..bbb9963e403 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,8 @@ out/ # Claude Code local custom settings # ##################################### .claude/*.local.* +.claude-invariants.md +.claude-status.md # Vim # ####### diff --git a/buildSrc/src/main/kotlin/datadog/gradle/sca/GhsaEnrichmentParser.kt b/buildSrc/src/main/kotlin/datadog/gradle/sca/GhsaEnrichmentParser.kt new file mode 100644 index 00000000000..cecfb98a97a --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/sca/GhsaEnrichmentParser.kt @@ -0,0 +1,79 @@ +package datadog.gradle.sca + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper + +/** + * Parses GHSA enrichment JSON files from the sca-reachability-database into the internal + * sca_cves.json format consumed by SCA Reachability at runtime. + * + * Key transformations: + * - Filters entries to JVM language only + * - Expands multi-package GHSA entries into N records (one per Maven artifact), because + * each artifact may have different version ranges for the same set of class symbols + * - Converts class FQNs to JVM internal format (slashes) so the ClassFileTransformer + * can do O(1) map lookups without per-class string conversion + * - Sets method=null for all symbols — field exists for forward compatibility when the + * database adds method-level symbols in the future (see APPSEC-62260) + */ +object GhsaEnrichmentParser { + + private val mapper = ObjectMapper() + + /** + * Parses a single GHSA enrichment file. + * + * @param ghsaId the GHSA identifier (e.g. "GHSA-645p-88qh-w398"), used as vuln_id + * @param jsonContent the raw JSON content of the enrichment file + * @return list of sca_cves.json entry maps, one per affected Maven artifact + */ + fun parse(ghsaId: String, jsonContent: String): List> { + val root = mapper.readTree(jsonContent) + require(root.isArray) { "GHSA enrichment file $ghsaId must be a JSON array, got ${root.nodeType}" } + + val entries = mutableListOf>() + + for (entry in root) { + if (entry.path("language").asText() != "jvm") continue + + val symbols = extractSymbols(entry) + if (symbols.isEmpty()) continue + + for (pkg in entry.path("package")) { + if (pkg.path("ecosystem").asText() != "maven") continue + val artifact = pkg.path("name").asText().takeIf { it.isNotEmpty() } ?: continue + val versionRanges = pkg.path("version_range").map { it.asText() } + + entries += mapOf( + "vuln_id" to ghsaId, + "artifact" to artifact, + "version_ranges" to versionRanges, + "symbols" to symbols, + ) + } + } + + return entries + } + + private fun extractSymbols(entry: JsonNode): List> { + val symbols = mutableListOf>() + val imports = entry.path("ecosystem_specific").path("imports") + if (imports.isMissingNode || !imports.isArray) return symbols + + for (importGroup in imports) { + for (symbol in importGroup.path("symbols")) { + if (symbol.path("type").asText() != "class") continue + val pkg = symbol.path("value").asText().takeIf { it.isNotEmpty() } ?: continue + val name = symbol.path("name").asText().takeIf { it.isNotEmpty() } ?: continue + + // JVM internal format (slashes) — avoids per-class conversion in the + // ClassFileTransformer hot path at runtime + val internalName = "$pkg.$name".replace('.', '/') + symbols += mapOf("class" to internalName, "method" to null) + } + } + + return symbols + } +} diff --git a/buildSrc/src/test/kotlin/datadog/gradle/sca/GhsaEnrichmentParserTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/sca/GhsaEnrichmentParserTest.kt new file mode 100644 index 00000000000..7f850b325e5 --- /dev/null +++ b/buildSrc/src/test/kotlin/datadog/gradle/sca/GhsaEnrichmentParserTest.kt @@ -0,0 +1,117 @@ +package datadog.gradle.sca + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test + +class GhsaEnrichmentParserTest { + + private fun fixture(name: String): String = + GhsaEnrichmentParserTest::class.java + .getResourceAsStream("/sca/fixtures/$name")!! + .bufferedReader() + .readText() + + @Test + fun `single package entry produces one record`() { + val entries = GhsaEnrichmentParser.parse("GHSA-single-package", fixture("GHSA-single-package.json")) + + assertThat(entries).hasSize(1) + val entry = entries[0] + assertThat(entry["vuln_id"]).isEqualTo("GHSA-single-package") + assertThat(entry["artifact"]).isEqualTo("com.fasterxml.jackson.core:jackson-databind") + assertThat(entry["version_ranges"]).isEqualTo(listOf("< 2.6.7.3", ">= 2.7.0, < 2.7.9.5")) + } + + @Test + fun `class names are converted to JVM internal format with slashes`() { + val entries = GhsaEnrichmentParser.parse("GHSA-single-package", fixture("GHSA-single-package.json")) + + @Suppress("UNCHECKED_CAST") + val symbols = entries[0]["symbols"] as List> + assertThat(symbols).hasSize(2) + assertThat(symbols.map { it["class"] }).containsExactly( + "com/fasterxml/jackson/databind/ObjectMapper", + "com/fasterxml/jackson/databind/ObjectReader", + ) + } + + @Test + fun `method field is always null for class-level symbols`() { + val entries = GhsaEnrichmentParser.parse("GHSA-single-package", fixture("GHSA-single-package.json")) + + @Suppress("UNCHECKED_CAST") + val symbols = entries[0]["symbols"] as List> + assertThat(symbols).allSatisfy { symbol -> + assertThat(symbol["method"]).isNull() + } + } + + @Test + fun `multi-package entry expands to one record per artifact`() { + val entries = GhsaEnrichmentParser.parse("GHSA-multi-package", fixture("GHSA-multi-package.json")) + + assertThat(entries).hasSize(2) + assertThat(entries.map { it["artifact"] }).containsExactlyInAnyOrder( + "org.springframework.boot:spring-boot-starter-web", + "org.springframework:spring-webmvc", + ) + } + + @Test + fun `multi-package entries each have their own version ranges`() { + val entries = GhsaEnrichmentParser.parse("GHSA-multi-package", fixture("GHSA-multi-package.json")) + + val webEntry = entries.first { it["artifact"] == "org.springframework.boot:spring-boot-starter-web" } + assertThat(webEntry["version_ranges"]).isEqualTo(listOf("< 2.5.12", ">= 2.6.0, < 2.6.6")) + + val mvcEntry = entries.first { it["artifact"] == "org.springframework:spring-webmvc" } + assertThat(mvcEntry["version_ranges"]).isEqualTo(listOf(">= 5.3.0, < 5.3.18", "< 5.2.20.RELEASE")) + } + + @Test + fun `multi-package entries share the same symbols`() { + val entries = GhsaEnrichmentParser.parse("GHSA-multi-package", fixture("GHSA-multi-package.json")) + + @Suppress("UNCHECKED_CAST") + val symbols0 = entries[0]["symbols"] as List> + @Suppress("UNCHECKED_CAST") + val symbols1 = entries[1]["symbols"] as List> + assertThat(symbols0.map { it["class"] }).containsExactlyInAnyOrder( + "org/springframework/stereotype/Controller", + "org/springframework/web/bind/annotation/RestController", + ) + assertThat(symbols0.map { it["class"] }).isEqualTo(symbols1.map { it["class"] }) + } + + @Test + fun `non-jvm language entries are ignored`() { + val entries = GhsaEnrichmentParser.parse("GHSA-mixed-languages", fixture("GHSA-mixed-languages.json")) + + assertThat(entries).hasSize(1) + assertThat(entries[0]["artifact"]).isEqualTo("com.thoughtworks.xstream:xstream") + } + + @Test + fun `entries with no symbols produce no output`() { + val entries = GhsaEnrichmentParser.parse("GHSA-empty-symbols", fixture("GHSA-empty-symbols.json")) + + assertThat(entries).isEmpty() + } + + @Test + fun `ghsa id is used as vuln_id without modification`() { + val ghsaId = "GHSA-645p-88qh-w398" + val entries = GhsaEnrichmentParser.parse(ghsaId, fixture("GHSA-single-package.json")) + + assertThat(entries[0]["vuln_id"]).isEqualTo(ghsaId) + } + + @Test + fun `non-json-array input throws IllegalArgumentException`() { + assertThatThrownBy { + GhsaEnrichmentParser.parse("GHSA-bad", """{"language": "jvm"}""") + }.isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("must be a JSON array") + } +} diff --git a/buildSrc/src/test/resources/sca/fixtures/GHSA-empty-symbols.json b/buildSrc/src/test/resources/sca/fixtures/GHSA-empty-symbols.json new file mode 100644 index 00000000000..e856f6d2f9c --- /dev/null +++ b/buildSrc/src/test/resources/sca/fixtures/GHSA-empty-symbols.json @@ -0,0 +1,15 @@ +[ + { + "language": "jvm", + "package": [ + { + "ecosystem": "maven", + "name": "org.example:some-lib", + "version_range": ["< 1.0.0"] + } + ], + "ecosystem_specific": { + "imports": [] + } + } +] diff --git a/buildSrc/src/test/resources/sca/fixtures/GHSA-mixed-languages.json b/buildSrc/src/test/resources/sca/fixtures/GHSA-mixed-languages.json new file mode 100644 index 00000000000..5f85a1295a1 --- /dev/null +++ b/buildSrc/src/test/resources/sca/fixtures/GHSA-mixed-languages.json @@ -0,0 +1,40 @@ +[ + { + "language": "python", + "package": [ + { + "ecosystem": "pypi", + "name": "requests", + "version_range": ["< 2.28.0"] + } + ], + "ecosystem_specific": { + "imports": [ + { + "symbols": [ + {"type": "function", "value": "requests", "name": "get"} + ] + } + ] + } + }, + { + "language": "jvm", + "package": [ + { + "ecosystem": "maven", + "name": "com.thoughtworks.xstream:xstream", + "version_range": ["< 1.4.16"] + } + ], + "ecosystem_specific": { + "imports": [ + { + "symbols": [ + {"type": "class", "value": "com.thoughtworks.xstream", "name": "XStream"} + ] + } + ] + } + } +] diff --git a/buildSrc/src/test/resources/sca/fixtures/GHSA-multi-package.json b/buildSrc/src/test/resources/sca/fixtures/GHSA-multi-package.json new file mode 100644 index 00000000000..133a7e1ccf2 --- /dev/null +++ b/buildSrc/src/test/resources/sca/fixtures/GHSA-multi-package.json @@ -0,0 +1,41 @@ +[ + { + "language": "jvm", + "package": [ + { + "ecosystem": "maven", + "name": "org.springframework.boot:spring-boot-starter-web", + "version_range": [ + "< 2.5.12", + ">= 2.6.0, < 2.6.6" + ] + }, + { + "ecosystem": "maven", + "name": "org.springframework:spring-webmvc", + "version_range": [ + ">= 5.3.0, < 5.3.18", + "< 5.2.20.RELEASE" + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "symbols": [ + { + "type": "class", + "value": "org.springframework.stereotype", + "name": "Controller" + }, + { + "type": "class", + "value": "org.springframework.web.bind.annotation", + "name": "RestController" + } + ] + } + ] + } + } +] diff --git a/buildSrc/src/test/resources/sca/fixtures/GHSA-single-package.json b/buildSrc/src/test/resources/sca/fixtures/GHSA-single-package.json new file mode 100644 index 00000000000..3f9c2ea01aa --- /dev/null +++ b/buildSrc/src/test/resources/sca/fixtures/GHSA-single-package.json @@ -0,0 +1,33 @@ +[ + { + "language": "jvm", + "package": [ + { + "ecosystem": "maven", + "name": "com.fasterxml.jackson.core:jackson-databind", + "version_range": [ + "< 2.6.7.3", + ">= 2.7.0, < 2.7.9.5" + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "symbols": [ + { + "type": "class", + "value": "com.fasterxml.jackson.databind", + "name": "ObjectMapper" + }, + { + "type": "class", + "value": "com.fasterxml.jackson.databind", + "name": "ObjectReader" + } + ] + } + ] + } + } +] 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 f46e04bed6a..b6d1acaae4e 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 @@ -676,6 +676,7 @@ public void execute() { } maybeStartAppSec(scoClass, sco); + maybeStartScaReachability(instrumentation); maybeStartCiVisibility(instrumentation, scoClass, sco); maybeStartLLMObs(instrumentation, scoClass, sco); // start debugger before remote config to subscribe to it before starting to poll @@ -1073,6 +1074,22 @@ private static boolean isSupportedAppSecArch() { return true; } + private static void maybeStartScaReachability(Instrumentation instrumentation) { + if (!Config.get().isAppSecScaEnabled()) { + return; + } + StaticEventLogger.begin("ScaReachability"); + try { + final Class scaClass = + AGENT_CLASSLOADER.loadClass("com.datadog.appsec.sca.ScaReachabilitySystem"); + final Method startMethod = scaClass.getMethod("start", Instrumentation.class); + startMethod.invoke(null, instrumentation); + } catch (final Throwable ex) { + log.warn("Not starting SCA Reachability subsystem: {}", ex.getMessage()); + } + StaticEventLogger.end("ScaReachability"); + } + private static void maybeStartIast(Instrumentation instrumentation) { if (iastEnabled || !iastFullyDisabled) { diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/appsec/sca/ScaReachabilityCallback.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/appsec/sca/ScaReachabilityCallback.java new file mode 100644 index 00000000000..dc390b0a45e --- /dev/null +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/appsec/sca/ScaReachabilityCallback.java @@ -0,0 +1,75 @@ +package datadog.trace.bootstrap.appsec.sca; + +/** + * Bootstrap-classloader callback for SCA Reachability method-level detection. + * + *

Bytecode injected into application classes by {@code ScaReachabilityTransformer} calls {@link + * #onMethodHit} statically. Because this class lives in the bootstrap classloader, it is visible + * from any application class regardless of classloader hierarchy. + * + *

The actual handler is registered at agent startup by {@code ScaReachabilitySystem.start()}. + */ +public final class ScaReachabilityCallback { + + /** Receives method-level reachability hits from instrumented application code. */ + public interface Handler { + void onMethodHit( + String vulnId, + String artifact, + String version, + String dotClassName, + String methodName, + int line); + } + + private static volatile Handler handler; + + /** Runtime dedup: "vulnId|artifact|dotClassName|methodName" tuples already reported. */ + private static final java.util.Set reported = + java.util.concurrent.ConcurrentHashMap.newKeySet(); + + /** + * Called by {@code ScaReachabilitySystem} to wire up the real reporting implementation. Passing + * {@code null} clears both the handler and the dedup set (used in tests). + */ + public static void register(Handler h) { + handler = h; + if (h == null) { + reported.clear(); + } + } + + /** + * Called from bytecode injected into the entry point of a vulnerable method. Deduplicates at + * runtime so the handler is called at most once per (vulnId, artifact, methodName) triple. + * + *

The {@code dotClassName} and {@code methodName} parameters identify the VULNERABLE SYMBOL + * (baked in at transform time) and are used for deduplication. The handler (registered by {@code + * ScaReachabilitySystem}) is responsible for capturing the callsite from the current thread stack + * and reporting it to telemetry - keeping this class minimal as required for bootstrap. + */ + public static void onMethodHit( + String vulnId, + String artifact, + String version, + String dotClassName, + String methodName, + int line) { + try { + Handler h = handler; + if (h == null) { + return; + } + // Include version and dotClassName: version isolates hits across artifact versions loaded + // in separate classloaders; dotClassName distinguishes classes with the same method name. + String key = vulnId + "|" + artifact + "|" + version + "|" + dotClassName + "|" + methodName; + if (reported.add(key)) { + h.onMethodHit(vulnId, artifact, version, dotClassName, methodName, line); + } + } catch (Throwable t) { + // Never propagate to application code — SCA detection is observation-only + } + } + + private ScaReachabilityCallback() {} +} diff --git a/dd-java-agent/appsec/build.gradle b/dd-java-agent/appsec/build.gradle index b98f2422897..479ff79c457 100644 --- a/dd-java-agent/appsec/build.gradle +++ b/dd-java-agent/appsec/build.gradle @@ -21,6 +21,7 @@ dependencies { implementation libs.moshi compileOnly project(':dd-java-agent:agent-bootstrap') + compileOnly libs.bytebuddy // for net.bytebuddy.jar.asm.* used by ScaReachabilityTransformer testImplementation project(':dd-java-agent:agent-bootstrap') testImplementation libs.bytebuddy testImplementation project(':remote-config:remote-config-core') @@ -47,6 +48,92 @@ tasks.named("jar", Jar) { archiveClassifier = 'unbundled' } +// --------------------------------------------------------------------------- +// SCA Reachability: downloads GHSA enrichments and generates sca_cves.json +// --------------------------------------------------------------------------- + +def SCA_ENRICHMENTS_API = + 'https://api.github.com/repos/DataDog/sca-reachability-database/contents/enrichments' + +// Opens an authenticated connection to a GitHub URL with consistent headers and timeouts. +// Throws GradleException on non-200 response — no fallback by design. +// Set GITHUB_TOKEN to raise the unauthenticated rate limit (60 req/hr → 5000 req/hr). +def githubConnect = { String url, String token -> + def connection = (HttpURLConnection) new URL(url).openConnection() + connection.setRequestProperty('Accept', 'application/vnd.github+json') + connection.setRequestProperty('X-GitHub-Api-Version', '2022-11-28') + if (token) { + connection.setRequestProperty('Authorization', "Bearer ${token}") + } + connection.connectTimeout = 10_000 + connection.readTimeout = 30_000 + int code = connection.responseCode + if (code != 200) { + throw new GradleException( + "GitHub API returned HTTP ${code} for ${url}.\n" + + "Unauthenticated rate limit is 60 req/hr. Set GITHUB_TOKEN to raise it.") + } + connection +} + +// Fetches a GitHub URL and parses the response body as JSON. +def githubFetch = { String url, String token -> + def conn = githubConnect(url, token) + try { + new JsonSlurper().parse(conn.inputStream) + } finally { + conn.disconnect() + } +} + +// Fetches a GitHub URL and returns the raw response body as a String. +def githubFetchRaw = { String url, String token -> + def conn = githubConnect(url, token) + try { + conn.inputStream.text + } finally { + conn.disconnect() + } +} + +tasks.register('generateScaCvesJson') { + description = 'Downloads GHSA enrichments from sca-reachability-database and updates src/main/resources/sca_cves.json. ' + + 'Run with -PrefreshSca to force a refresh. ' + + 'sca_cves.json is committed to the repo so CI does not need network access to this private repo.' + group = 'build' + + // Output lives in src/main/resources so it is versioned and picked up by processResources + // without any extra wiring. CI builds use the committed copy; only maintainers who need + // to update the database run this task (with -PrefreshSca or when the file is absent). + def outputFile = file('src/main/resources/sca_cves.json') + + outputs.file(outputFile) + onlyIf { + project.hasProperty('refreshSca') || !outputFile.exists() + } + + doLast { + def token = System.getenv('GITHUB_TOKEN') + + logger.lifecycle('Fetching GHSA enrichment index from GitHub...') + def fileList = githubFetch(SCA_ENRICHMENTS_API, token) as List + def ghsaFiles = fileList.findAll { it.name?.endsWith('.json') && it.type == 'file' } + logger.lifecycle("Found ${ghsaFiles.size()} enrichment files") + + def entries = [] + ghsaFiles.each { fileInfo -> + def ghsaId = (fileInfo.name as String).replace('.json', '') + def rawContent = githubFetchRaw(fileInfo.download_url as String, token) + // Transformation is handled by GhsaEnrichmentParser (tested separately in buildSrc) + entries.addAll(datadog.gradle.sca.GhsaEnrichmentParser.INSTANCE.parse(ghsaId, rawContent)) + } + + outputFile.text = JsonOutput.toJson([version: 1, entries: entries]) + logger.lifecycle("sca_cves.json: ${entries.size()} entries from ${ghsaFiles.size()} GHSA files") + logger.lifecycle("Remember to commit src/main/resources/sca_cves.json after updating the database.") + } +} + tasks.named("processResources", ProcessResources) { doLast { fileTree(dir: outputs.files.asPath, includes: ['**/*.json']).each { diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/sca/ScaCveDatabase.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/sca/ScaCveDatabase.java new file mode 100644 index 00000000000..994e2b59421 --- /dev/null +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/sca/ScaCveDatabase.java @@ -0,0 +1,165 @@ +package com.datadog.appsec.sca; + +import com.squareup.moshi.Json; +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.Moshi; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Loads {@code sca_cves.json} from the classpath and builds the runtime index used by {@link + * ScaReachabilityTransformer}. + * + *

The primary index maps JVM internal class names (slashes) to the list of {@link ScaEntry} + * objects that reference them. The transformer does an O(1) lookup on every class load using this + * map. + */ +public final class ScaCveDatabase { + + private static final Logger log = LoggerFactory.getLogger(ScaCveDatabase.class); + private static final String RESOURCE_PATH = "/sca_cves.json"; + + private final Map> index; + + private ScaCveDatabase(Map> index) { + this.index = index; + } + + /** + * Loads and parses {@code sca_cves.json} from the classpath. + * + * @return a populated database, or an empty one if the resource is missing or malformed + */ + public static ScaCveDatabase load() { + InputStream stream = ScaCveDatabase.class.getResourceAsStream(RESOURCE_PATH); + if (stream == null) { + log.info( + "SCA Reachability: {} not found on classpath - no vulnerabilities will be tracked", + RESOURCE_PATH); + return new ScaCveDatabase(Collections.emptyMap()); + } + // "UTF-8" string literal - java.nio.* is forbidden during premain + try (InputStreamReader reader = new InputStreamReader(stream, "UTF-8")) { + return parse(reader); + } catch (Exception e) { + log.error( + "SCA Reachability: failed to parse {} - no vulnerabilities will be tracked", + RESOURCE_PATH, + e); + return new ScaCveDatabase(Collections.emptyMap()); + } + } + + static ScaCveDatabase parse(java.io.Reader reader) throws IOException { + Moshi moshi = new Moshi.Builder().build(); + JsonAdapter adapter = moshi.adapter(DatabaseJson.class); + + String content = readAll(reader); + DatabaseJson root = adapter.fromJson(content); + if (root == null || root.entries == null) { + return new ScaCveDatabase(Collections.emptyMap()); + } + + Map> index = new HashMap<>(); + int entryCount = 0; + + for (EntryJson e : root.entries) { + ScaEntry entry = toScaEntry(e); + if (entry == null) continue; + entryCount++; + // Index once per unique class name: an entry with multiple symbols for the same class + // (e.g. Yaml.load + Yaml.loadAll) must appear only once in the list, otherwise + // processClass iterates it twice and injects duplicate bytecode callbacks. + Set seen = new HashSet<>(); + for (ScaSymbol symbol : entry.symbols()) { + if (seen.add(symbol.className())) { + index.computeIfAbsent(symbol.className(), k -> new ArrayList<>()).add(entry); + } + } + } + + log.debug( + "SCA Reachability: loaded {} entries, {} unique class symbols", entryCount, index.size()); + return new ScaCveDatabase(Collections.unmodifiableMap(index)); + } + + @Nullable + private static ScaEntry toScaEntry(EntryJson e) { + if (e.vulnId == null || e.artifact == null || e.versionRanges == null || e.symbols == null) { + log.debug("SCA Reachability: skipping malformed entry: {}", e); + return null; + } + List symbols = new ArrayList<>(e.symbols.size()); + for (SymbolJson s : e.symbols) { + if (s.className == null) continue; + symbols.add(new ScaSymbol(s.className, s.method)); + } + if (symbols.isEmpty()) return null; + return new ScaEntry(e.vulnId, e.artifact, e.versionRanges, symbols); + } + + /** Returns the entries associated with the given JVM internal class name, or null if none. */ + public List entriesForClass(String internalClassName) { + return index.get(internalClassName); + } + + public boolean isEmpty() { + return index.isEmpty(); + } + + public int size() { + return index.size(); + } + + private static String readAll(java.io.Reader reader) throws IOException { + StringBuilder sb = new StringBuilder(); + char[] buf = new char[8192]; + int n; + while ((n = reader.read(buf)) != -1) { + sb.append(buf, 0, n); + } + return sb.toString(); + } + + // --------------------------------------------------------------------------- + // JSON DTOs - only used during parsing, never exposed outside this class + // --------------------------------------------------------------------------- + + static final class DatabaseJson { + int version; + @Nullable List entries; + } + + static final class EntryJson { + @Json(name = "vuln_id") + @Nullable + String vulnId; + + @Nullable String artifact; + + @Json(name = "version_ranges") + @Nullable + List versionRanges; + + @Nullable List symbols; + } + + static final class SymbolJson { + @Json(name = "class") + @Nullable + String className; + + @Nullable String method; + } +} diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/sca/ScaEntry.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/sca/ScaEntry.java new file mode 100644 index 00000000000..f1fc2382640 --- /dev/null +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/sca/ScaEntry.java @@ -0,0 +1,47 @@ +package com.datadog.appsec.sca; + +import java.util.Collections; +import java.util.List; + +/** One entry from sca_cves.json: a vulnerability affecting a specific Maven artifact. */ +public final class ScaEntry { + + private final String vulnId; + private final String artifact; + private final List versionRanges; + private final List symbols; + + public ScaEntry( + String vulnId, String artifact, List versionRanges, List symbols) { + this.vulnId = vulnId; + this.artifact = artifact; + this.versionRanges = Collections.unmodifiableList(versionRanges); + this.symbols = Collections.unmodifiableList(symbols); + } + + /** GHSA identifier, e.g. {@code "GHSA-645p-88qh-w398"}. */ + public String vulnId() { + return vulnId; + } + + /** Maven coordinate, e.g. {@code "com.fasterxml.jackson.core:jackson-databind"}. */ + public String artifact() { + return artifact; + } + + /** + * Version range strings from sca_cves.json, e.g. {@code ["< 2.6.7.3", ">= 2.7.0, < 2.7.9.5"]}. + */ + public List versionRanges() { + return versionRanges; + } + + public List symbols() { + return symbols; + } + + /** Returns true if the given version falls within any of this entry's version ranges. */ + public boolean isVersionVulnerable(String version) { + return VersionRangeParser.matchesAny(version, versionRanges); + } +} diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/sca/ScaReachabilitySystem.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/sca/ScaReachabilitySystem.java new file mode 100644 index 00000000000..ee8bb88b84d --- /dev/null +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/sca/ScaReachabilitySystem.java @@ -0,0 +1,149 @@ +package com.datadog.appsec.sca; + +import datadog.trace.api.telemetry.ScaReachabilityDependencyRegistry; +import datadog.trace.bootstrap.appsec.sca.ScaReachabilityCallback; +import datadog.trace.util.stacktrace.AbstractStackWalker; +import java.lang.instrument.Instrumentation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Entry point for the SCA Reachability subsystem. Called from {@code Agent.java} via reflection + * (same pattern as {@code AppSecSystem} and {@code IastSystem}). + * + *

Responsibilities: + * + *

    + *
  1. Load {@code sca_cves.json} from the classpath. + *
  2. Build the class-name index. + *
  3. Register {@link ScaReachabilityTransformer} with the JVM. + *
  4. Scan already-loaded classes so that libraries loaded before agent startup are detected. + *
+ */ +public final class ScaReachabilitySystem { + + private static final Logger log = LoggerFactory.getLogger(ScaReachabilitySystem.class); + + private ScaReachabilitySystem() {} + + /** + * Starts the SCA Reachability subsystem. + * + *

Called by reflection from {@code Agent.maybeStartScaReachability()} - the method signature + * must remain {@code public static void start(Instrumentation)}. + */ + public static void start(Instrumentation instrumentation) { + ScaCveDatabase database = ScaCveDatabase.load(); + if (database.isEmpty()) { + log.info("SCA Reachability: no vulnerability data found - subsystem inactive"); + return; + } + log.info("SCA Reachability: loaded {} vulnerable class symbols", database.size()); + + // Register the method-level callback. When called synchronously from the injected bytecode, + // the current thread stack still contains the full call chain: + // this handler lambda + // ScaReachabilityCallback.onMethodHit + // (dotClassName.methodName) + // [optional intermediate library frames] + // ← what we report + // Agent frames are filtered by AbstractStackWalker.isNotDatadogTraceStackElement; intermediate + // library frames are filtered by ScaStackExclusionTrie so we skip past them to client code. + ScaReachabilityCallback.register( + (vulnId, artifact, version, dotClassName, methodName, line) -> { + StackTraceElement callsite = findCallsite(dotClassName); + if (callsite != null) { + ScaReachabilityDependencyRegistry.INSTANCE.recordHit( + artifact, + version, + vulnId, + callsite.getClassName(), + callsite.getMethodName(), + callsite.getLineNumber()); + } else { + // Fallback: no application frame found - report the vulnerable symbol so the + // backend at least knows the method was reached. + ScaReachabilityDependencyRegistry.INSTANCE.recordHit( + artifact, version, vulnId, dotClassName, methodName, line); + } + }); + + ScaReachabilityTransformer transformer = + new ScaReachabilityTransformer(database, instrumentation); + + // canRetransform=true is required so that future method-level symbols (when added to the + // database) can trigger retransformation of already-loaded classes via retransformClasses(). + // For current class-level symbols, retransformation is not used - see + // checkAlreadyLoadedClasses. + instrumentation.addTransformer(transformer, true); + + transformer.checkAlreadyLoadedClasses(); + log.debug("SCA Reachability: startup scan complete"); + + // Register the periodic retransform callback so the telemetry heartbeat can retry + // method-level instrumentation for classes that could not be processed at load time. + ScaReachabilityDependencyRegistry.INSTANCE.setPeriodicWorkCallback( + transformer::performPendingRetransforms); + } + + /** + * Walks the current thread stack to find the first application frame that called the vulnerable + * method. Agent frames are skipped via {@link AbstractStackWalker#isNotDatadogTraceStackElement}; + * intermediate library frames (e.g. a wrapper around the vulnerable API) are skipped via {@link + * ScaStackExclusionTrie}. + * + *

The stack at call time is: + * + *

+   *   ScaReachabilitySystem handler lambda  (skip - agent)
+   *   ScaReachabilityCallback.onMethodHit   (skip - agent)
+   *   <vulnerableClass>.<method>           (skip - the instrumented library class)
+   *   [intermediate library frames]         (skip - trie-excluded)
+   *   <application callsite>               ← return this
+   * 
+ * + * @param vulnerableClass dot-notation FQN of the instrumented class + * @return first application callsite frame, or {@code null} if not found + */ + static StackTraceElement findCallsite(String vulnerableClass) { + return findCallsite(vulnerableClass, Thread.currentThread().getStackTrace()); + } + + /** + * Overload that accepts an explicit stack for testing. + * + * @see #findCallsite(String) + */ + static StackTraceElement findCallsite(String vulnerableClass, StackTraceElement[] stack) { + boolean pastVulnerableClass = false; + + for (StackTraceElement frame : stack) { + String cls = frame.getClassName(); + + // Skip agent frames (datadog.trace.*, com.datadog.appsec.*, etc.) + if (!AbstractStackWalker.isNotDatadogTraceStackElement(frame)) { + continue; + } + + if (!pastVulnerableClass) { + if (cls.equals(vulnerableClass)) { + pastVulnerableClass = true; + } + continue; + } + + // Skip remaining frames from the vulnerable class itself + if (cls.equals(vulnerableClass)) { + continue; + } + + // Skip intermediate library frames so we report client code, not a wrapper library + if (ScaStackExclusionTrie.apply(cls) >= 1) { + continue; + } + + return frame; + } + return null; + } +} diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/sca/ScaReachabilityTransformer.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/sca/ScaReachabilityTransformer.java new file mode 100644 index 00000000000..8df1513fe28 --- /dev/null +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/sca/ScaReachabilityTransformer.java @@ -0,0 +1,623 @@ +package com.datadog.appsec.sca; + +import datadog.telemetry.dependency.Dependency; +import datadog.telemetry.dependency.DependencyResolver; +import datadog.trace.api.telemetry.ScaReachabilityDependencyRegistry; +import datadog.trace.api.telemetry.ScaReachabilityHit; +import datadog.trace.bootstrap.appsec.sca.ScaReachabilityCallback; +import java.io.File; +import java.lang.instrument.ClassFileTransformer; +import java.lang.instrument.Instrumentation; +import java.net.URI; +import java.net.URL; +import java.net.URLClassLoader; +import java.security.CodeSource; +import java.security.ProtectionDomain; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.regex.Pattern; +import net.bytebuddy.jar.asm.ClassReader; +import net.bytebuddy.jar.asm.ClassVisitor; +import net.bytebuddy.jar.asm.ClassWriter; +import net.bytebuddy.jar.asm.Label; +import net.bytebuddy.jar.asm.MethodVisitor; +import net.bytebuddy.jar.asm.Opcodes; +import net.bytebuddy.utility.OpenedClassReader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Observation-only {@link ClassFileTransformer} that detects when classes from vulnerable libraries + * are loaded and reports reachability hits via {@link ScaReachabilityDependencyRegistry}. + * + *

Design principles (see APPSEC-62260): + * + *

    + *
  • Always returns {@code null} - never modifies bytecode for class-level symbols. + *
  • Never throws - any error in {@link #transform} is caught silently to avoid breaking class + * loading. + *
  • All shared state uses concurrent collections - {@link #transform} is called from multiple + * class-loading threads simultaneously. + *
  • Version resolution is cached per JAR URL - each JAR is read at most once. + *
  • Each (vulnId, artifact, symbolName) tuple is reported at most once - RFC requires a single + * occurrence. Class-level dedup lives in {@link #reportedHits}; method-level dedup lives in + * {@code ScaReachabilityCallback.reported} (bootstrap-side, persists across retransforms). + *
+ */ +public final class ScaReachabilityTransformer implements ClassFileTransformer { + + private static final Logger log = LoggerFactory.getLogger(ScaReachabilityTransformer.class); + private static final Pattern PATH_SEPARATOR = Pattern.compile(Pattern.quote(File.pathSeparator)); + + private final ScaCveDatabase database; + private final Instrumentation instrumentation; + + /** + * Cache: JAR URI → resolved dependencies. URI is used instead of URL to avoid DNS lookups in + * equals/hashCode (DMI_COLLECTION_OF_URLS). Only non-empty results are cached to allow retries. + */ + private final ConcurrentHashMap> jarCache = new ConcurrentHashMap<>(); + + /** + * Cache: artifact name → classpath-resolved version. Used when the class's own JAR does not + * contain the vulnerable artifact (e.g., Spring Boot starters whose watched classes live in + * transitive dependency JARs). Only non-null results are cached; null means "not yet found" and + * will be retried on the next periodic retransform. + */ + private final ConcurrentHashMap classpathArtifactCache = + new ConcurrentHashMap<>(); + + /** Deduplication set: "vulnId|artifact|symbol" tuples already reported. */ + private final Set reportedHits = ConcurrentHashMap.newKeySet(); + + /** + * Classes whose bytecode needs (re)transformation for method-level symbol injection: + * + *
    + *
  • Classes already loaded at startup before this transformer was registered. + *
  • Classes where JAR version resolution returned no results at load time and needs a retry. + *
+ * + * Drained and processed by {@link #performPendingRetransforms()} on each telemetry heartbeat. + */ + // package-private for testing + final ConcurrentLinkedQueue> pendingRetransform = new ConcurrentLinkedQueue<>(); + + /** Class names (internal format) queued for deferred retransformation by name lookup. */ + private final Set pendingRetransformNames = ConcurrentHashMap.newKeySet(); + + public ScaReachabilityTransformer(ScaCveDatabase database, Instrumentation instrumentation) { + this.database = database; + this.instrumentation = instrumentation; + } + + // --------------------------------------------------------------------------- + // ClassFileTransformer + // --------------------------------------------------------------------------- + + @Override + public byte[] transform( + ClassLoader loader, + String className, + Class classBeingRedefined, + ProtectionDomain protectionDomain, + byte[] classfileBuffer) { + try { + // Filter array types (e.g. "[Ljava/sql/PreparedStatement;"). + if (className == null || className.charAt(0) == '[') { + return null; + } + + // JDK/bootstrap classes (protectionDomain == null) are skipped - they are loaded regardless + // of which library is present and are not reliable reachability indicators. + if (protectionDomain == null) { + return null; + } + + List entries = database.entriesForClass(className); + if (entries == null || entries.isEmpty()) { + return null; + } + + CodeSource codeSource = protectionDomain.getCodeSource(); + if (codeSource == null) { + return null; // runtime-generated class (dynamic proxy, lambda, etc.) + } + URL location = codeSource.getLocation(); + if (location == null) { + return null; + } + + return processClass(className, location, entries, classfileBuffer); + } catch (Throwable t) { + // Never propagate from transform() - it would break the class being loaded. + log.debug("SCA Reachability: error processing class {}", className, t); + } + return null; + } + + /** + * Handles both class-level and method-level symbols for a single class load event. + * + *
    + *
  • Class-level ({@code symbol.method() == null}): reports a hit immediately via {@link + * ScaReachabilityDependencyRegistry} with symbol {@link + * ScaReachabilityHit#CLASS_LEVEL_SYMBOL}. + *
  • Method-level ({@code symbol.method() != null}): injects a static callback into the method + * bytecode via ASM. The callback is invoked the first time the method is called and reports + * via {@link ScaReachabilityCallback}. Returns modified bytecode; {@code null} if only + * class-level symbols were present. + *
+ */ + private byte[] processClass( + String className, URL jarUrl, List entries, byte[] classfileBuffer) { + List classJarDeps = resolveDependencies(jarUrl); + + // Collect method-level callbacks to inject, keyed by method name + Map> methodCallbacks = new HashMap<>(); + boolean hasUnresolvedMethodLevelSymbols = false; + String dotClassName = className.replace('/', '.'); + + for (ScaEntry entry : entries) { + // Resolve version: first check the class's own JAR, then fall back to a full classpath + // scan. The fallback handles cases where the vulnerable artifact is an aggregator/starter + // POM whose watched classes actually live in a transitive dependency JAR (e.g., + // spring-boot-starter-web watches @Controller, but @Controller is in spring-context.jar). + String version = resolveVersionForArtifact(entry.artifact(), classJarDeps); + if (version == null) { + // Version not yet resolvable - check lazily (only here) whether this entry has + // method-level symbols, to decide if a periodic retry should be scheduled. + // Doing this check only when version==null avoids the stream allocation on the + // common path where the version resolves successfully. + if (entry.symbols().stream() + .anyMatch(s -> s.className().equals(className) && !s.isClassLevel())) { + hasUnresolvedMethodLevelSymbols = true; + } + continue; + } + + if (!entry.isVersionVulnerable(version)) { + continue; + } + + // Report class-level hit immediately; register method-level CVEs and collect for ASM + // injection. + reportClassLevelHitIfPresent(entry, version, className); + for (ScaSymbol symbol : entry.symbols()) { + if (!symbol.className().equals(className) || symbol.isClassLevel()) { + continue; + } + // Register the CVE now (at class load time) with reached=[] so the next heartbeat + // signals the backend that SCA is monitoring this CVE. The callsite will be added + // later when the method is actually called (via ScaReachabilityCallback). + ScaReachabilityDependencyRegistry.INSTANCE.registerCve( + entry.artifact(), version, entry.vulnId()); + methodCallbacks + .computeIfAbsent(symbol.method(), k -> new ArrayList<>()) + .add( + new MethodCallbackSpec( + entry.vulnId(), entry.artifact(), version, dotClassName, symbol.method())); + } + } + + if (hasUnresolvedMethodLevelSymbols) { + // Schedule retransformation for a later attempt. In transform(), classBeingRedefined is + // null (first class load), so we don't have a Class handle to put directly into + // pendingRetransform. Instead we queue the internal class name; performPendingRetransforms() + // will resolve it back to a Class via instrumentation.getAllLoadedClasses() and + // retransform. + pendingRetransformNames.add(className); + } + + if (methodCallbacks.isEmpty()) { + return null; + } + return injectMethodCallbacks(classfileBuffer, methodCallbacks); + } + + // --------------------------------------------------------------------------- + // Startup scan for already-loaded classes + // --------------------------------------------------------------------------- + + /** + * Checks classes already loaded before this transformer was registered. + * + *

Only processes 3rd-party classes (non-null {@link ProtectionDomain} with a code source). JDK + * classes are skipped: they are always loaded regardless of which library is in the classpath and + * would produce false positives if used as reachability proxies. See APPSEC-62260. + */ + public void checkAlreadyLoadedClasses() { + for (Class clazz : instrumentation.getAllLoadedClasses()) { + String internalName = clazz.getName().replace('.', '/'); + if (internalName.charAt(0) == '[') { + continue; + } + List entries = database.entriesForClass(internalName); + if (entries == null || entries.isEmpty()) { + continue; + } + + ProtectionDomain pd = clazz.getProtectionDomain(); + URL location = locationOf(pd); + if (location == null) { + // JDK/bootstrap class (no code source): skip - false positive, see class Javadoc. + continue; + } + try { + processClass(internalName, location, entries); + // If any entry for this class has method-level symbols, the class needs retransformation + // so the bytecode callback can be injected. We can't modify bytecode here (we're just + // scanning) - retransformation is deferred to performPendingRetransforms(). + boolean needsMethodLevelInstrumentation = + entries.stream() + .flatMap(e -> e.symbols().stream()) + .anyMatch(s -> s.className().equals(internalName) && !s.isClassLevel()); + if (needsMethodLevelInstrumentation) { + pendingRetransform.add(clazz); + } + } catch (Exception e) { + // Never abort the scan - a failure on one class must not skip the remaining ones. + log.debug("SCA Reachability: error scanning already-loaded class {}", internalName, e); + } + } + } + + /** + * Retransforms classes that could not be instrumented for method-level detection earlier: + * + *

    + *
  1. Classes already loaded before the transformer was registered (populated in {@link + * #checkAlreadyLoadedClasses}). + *
  2. Classes whose JAR version could not be resolved at load time (populated in {@link + * #processClass} when {@code DependencyResolver} returns an empty list - the version may be + * available by the time this periodic callback fires). + *
+ * + *

Called by {@code ScaReachabilityPeriodicAction} on each telemetry heartbeat via the {@code + * periodicWorkCallback} registered in {@link ScaReachabilityDependencyRegistry}. + */ + public void performPendingRetransforms() { + if (instrumentation == null) { + return; // no-op when instrumentation is unavailable (e.g. in unit tests) + } + // Drain the direct Class queue (from checkAlreadyLoadedClasses) + List> toRetransform = new ArrayList<>(); + Class clazz; + while ((clazz = pendingRetransform.poll()) != null) { + toRetransform.add(clazz); + } + + // Resolve any classes queued by name (from processClass timing failures) + if (!pendingRetransformNames.isEmpty()) { + for (Class loaded : instrumentation.getAllLoadedClasses()) { + String name = loaded.getName().replace('.', '/'); + if (pendingRetransformNames.remove(name)) { + toRetransform.add(loaded); + } + } + } + + if (toRetransform.isEmpty()) { + return; + } + + try { + instrumentation.retransformClasses(toRetransform.toArray(new Class[0])); + log.debug( + "SCA Reachability: retransformed {} class(es) for method-level detection", + toRetransform.size()); + } catch (Exception e) { + log.debug("SCA Reachability: retransformClasses failed", e); + // Re-queue on failure so the next heartbeat can retry + pendingRetransform.addAll(toRetransform); + } + } + + // --------------------------------------------------------------------------- + // Internal matching logic + // --------------------------------------------------------------------------- + + private void processClass(String internalClassName, URL jarUrl, List entries) { + List classJarDeps = resolveDependencies(jarUrl); + for (ScaEntry entry : entries) { + String version = resolveVersionForArtifact(entry.artifact(), classJarDeps); + if (version == null || !entry.isVersionVulnerable(version)) { + continue; + } + // Only class-level symbols are reported at class load time. + // Method-level symbols are handled by processClass() via ASM injection. + reportClassLevelHitIfPresent(entry, version, internalClassName); + } + } + + /** + * Reports a class-level reachability hit for the first class-level symbol in {@code entry} that + * matches {@code internalClassName}. No-op if no matching class-level symbol exists. + */ + private void reportClassLevelHitIfPresent( + ScaEntry entry, String version, String internalClassName) { + for (ScaSymbol symbol : entry.symbols()) { + if (symbol.className().equals(internalClassName) && symbol.isClassLevel()) { + reportHit(entry, version, internalClassName, ScaReachabilityHit.CLASS_LEVEL_SYMBOL, 1); + return; // one hit per entry is sufficient + } + } + } + + /** + * Resolves the version of {@code artifactName} using a two-step strategy: + * + *

    + *
  1. Check the dependencies resolved from the class's own JAR ({@code classJarDeps}). This + * covers the common case where the class and its artifact live in the same JAR. + *
  2. If not found, fall back to a full classpath scan via {@link + * #findArtifactVersionInClasspath}. This handles aggregator/starter POM artifacts (e.g., + * {@code spring-boot-starter-web}) whose watched classes live in transitive dependency JARs + * rather than in the starter JAR itself. Results of successful scans are cached. + *
+ * + * @return the resolved version string, or {@code null} if the artifact cannot be found + */ + // package-private for testing + String resolveVersionForArtifact(String artifactName, List classJarDeps) { + for (Dependency dep : classJarDeps) { + if (artifactName.equals(dep.name)) { + return dep.version; + } + } + // Classpath fallback: check cache first, then scan. + String cached = classpathArtifactCache.get(artifactName); + if (cached != null) { + return cached; + } + String version = findArtifactVersionInClasspath(artifactName); + if (version != null) { + classpathArtifactCache.put(artifactName, version); // only cache hits; misses are retried + } + return version; + } + + // --------------------------------------------------------------------------- + // Method-level bytecode injection (ASM) + // --------------------------------------------------------------------------- + + private static final String CALLBACK_OWNER = + "datadog/trace/bootstrap/appsec/sca/ScaReachabilityCallback"; + private static final String CALLBACK_METHOD = "onMethodHit"; + private static final String CALLBACK_DESC = + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;I)V"; + + // package-private for testing + byte[] injectMethodCallbacks( + byte[] classfileBuffer, Map> callbacksPerMethod) { + ClassReader cr = new ClassReader(classfileBuffer); + ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_MAXS); + cr.accept(new MethodCallbackInjector(cw, callbacksPerMethod), ClassReader.EXPAND_FRAMES); + return cw.toByteArray(); + } + + private class MethodCallbackInjector extends ClassVisitor { + private final Map> callbacksPerMethod; + + MethodCallbackInjector( + ClassVisitor cv, Map> callbacksPerMethod) { + super(OpenedClassReader.ASM_API, cv); + this.callbacksPerMethod = callbacksPerMethod; + } + + @Override + public MethodVisitor visitMethod( + int access, String name, String descriptor, String signature, String[] exceptions) { + MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions); + List specs = callbacksPerMethod.get(name); + if (specs == null || specs.isEmpty()) { + return mv; + } + return new MethodEntryInjector(mv, specs); + } + } + + private class MethodEntryInjector extends MethodVisitor { + private final List specs; + private boolean injected = false; + + MethodEntryInjector(MethodVisitor mv, List specs) { + super(OpenedClassReader.ASM_API, mv); + this.specs = specs; + } + + @Override + public void visitLineNumber(int line, Label start) { + if (!injected) { + injected = true; + injectCallbacks(line); + } + super.visitLineNumber(line, start); + } + + @Override + public void visitInsn(int opcode) { + ensureInjected(); + super.visitInsn(opcode); + } + + @Override + public void visitVarInsn(int opcode, int varIndex) { + ensureInjected(); + super.visitVarInsn(opcode, varIndex); + } + + @Override + public void visitMethodInsn( + int opcode, String owner, String name, String descriptor, boolean isInterface) { + ensureInjected(); + super.visitMethodInsn(opcode, owner, name, descriptor, isInterface); + } + + @Override + public void visitFieldInsn(int opcode, String owner, String name, String descriptor) { + ensureInjected(); + super.visitFieldInsn(opcode, owner, name, descriptor); + } + + private void ensureInjected() { + if (!injected) { + injected = true; + injectCallbacks(1); // no debug info - use line 1 as placeholder + } + } + + private void injectCallbacks(int line) { + // No dedup check here: retransformClasses() always starts from the original class bytes, + // so the callback must be re-injected on every transformation pass. Deduplication of + // actual runtime reports is handled by ScaReachabilityCallback.reported (bootstrap-side), + // which persists across retransformations and prevents duplicate hits regardless of how + // many times the class is retransformed. + for (MethodCallbackSpec spec : specs) { + mv.visitLdcInsn(spec.vulnId); + mv.visitLdcInsn(spec.artifact); + mv.visitLdcInsn(spec.version); + mv.visitLdcInsn(spec.dotClassName); + mv.visitLdcInsn(spec.methodName); + mv.visitIntInsn(Opcodes.SIPUSH, line); + mv.visitMethodInsn( + Opcodes.INVOKESTATIC, CALLBACK_OWNER, CALLBACK_METHOD, CALLBACK_DESC, false); + } + } + } + + /** Immutable spec for a single method-level callback to inject. */ + static final class MethodCallbackSpec { + final String vulnId; + final String artifact; + final String version; + final String dotClassName; + final String methodName; + + MethodCallbackSpec( + String vulnId, String artifact, String version, String dotClassName, String methodName) { + this.vulnId = vulnId; + this.artifact = artifact; + this.version = version; + this.dotClassName = dotClassName; + this.methodName = methodName; + } + } + + // package-private for testing + String findArtifactVersionInClasspath(String artifactName) { + // Use URI (not URL) to avoid DNS lookups in equals/hashCode (DMI_COLLECTION_OF_URLS) + Set scanned = new HashSet<>(); + + // Walk URLClassLoader chain (covers Java 8 system classloader and custom classloaders on 9+) + ClassLoader cl = Thread.currentThread().getContextClassLoader(); + while (cl != null) { + if (cl instanceof URLClassLoader) { + for (URL url : ((URLClassLoader) cl).getURLs()) { + try { + if (scanned.add(url.toURI())) { + String version = findArtifactInUrl(artifactName, url); + if (version != null) { + return version; + } + } + } catch (Exception e) { + log.debug("SCA Reachability: could not scan classloader URL {}", url, e); + } + } + } + cl = cl.getParent(); + } + + // Fallback for Java 9+: system classloader (jdk.internal.loader.ClassLoaders$AppClassLoader) + // no longer extends URLClassLoader, so the loop above misses the main classpath. The + // java.class.path system property always contains the classpath entries in this case. + String classpath = System.getProperty("java.class.path", ""); + for (String entry : PATH_SEPARATOR.split(classpath)) { + if (entry.isEmpty()) { + continue; + } + try { + URI uri = new File(entry).toURI(); + if (scanned.add(uri)) { + String version = findArtifactInUrl(artifactName, uri.toURL()); + if (version != null) { + return version; + } + } + } catch (Exception e) { + log.debug("SCA Reachability: could not scan classpath entry {}", entry, e); + } + } + return null; + } + + private String findArtifactInUrl(String artifactName, URL url) { + for (Dependency dep : resolveDependencies(url)) { + if (artifactName.equals(dep.name) && dep.version != null) { + return dep.version; + } + } + return null; + } + + private void reportHit( + ScaEntry entry, String version, String internalClassName, String symbolName, int line) { + // Include version: two artifact versions loaded in separate classloaders must produce + // independent class-level hits. + String dedupKey = entry.vulnId() + "|" + entry.artifact() + "|" + version + "|" + symbolName; + if (!reportedHits.add(dedupKey)) { + return; + } + String dotClassName = internalClassName.replace('/', '.'); + log.debug( + "SCA Reachability: {} reached in {}:{} via {}#{}", + entry.vulnId(), + entry.artifact(), + version, + dotClassName, + symbolName); + // Register with callsite in the stateful registry. For class-level, dotClassName and + // symbolName ("") are used as the callsite - there is no separate "caller" frame. + ScaReachabilityDependencyRegistry.INSTANCE.recordHit( + entry.artifact(), version, entry.vulnId(), dotClassName, symbolName, line); + } + + private List resolveDependencies(URL url) { + try { + URI uri = url.toURI(); + List cached = jarCache.get(uri); + if (cached != null) { + return cached; + } + List resolved = DependencyResolver.resolve(uri); + if (resolved == null) { + resolved = Collections.emptyList(); + } + // Only cache non-empty results: empty means the JAR had no pom.properties, which may be + // a transient failure. Not caching allows the periodic retransform to retry successfully. + if (!resolved.isEmpty()) { + List existing = jarCache.putIfAbsent(uri, resolved); + return existing != null ? existing : resolved; + } + return resolved; + } catch (Exception e) { + log.debug("SCA Reachability: could not resolve {}", url, e); + return Collections.emptyList(); + } + } + + private static URL locationOf(ProtectionDomain pd) { + if (pd == null) return null; + CodeSource cs = pd.getCodeSource(); + if (cs == null) return null; + return cs.getLocation(); + } +} diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/sca/ScaSymbol.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/sca/ScaSymbol.java new file mode 100644 index 00000000000..537a208ae6b --- /dev/null +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/sca/ScaSymbol.java @@ -0,0 +1,33 @@ +package com.datadog.appsec.sca; + +import javax.annotation.Nullable; + +/** A single symbol from sca_cves.json: a class (and optionally a method) to watch for. */ +public final class ScaSymbol { + + private final String className; // JVM internal format: "com/foo/Bar" + @Nullable private final String method; // null = class-level; non-null = future method-level + + public ScaSymbol(String className, @Nullable String method) { + this.className = className; + this.method = method; + } + + /** JVM internal class name with slashes, e.g. {@code "com/foo/Bar"}. */ + public String className() { + return className; + } + + /** + * Method name for method-level tracking, or null for class-level. Currently always null since the + * database only has class-level symbols. + */ + @Nullable + public String method() { + return method; + } + + public boolean isClassLevel() { + return method == null; + } +} diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/sca/VersionRangeParser.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/sca/VersionRangeParser.java new file mode 100644 index 00000000000..f95e11d6064 --- /dev/null +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/sca/VersionRangeParser.java @@ -0,0 +1,85 @@ +package com.datadog.appsec.sca; + +import datadog.trace.util.ComparableVersion; +import java.util.List; +import java.util.regex.Pattern; + +/** + * Checks whether a version string matches GHSA version range expressions. + * + *

Range string format (per sca-reachability-database enrichments): + * + *

    + *
  • {@code "< 2.6.7.3"} - strictly less than + *
  • {@code "<= 2.6.7.3"} - less than or equal + *
  • {@code "> 2.6.7.3"} - strictly greater than + *
  • {@code ">= 2.6.7.3"} - greater than or equal + *
  • {@code "= 2.6.7.3"} - exact match + *
  • {@code ">= 2.7.0, < 2.7.9.5"} - AND of two conditions (comma-separated) + *
+ * + *

Multiple range strings in a list are evaluated as OR: a version is affected if it matches ANY + * of the ranges. + * + *

Version comparison uses {@link ComparableVersion} (Maven 3.9.9 semantics), which correctly + * handles qualifiers such as {@code .RELEASE}, {@code .GA}, {@code .FINAL}, and 4-part versions. + */ +public final class VersionRangeParser { + + private static final Pattern COMMA = Pattern.compile(","); + + private VersionRangeParser() {} + + /** + * Returns true if {@code version} matches at least one of the provided range strings. + * + * @param version the version to test (e.g. {@code "2.8.5"} or {@code "5.2.19.RELEASE"}) + * @param versionRanges list of range strings from sca_cves.json + * @return true if the version falls within any range + */ + public static boolean matchesAny(String version, List versionRanges) { + if (version == null || version.isEmpty() || versionRanges == null || versionRanges.isEmpty()) { + return false; + } + ComparableVersion v = new ComparableVersion(version); + for (String range : versionRanges) { + if (matchesRange(v, range)) { + return true; + } + } + return false; + } + + /** + * Returns true if {@code version} matches a single range string. Multiple conditions within a + * single string (comma-separated) are evaluated as AND. + */ + static boolean matchesRange(ComparableVersion version, String versionRange) { + String[] conditions = COMMA.split(versionRange); + for (String condition : conditions) { + if (!matchesCondition(version, condition.trim())) { + return false; + } + } + return true; + } + + private static boolean matchesCondition(ComparableVersion version, String condition) { + if (condition.startsWith(">=")) { + return version.compareTo(new ComparableVersion(condition.substring(2).trim())) >= 0; + } + if (condition.startsWith("<=")) { + return version.compareTo(new ComparableVersion(condition.substring(2).trim())) <= 0; + } + if (condition.startsWith(">")) { + return version.compareTo(new ComparableVersion(condition.substring(1).trim())) > 0; + } + if (condition.startsWith("<")) { + return version.compareTo(new ComparableVersion(condition.substring(1).trim())) < 0; + } + if (condition.startsWith("=")) { + return version.compareTo(new ComparableVersion(condition.substring(1).trim())) == 0; + } + throw new IllegalArgumentException("Unrecognised version condition: '" + condition + "'"); + } +} diff --git a/dd-java-agent/appsec/src/main/resources/com/datadog/appsec/sca/sca_stack_exclusion.trie b/dd-java-agent/appsec/src/main/resources/com/datadog/appsec/sca/sca_stack_exclusion.trie new file mode 100644 index 00000000000..3cd56444b2c --- /dev/null +++ b/dd-java-agent/appsec/src/main/resources/com/datadog/appsec/sca/sca_stack_exclusion.trie @@ -0,0 +1,289 @@ +# Stack frame exclusion trie for SCA Reachability callsite detection. +# Generated class: com.datadog.appsec.sca.ScaStackExclusionTrie +# +# 1 = known library/framework frame — skip when walking the call stack +# 0 = application code within an otherwise-excluded package — allow as callsite + +# -------- JDK -------- +1 com.azul.* +1 com.sun.* +1 java.* +1 javafx.* +1 javax.* +1 jdk.* +1 openj9.* +1 org.omg.* +1 org.w3c.* +1 org.xml.* +1 $Proxy* +1 sun.* + +# -------- Groovy -------- +1 groovy* +1 org.groovy.* + +# -------- Scala -------- +1 scala.* + +# -------- Kotlin -------- +1 kotlin.* + +# -------- DataDog -------- +1 com.datadog.* +0 com.datadog.demo.* +1 datadog.* +0 datadog.smoketest.* +1 com.timgroup.statsd.* + +# -------- Agents ----------- +1 com.instana.* + +# -------- Libraries -------- +1 aj.org.objectweb.* +1 akka.* +1 antlr.* +1 brave.* +1 bsh.* +1 ch.qos.* +1 coldfusion.* +1 com.adobe.* +1 com.amazonaws.* +1 com.apple.java.* +1 com.apple.crypto.* +1 com.arjuna.* +1 com.atlassian.jira.* +1 com.azure.* +1 com.bea.* +1 com.blogspot.* +1 com.certicom.* +1 com.codahale.* +1 com.cognos.* +1 com.couchbase.* +1 com.ctc.* +1 com.cystaldecisions.* +1 com.datastax.* +1 com.datical.liquibase.* +1 com.denodo.vdb.jdbcdriver.* +1 com.dwr.* +1 com.ecwid.consul.* +1 org.egothor.* +1 com.esotericsoftware.kryo.* +1 com.fasterxml.* +1 com.filenet.* +1 com.github.benmanes.caffeine.* +1 com.github.jknack.handlebars.* +1 com.google.* +1 com.googlecode.* +1 com.hazelcast.* +1 com.hdivsecurity.* +1 com.ibm.* +0 com.ibm._jsp.* +1 com.intellij.* +1 com.itext.* +1 com.itextpdf.* +1 com.jcraft.* +1 com.jhlabs.* +1 com.jprofiler.* +1 com.launchdarkly.* +1 com.liferay.* +1 com.lowagie.* +1 com.mchange.* +1 com.microsoft.* +1 com.mongodb.* +1 com.mysql.* +1 com.neo4j.* +1 com.netscape.* +1 com.ning.* +1 com.novell.* +1 com.ocpsoft.* +1 com.octetstring.* +1 com.opencsv.* +1 com.opensymphony.* +1 com.octo.captcha.* +1 com.oracle.* +1 com.rabbitmq.* +1 com.rsa.* +1 com.safelayer.* +1 com.solarmetric.* +1 com.squareup.* +1 com.stripe.* +1 com.tangosol.* +1 com.thoughtworks.* +1 com.typesafe.* +1 com.zaxxer.* +1 com.facebook.presto.* +1 commonj.work.* +1 cryptix.* +1 dev.failsafe.* +1 edu.emory.* +1 edu.oswego.* +1 freemarker.* +1 gnu.* +1 graphql.* +1 ibm.security.* +1 io.confluent.* +1 io.dropwizard.* +1 io.fabric8.* +1 io.github.lukehutch.fastclasspathscanner.* +1 io.grpc.* +1 io.leangen.geantyref.* +1 io.jsonwebtoken.* +1 io.ktor.* +1 io.prometheus.* +1 io.quarkus.* +1 io.micrometer.* +1 io.micronaut.* +1 io.netty.* +1 io.r2dbc.* +1 io.reactivex.* +1 io.smallrye.* +1 io.springfox.* +1 io.swagger.* +1 io.trino.* +1 io.undertow.* +1 io.vertx.* +0 io.vertx.demo.* +1 jakarta.* +1 jasperreports.* +1 javassist.* +1 javolution.* +1 jersey.repackaged.* +1 jnr.ffi.* +1 jnr.posix.* +1 joptsimple.* +1 jregex.* +1 jrun.* +1 jrunx.* +1 jva_cup.* +1 liquibase.* +1 kodo.* +1 kong.unirest.* +1 macromedia.* +1 Microsoft.* +1 nanoxml.* +1 nano.xml.* +1 net.bytebuddy.* +1 net.jcip.* +1 net.jodah.failsafe.* +1 net.jpountz.xxhash.* +1 net.logstash.* +1 net.nicholaswilliams.* +1 net.sf.beanlib.* +1 net.sf.cglib.* +1 net.sf.ehcache.* +1 net.sf.jasperreports.* +1 net.sf.jsqlparser.* +1 net.sf.saxon.* +1 net.sourceforge.argparse4j.* +1 net.sourceforge.barbecue.* +1 netscape.* +1 nu.xom.* +1 okhttp3.* +1 okio.* +1 ognl.* +1 oracle.jdbc.* +1 oracle.sql.* +1 org.ajax4jsf.* +1 org.aopalliance.* +1 org.antlr.* +1 org.apache.* +0 org.apache.http.client.methods.* +0 org.apache.hc.client5.http.classic.methods.* +0 org.apache.jsp.* +1 org.apiguardian.* +1 org.aspectj.* +1 org.attoparser.* +1 org.bouncycastle.* +1 org.bson.* +1 org.clojure.* +1 org.codehaus.* +1 org.crac.* +1 org.cyberneko.* +1 org.directwebremoting.* +1 org.dom4j.* +1 org.eclipse.* +1 org.ehcache.* +1 org.flywaydb.* +1 org.gjt.mm.mysql.* +1 org.glassfish.* +1 org.graalvm.* +1 org.grails.orm.hibernate.cfg.GrailsDomainBinder +1 org.h2.* +1 org.hamcrest.* +1 org.hdiv.* +1 org.hibernate.* +1 org.hsqldb.* +1 org.htmlparser.* +1 org.ietf.* +1 org.infinispan.* +1 org.jaxen.* +1 org.jboss.* +1 org.jcp.xml.* +1 org.jdbcdslog +1 org.jdbi.* +1 org.jdom.* +1 org.jetbrains.* +1 org.jfree.* +1 org.jnp.* +1 org.joda.* +1 org.jooq.* +1 org.jruby.* +1 org.json.* +1 org.jsoup.* +1 org.jvnet.hk2.* +1 org.keycloak.* +1 org.liquibase.* +1 org.mariadb.* +1 org.mockito.* +1 org.modelmapper.* +1 org.mongodb.* +1 org.mortbay.* +1 org.objectweb.* +1 org.openid4java.* +1 org.openjdk.jol.vm.* +1 io.opentelemetry.* +1 org.openxmlformats.* +1 org.osgi.* +1 org.owasp.* +0 org.owasp.benchmark.* +0 org.owasp.webgoat.* +1 org.picketbox.* +1 org.picketlink.* +1 org.postgresql.* +1 org.primefaces.* +1 org.python.* +1 org.quartz.* +1 org.reactivestreams.* +1 org.relaxng.* +1 org.renjin.* +1 org.richfaces.* +1 org.seasar.* +1 org.slf4j.* +1 org.springdoc.* +1 org.springframework.* +0 org.springframework.samples.* +1 org.sqlite.* +1 org.stringtemplate.* +1 org.synchronoss.* +1 org.terracotta.* +1 org.testcontainers.* +1 org.thymeleaf.* +1 org.tigris.gef.* +1 org.wildfly.* +1 org.xerial.snappy.* +1 org.xnio.* +1 org.yaml.* +1 org.yecht.* +1 reactor.* +1 sbt.* +1 schemacom_bea_xml.* +1 serp.* +1 sl.org.objectweb.asm.* +1 software.amazon.awssdk.* +1 spark.* +1 springfox.* +1 System.* +1 tech.jhipster.* +1 uk.ltd.getahead.* +1 weblogic.* +1 zipkin2.* diff --git a/dd-java-agent/appsec/src/main/resources/sca_cves.json b/dd-java-agent/appsec/src/main/resources/sca_cves.json new file mode 100644 index 00000000000..60d9777c507 --- /dev/null +++ b/dd-java-agent/appsec/src/main/resources/sca_cves.json @@ -0,0 +1 @@ +{"version":1,"entries":[{"vuln_id":"GHSA-24rp-q3w6-vc56","artifact":"org.postgresql:postgresql","version_ranges":[">= 42.2.0, < 42.2.28",">= 42.3.0, < 42.3.9",">= 42.4.0, < 42.4.4",">= 42.5.0, < 42.5.5",">= 42.6.0, < 42.6.1",">= 42.7.0, < 42.7.2"],"symbols":[{"class":"org/postgresql/ds/PGSimpleDataSource","method":null},{"class":"org/postgresql/ds/PGPoolingDataSource","method":null},{"class":"org/postgresql/ds/PGConnectionPoolDataSource","method":null}]},{"vuln_id":"GHSA-2p3x-qw9c-25hh","artifact":"com.thoughtworks.xstream:xstream","version_ranges":["< 1.4.16"],"symbols":[{"class":"com/thoughtworks/xstream/XStream","method":"fromXML"}]},{"vuln_id":"GHSA-2q8x-2p7f-574v","artifact":"com.thoughtworks.xstream:xstream","version_ranges":["< 1.4.18"],"symbols":[{"class":"com/thoughtworks/xstream/XStream","method":"fromXML"}]},{"vuln_id":"GHSA-36p3-wjmg-h94x","artifact":"org.springframework.boot:spring-boot-starter-web","version_ranges":["< 2.5.12",">= 2.6.0, < 2.6.6"],"symbols":[{"class":"org/springframework/stereotype/Controller","method":null},{"class":"org/springframework/web/bind/annotation/RestController","method":null},{"class":"org/springframework/web/bind/annotation/RequestMapping","method":null},{"class":"org/springframework/web/bind/annotation/PostMapping","method":null},{"class":"org/springframework/web/bind/annotation/PutMapping","method":null},{"class":"org/springframework/web/bind/annotation/PatchMapping","method":null},{"class":"org/springframework/web/bind/annotation/ModelAttribute","method":null},{"class":"org/springframework/validation/DataBinder","method":null},{"class":"org/springframework/web/bind/WebDataBinder","method":null},{"class":"org/springframework/web/bind/annotation/InitBinder","method":null},{"class":"org/springframework/web/bind/annotation/ControllerAdvice","method":null}]},{"vuln_id":"GHSA-36p3-wjmg-h94x","artifact":"org.springframework.boot:spring-boot-starter-webflux","version_ranges":["< 2.5.12",">= 2.6.0, < 2.6.6"],"symbols":[{"class":"org/springframework/stereotype/Controller","method":null},{"class":"org/springframework/web/bind/annotation/RestController","method":null},{"class":"org/springframework/web/bind/annotation/RequestMapping","method":null},{"class":"org/springframework/web/bind/annotation/PostMapping","method":null},{"class":"org/springframework/web/bind/annotation/PutMapping","method":null},{"class":"org/springframework/web/bind/annotation/PatchMapping","method":null},{"class":"org/springframework/web/bind/annotation/ModelAttribute","method":null},{"class":"org/springframework/validation/DataBinder","method":null},{"class":"org/springframework/web/bind/WebDataBinder","method":null},{"class":"org/springframework/web/bind/annotation/InitBinder","method":null},{"class":"org/springframework/web/bind/annotation/ControllerAdvice","method":null}]},{"vuln_id":"GHSA-36p3-wjmg-h94x","artifact":"org.springframework:spring-beans","version_ranges":[">= 5.3.0, < 5.3.18","< 5.2.20.RELEASE"],"symbols":[{"class":"org/springframework/stereotype/Controller","method":null},{"class":"org/springframework/web/bind/annotation/RestController","method":null},{"class":"org/springframework/web/bind/annotation/RequestMapping","method":null},{"class":"org/springframework/web/bind/annotation/PostMapping","method":null},{"class":"org/springframework/web/bind/annotation/PutMapping","method":null},{"class":"org/springframework/web/bind/annotation/PatchMapping","method":null},{"class":"org/springframework/web/bind/annotation/ModelAttribute","method":null},{"class":"org/springframework/validation/DataBinder","method":null},{"class":"org/springframework/web/bind/WebDataBinder","method":null},{"class":"org/springframework/web/bind/annotation/InitBinder","method":null},{"class":"org/springframework/web/bind/annotation/ControllerAdvice","method":null}]},{"vuln_id":"GHSA-36p3-wjmg-h94x","artifact":"org.springframework:spring-webflux","version_ranges":[">= 5.3.0, < 5.3.18","< 5.2.20.RELEASE"],"symbols":[{"class":"org/springframework/stereotype/Controller","method":null},{"class":"org/springframework/web/bind/annotation/RestController","method":null},{"class":"org/springframework/web/bind/annotation/RequestMapping","method":null},{"class":"org/springframework/web/bind/annotation/PostMapping","method":null},{"class":"org/springframework/web/bind/annotation/PutMapping","method":null},{"class":"org/springframework/web/bind/annotation/PatchMapping","method":null},{"class":"org/springframework/web/bind/annotation/ModelAttribute","method":null},{"class":"org/springframework/validation/DataBinder","method":null},{"class":"org/springframework/web/bind/WebDataBinder","method":null},{"class":"org/springframework/web/bind/annotation/InitBinder","method":null},{"class":"org/springframework/web/bind/annotation/ControllerAdvice","method":null}]},{"vuln_id":"GHSA-36p3-wjmg-h94x","artifact":"org.springframework:spring-webmvc","version_ranges":[">= 5.3.0, < 5.3.18","< 5.2.20.RELEASE"],"symbols":[{"class":"org/springframework/stereotype/Controller","method":null},{"class":"org/springframework/web/bind/annotation/RestController","method":null},{"class":"org/springframework/web/bind/annotation/RequestMapping","method":null},{"class":"org/springframework/web/bind/annotation/PostMapping","method":null},{"class":"org/springframework/web/bind/annotation/PutMapping","method":null},{"class":"org/springframework/web/bind/annotation/PatchMapping","method":null},{"class":"org/springframework/web/bind/annotation/ModelAttribute","method":null},{"class":"org/springframework/validation/DataBinder","method":null},{"class":"org/springframework/web/bind/WebDataBinder","method":null},{"class":"org/springframework/web/bind/annotation/InitBinder","method":null},{"class":"org/springframework/web/bind/annotation/ControllerAdvice","method":null}]},{"vuln_id":"GHSA-3ccq-5vw3-2p6x","artifact":"com.thoughtworks.xstream:xstream","version_ranges":["< 1.4.18"],"symbols":[{"class":"com/thoughtworks/xstream/XStream","method":"fromXML"}]},{"vuln_id":"GHSA-3gm7-v7vw-866c","artifact":"org.apache.solr:solr-core","version_ranges":["< 8.2.0"],"symbols":[{"class":"org/apache/solr/handler/dataimport/DataImporter","method":null},{"class":"org/apache/solr/handler/dataimport/DataImportHandler","method":null}]},{"vuln_id":"GHSA-4cch-wxpw-8p28","artifact":"com.thoughtworks.xstream:xstream","version_ranges":["< 1.4.15"],"symbols":[{"class":"com/thoughtworks/xstream/XStream","method":"fromXML"}]},{"vuln_id":"GHSA-4gq5-ch57-c2mg","artifact":"com.fasterxml.jackson.core:jackson-databind","version_ranges":["< 2.7.9.5",">= 2.8.0, < 2.8.11.3",">= 2.9.0, < 2.9.7"],"symbols":[{"class":"com/fasterxml/jackson/databind/ObjectMapper","method":null},{"class":"com/fasterxml/jackson/databind/ObjectMapper","method":"readValue"},{"class":"com/fasterxml/jackson/databind/ObjectMapper","method":"readValues"},{"class":"com/fasterxml/jackson/databind/ObjectReader","method":null},{"class":"com/fasterxml/jackson/databind/ObjectReader","method":"readValue"},{"class":"com/fasterxml/jackson/databind/ObjectReader","method":"readValues"}]},{"vuln_id":"GHSA-4jrv-ppp4-jm57","artifact":"com.google.code.gson:gson","version_ranges":["< 2.8.9"],"symbols":[{"class":"com/google/gson/Gson","method":null},{"class":"com/google/gson/GsonBuilder","method":null}]},{"vuln_id":"GHSA-4w82-r329-3q67","artifact":"com.fasterxml.jackson.core:jackson-databind","version_ranges":["< 2.6.7.4",">= 2.7.0, < 2.7.9.7",">= 2.8.0, < 2.8.11.5",">= 2.9.0, < 2.9.10.3"],"symbols":[{"class":"com/fasterxml/jackson/databind/ObjectMapper","method":null},{"class":"com/fasterxml/jackson/databind/ObjectMapper","method":"readValue"},{"class":"com/fasterxml/jackson/databind/ObjectMapper","method":"readValues"},{"class":"com/fasterxml/jackson/databind/ObjectReader","method":null},{"class":"com/fasterxml/jackson/databind/ObjectReader","method":"readValue"},{"class":"com/fasterxml/jackson/databind/ObjectReader","method":"readValues"}]},{"vuln_id":"GHSA-4wrc-f8pq-fpqp","artifact":"org.springframework:spring-web","version_ranges":["< 6.0.0"],"symbols":[{"class":"org/springframework/remoting/rmi/CodebaseAwareObjectInputStream","method":null},{"class":"org/springframework/remoting/rmi/JndiRmiClientInterceptor","method":null},{"class":"org/springframework/remoting/rmi/JndiRmiProxyFactoryBean","method":null},{"class":"org/springframework/remoting/rmi/JndiRmiServiceExporter","method":null},{"class":"org/springframework/remoting/rmi/RemoteInvocationSerializingExporter","method":null},{"class":"org/springframework/remoting/rmi/RmiBasedExporter","method":null},{"class":"org/springframework/remoting/rmi/RmiClientInterceptor","method":null},{"class":"org/springframework/remoting/rmi/RmiClientInterceptorUtils","method":null},{"class":"org/springframework/remoting/rmi/RmiInvocationHandler","method":null},{"class":"org/springframework/remoting/rmi/RmiInvocationWrapper","method":null},{"class":"org/springframework/remoting/rmi/RmiProxyFactoryBean","method":null},{"class":"org/springframework/remoting/rmi/RmiRegistryFactoryBean","method":null},{"class":"org/springframework/remoting/rmi/RmiServiceExporter","method":null},{"class":"org/springframework/jms/remoting/JmsInvokerClientInterceptor","method":null},{"class":"org/springframework/jms/remoting/JmsInvokerProxyFactoryBean","method":null},{"class":"org/springframework/jms/remoting/JmsInvokerServiceExporter","method":null},{"class":"org/springframework/remoting/caucho/HessianClientInterceptor","method":null},{"class":"org/springframework/remoting/caucho/HessianExporter","method":null},{"class":"org/springframework/remoting/caucho/HessianProxyFactoryBean","method":null},{"class":"org/springframework/remoting/caucho/HessianServiceExporter","method":null},{"class":"org/springframework/remoting/httpinvoker/AbstractHttpInvokerRequestExecutor","method":null},{"class":"org/springframework/remoting/httpinvoker/HttpComponentsHttpInvokerRequestExecutor","method":null},{"class":"org/springframework/remoting/httpinvoker/HttpInvokerClientConfiguration","method":null},{"class":"org/springframework/remoting/httpinvoker/HttpInvokerClientInterceptor","method":null},{"class":"org/springframework/remoting/httpinvoker/HttpInvokerProxyFactoryBean","method":null},{"class":"org/springframework/remoting/httpinvoker/HttpInvokerRequestExecutor","method":null},{"class":"org/springframework/remoting/httpinvoker/HttpInvokerServiceExporter","method":null},{"class":"org/springframework/remoting/httpinvoker/SimpleHttpInvokerRequestExecutor","method":null},{"class":"org/springframework/remoting/httpinvoker/SimpleHttpInvokerServiceExporter","method":null}]},{"vuln_id":"GHSA-645p-88qh-w398","artifact":"com.fasterxml.jackson.core:jackson-databind","version_ranges":["< 2.6.7.3",">= 2.7.0, < 2.7.9.5",">= 2.8.0, < 2.8.11.3",">= 2.9.0, < 2.9.7"],"symbols":[{"class":"com/fasterxml/jackson/databind/ObjectMapper","method":null},{"class":"com/fasterxml/jackson/databind/ObjectMapper","method":"readValue"},{"class":"com/fasterxml/jackson/databind/ObjectMapper","method":"readValues"},{"class":"com/fasterxml/jackson/databind/ObjectReader","method":null},{"class":"com/fasterxml/jackson/databind/ObjectReader","method":"readValue"},{"class":"com/fasterxml/jackson/databind/ObjectReader","method":"readValues"}]},{"vuln_id":"GHSA-64xx-cq4q-mf44","artifact":"com.thoughtworks.xstream:xstream","version_ranges":["< 1.4.18"],"symbols":[{"class":"com/thoughtworks/xstream/XStream","method":"fromXML"}]},{"vuln_id":"GHSA-6w62-hx7r-mw68","artifact":"com.thoughtworks.xstream:xstream","version_ranges":["< 1.4.18"],"symbols":[{"class":"com/thoughtworks/xstream/XStream","method":"fromXML"}]},{"vuln_id":"GHSA-7rjr-3q55-vv33","artifact":"org.apache.logging.log4j:log4j-core","version_ranges":[">= 2.13.0, < 2.16.0","< 2.12.2"],"symbols":[{"class":"org/apache/logging/log4j/Logger","method":null},{"class":"org/apache/logging/log4j/Logger","method":"info"},{"class":"org/apache/logging/log4j/Logger","method":"error"},{"class":"org/apache/logging/log4j/Logger","method":"warn"},{"class":"org/apache/logging/log4j/Logger","method":"debug"},{"class":"org/apache/logging/log4j/Logger","method":"trace"},{"class":"org/apache/logging/log4j/Logger","method":"fatal"},{"class":"org/apache/logging/log4j/Logger","method":"log"},{"class":"org/apache/logging/log4j/LogManager","method":null}]},{"vuln_id":"GHSA-8jrj-525p-826v","artifact":"com.thoughtworks.xstream:xstream","version_ranges":["< 1.4.18"],"symbols":[{"class":"com/thoughtworks/xstream/XStream","method":"fromXML"}]},{"vuln_id":"GHSA-9mxf-g3x6-wv74","artifact":"com.fasterxml.jackson.core:jackson-databind","version_ranges":["< 2.9.7",">= 2.8.0, < 2.8.11.3",">= 2.7.0, < 2.7.9.5"],"symbols":[{"class":"com/fasterxml/jackson/databind/ObjectMapper","method":null},{"class":"com/fasterxml/jackson/databind/ObjectMapper","method":"readValue"},{"class":"com/fasterxml/jackson/databind/ObjectMapper","method":"readValues"},{"class":"com/fasterxml/jackson/databind/ObjectReader","method":null},{"class":"com/fasterxml/jackson/databind/ObjectReader","method":"readValue"},{"class":"com/fasterxml/jackson/databind/ObjectReader","method":"readValues"},{"class":"com/fasterxml/jackson/databind/annotation/JsonTypeInfo","method":null}]},{"vuln_id":"GHSA-c27h-mcmw-48hv","artifact":"org.codehaus.jackson:jackson-mapper-asl","version_ranges":["<= 1.9.13"],"symbols":[{"class":"org/codehaus/jackson/map/ObjectMapper","method":null},{"class":"org/codehaus/jackson/map/ObjectMapper","method":"readValue"},{"class":"org/codehaus/jackson/map/ObjectMapper","method":"readValues"}]},{"vuln_id":"GHSA-c9hw-wf7x-jp9j","artifact":"org.apache.tomcat.embed:tomcat-embed-core","version_ranges":[">= 9.0.0.M1, < 9.0.31",">= 8.5.0, < 8.5.51",">= 7.0.0, < 7.0.100"],"symbols":[{"class":"org/apache/coyote/ajp/AbstractAjpProtocol","method":null},{"class":"org/apache/coyote/ajp/AjpProcessor","method":null},{"class":"org/apache/coyote/ajp/AjpNioProtocol","method":null},{"class":"org/apache/coyote/ajp/AjpAprProtocol","method":null},{"class":"org/apache/coyote/ajp/AjpNio2Protocol","method":null},{"class":"org/apache/catalina/connector/Connector","method":null}]},{"vuln_id":"GHSA-cm59-pr5q-cw85","artifact":"org.springframework.boot:spring-boot","version_ranges":["<= 2.2.10.RELEASE"],"symbols":[{"class":"org/springframework/boot/SpringApplication","method":null}]},{"vuln_id":"GHSA-crg9-44h2-xw35","artifact":"org.apache.activemq:activemq-client","version_ranges":["< 5.15.16",">= 5.16.0, < 5.16.7",">= 5.17.0, < 5.17.6",">= 5.18.0, < 5.18.3"],"symbols":[{"class":"org/apache/activemq/ActiveMQConnectionFactory","method":null},{"class":"org/apache/activemq/ActiveMQSslConnectionFactory","method":null},{"class":"org/apache/activemq/ActiveMQXAConnectionFactory","method":null},{"class":"org/apache/activemq/ActiveMQXASslConnectionFactory","method":null},{"class":"org/apache/activemq/pool/PooledConnectionFactory","method":null},{"class":"org/apache/activemq/pool/XaPooledConnectionFactory","method":null},{"class":"org/apache/activemq/jms/pool/PooledConnectionFactory","method":null},{"class":"org/apache/activemq/jms/pool/XaPooledConnectionFactory","method":null},{"class":"org/apache/activemq/broker/BrokerService","method":null},{"class":"org/apache/activemq/broker/BrokerFactory","method":null},{"class":"org/apache/activemq/xbean/BrokerFactoryBean","method":null},{"class":"org/apache/activemq/xbean/XBeanBrokerService","method":null},{"class":"org/apache/activemq/spring/ActiveMQConnectionFactory","method":null},{"class":"org/apache/activemq/spring/ActiveMQXAConnectionFactory","method":null},{"class":"org/apache/activemq/pool/PooledConnectionFactoryBean","method":null},{"class":"org/apache/activemq/jndi/ActiveMQInitialContextFactory","method":null},{"class":"org/apache/activemq/jndi/ActiveMQSslInitialContextFactory","method":null},{"class":"org/apache/activemq/transport/tcp/TcpTransportFactory","method":null},{"class":"org/apache/activemq/transport/tcp/SslTransportFactory","method":null},{"class":"org/apache/activemq/transport/nio/NIOTransportFactory","method":null},{"class":"org/apache/activemq/transport/nio/NIOSSLTransportFactory","method":null}]},{"vuln_id":"GHSA-crg9-44h2-xw35","artifact":"org.apache.activemq:activemq-openwire-legacy","version_ranges":["< 5.15.16",">= 5.16.0, < 5.16.7",">= 5.17.0, < 5.17.6",">= 5.18.0, < 5.18.3"],"symbols":[{"class":"org/apache/activemq/ActiveMQConnectionFactory","method":null},{"class":"org/apache/activemq/ActiveMQSslConnectionFactory","method":null},{"class":"org/apache/activemq/ActiveMQXAConnectionFactory","method":null},{"class":"org/apache/activemq/ActiveMQXASslConnectionFactory","method":null},{"class":"org/apache/activemq/pool/PooledConnectionFactory","method":null},{"class":"org/apache/activemq/pool/XaPooledConnectionFactory","method":null},{"class":"org/apache/activemq/jms/pool/PooledConnectionFactory","method":null},{"class":"org/apache/activemq/jms/pool/XaPooledConnectionFactory","method":null},{"class":"org/apache/activemq/broker/BrokerService","method":null},{"class":"org/apache/activemq/broker/BrokerFactory","method":null},{"class":"org/apache/activemq/xbean/BrokerFactoryBean","method":null},{"class":"org/apache/activemq/xbean/XBeanBrokerService","method":null},{"class":"org/apache/activemq/spring/ActiveMQConnectionFactory","method":null},{"class":"org/apache/activemq/spring/ActiveMQXAConnectionFactory","method":null},{"class":"org/apache/activemq/pool/PooledConnectionFactoryBean","method":null},{"class":"org/apache/activemq/jndi/ActiveMQInitialContextFactory","method":null},{"class":"org/apache/activemq/jndi/ActiveMQSslInitialContextFactory","method":null},{"class":"org/apache/activemq/transport/tcp/TcpTransportFactory","method":null},{"class":"org/apache/activemq/transport/tcp/SslTransportFactory","method":null},{"class":"org/apache/activemq/transport/nio/NIOTransportFactory","method":null},{"class":"org/apache/activemq/transport/nio/NIOSSLTransportFactory","method":null}]},{"vuln_id":"GHSA-cxfm-5m4g-x7xp","artifact":"com.thoughtworks.xstream:xstream","version_ranges":["< 1.4.18"],"symbols":[{"class":"com/thoughtworks/xstream/XStream","method":"fromXML"}]},{"vuln_id":"GHSA-f3j5-rmmp-3fc5","artifact":"com.fasterxml.jackson.core:jackson-databind","version_ranges":["< 2.8.11.5",">= 2.9.0, < 2.9.10"],"symbols":[{"class":"com/fasterxml/jackson/databind/ObjectMapper","method":null},{"class":"com/fasterxml/jackson/databind/ObjectMapper","method":"readValue"},{"class":"com/fasterxml/jackson/databind/ObjectMapper","method":"readValues"},{"class":"com/fasterxml/jackson/databind/ObjectReader","method":null},{"class":"com/fasterxml/jackson/databind/ObjectReader","method":"readValue"},{"class":"com/fasterxml/jackson/databind/ObjectReader","method":"readValues"}]},{"vuln_id":"GHSA-g5h3-w546-pj7f","artifact":"org.springframework.boot:spring-boot-actuator-autoconfigure","version_ranges":[">= 3.0.0, < 3.0.6",">= 2.7.0, < 2.7.11",">= 2.6.0, < 2.6.15",">= 2.5.0, < 2.5.15"],"symbols":[{"class":"org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfiguration","method":null},{"class":"org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfiguration","method":null},{"class":"org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryWebEndpointServletHandlerMapping","method":null},{"class":"org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointHandlerMapping","method":null}]},{"vuln_id":"GHSA-g5w6-mrj7-75h2","artifact":"com.thoughtworks.xstream:xstream","version_ranges":["< 1.4.18"],"symbols":[{"class":"com/thoughtworks/xstream/XStream","method":"fromXML"}]},{"vuln_id":"GHSA-h7v4-7xg3-hxcc","artifact":"com.thoughtworks.xstream:xstream","version_ranges":["< 1.4.18"],"symbols":[{"class":"com/thoughtworks/xstream/XStream","method":"fromXML"}]},{"vuln_id":"GHSA-hph2-m3g5-xxv4","artifact":"com.thoughtworks.xstream:xstream","version_ranges":["< 1.4.18"],"symbols":[{"class":"com/thoughtworks/xstream/XStream","method":"fromXML"}]},{"vuln_id":"GHSA-j9h8-phrw-h4fh","artifact":"com.thoughtworks.xstream:xstream","version_ranges":["< 1.4.18"],"symbols":[{"class":"com/thoughtworks/xstream/XStream","method":"fromXML"}]},{"vuln_id":"GHSA-jfh8-c2jp-5v3q","artifact":"org.apache.logging.log4j:log4j-core","version_ranges":[">= 2.13.0, < 2.15.0",">= 2.4, < 2.12.2",">= 2.0-beta9, < 2.3.1"],"symbols":[{"class":"org/apache/logging/log4j/Logger","method":null},{"class":"org/apache/logging/log4j/Logger","method":"info"},{"class":"org/apache/logging/log4j/Logger","method":"error"},{"class":"org/apache/logging/log4j/Logger","method":"warn"},{"class":"org/apache/logging/log4j/Logger","method":"debug"},{"class":"org/apache/logging/log4j/Logger","method":"trace"},{"class":"org/apache/logging/log4j/Logger","method":"fatal"},{"class":"org/apache/logging/log4j/Logger","method":"log"},{"class":"org/apache/logging/log4j/LogManager","method":null}]},{"vuln_id":"GHSA-jfh8-c2jp-5v3q","artifact":"com.guicedee.services:log4j-core","version_ranges":["<= 1.2.1.2-jre17"],"symbols":[{"class":"org/apache/logging/log4j/Logger","method":null},{"class":"org/apache/logging/log4j/Logger","method":"info"},{"class":"org/apache/logging/log4j/Logger","method":"error"},{"class":"org/apache/logging/log4j/Logger","method":"warn"},{"class":"org/apache/logging/log4j/Logger","method":"debug"},{"class":"org/apache/logging/log4j/Logger","method":"trace"},{"class":"org/apache/logging/log4j/Logger","method":"fatal"},{"class":"org/apache/logging/log4j/Logger","method":"log"},{"class":"org/apache/logging/log4j/LogManager","method":null}]},{"vuln_id":"GHSA-jfh8-c2jp-5v3q","artifact":"org.xbib.elasticsearch:log4j","version_ranges":["= 6.3.2.1"],"symbols":[{"class":"org/apache/logging/log4j/Logger","method":null},{"class":"org/apache/logging/log4j/Logger","method":"info"},{"class":"org/apache/logging/log4j/Logger","method":"error"},{"class":"org/apache/logging/log4j/Logger","method":"warn"},{"class":"org/apache/logging/log4j/Logger","method":"debug"},{"class":"org/apache/logging/log4j/Logger","method":"trace"},{"class":"org/apache/logging/log4j/Logger","method":"fatal"},{"class":"org/apache/logging/log4j/Logger","method":"log"},{"class":"org/apache/logging/log4j/LogManager","method":null}]},{"vuln_id":"GHSA-jfh8-c2jp-5v3q","artifact":"uk.co.nichesolutions.logging.log4j:log4j-core","version_ranges":["= 2.6.3-CUSTOM"],"symbols":[{"class":"org/apache/logging/log4j/Logger","method":null},{"class":"org/apache/logging/log4j/Logger","method":"info"},{"class":"org/apache/logging/log4j/Logger","method":"error"},{"class":"org/apache/logging/log4j/Logger","method":"warn"},{"class":"org/apache/logging/log4j/Logger","method":"debug"},{"class":"org/apache/logging/log4j/Logger","method":"trace"},{"class":"org/apache/logging/log4j/Logger","method":"fatal"},{"class":"org/apache/logging/log4j/Logger","method":"log"},{"class":"org/apache/logging/log4j/LogManager","method":null}]},{"vuln_id":"GHSA-mjmj-j48q-9wg2","artifact":"org.yaml:snakeyaml","version_ranges":["<= 1.33"],"symbols":[{"class":"org/yaml/snakeyaml/Yaml","method":"load"},{"class":"org/yaml/snakeyaml/Yaml","method":"loadAll"}]},{"vuln_id":"GHSA-mw36-7c6c-q4q2","artifact":"com.thoughtworks.xstream:xstream","version_ranges":["<= 1.4.13"],"symbols":[{"class":"com/thoughtworks/xstream/XStream","method":"fromXML"}]},{"vuln_id":"GHSA-p8pq-r894-fm8f","artifact":"com.thoughtworks.xstream:xstream","version_ranges":["< 1.4.18"],"symbols":[{"class":"com/thoughtworks/xstream/XStream","method":"fromXML"}]},{"vuln_id":"GHSA-qmqc-x3r4-6v39","artifact":"com.fasterxml.jackson.core:jackson-databind","version_ranges":["< 2.9.10"],"symbols":[{"class":"com/fasterxml/jackson/databind/ObjectMapper","method":null},{"class":"com/fasterxml/jackson/databind/ObjectMapper","method":"readValue"},{"class":"com/fasterxml/jackson/databind/ObjectMapper","method":"readValues"}]},{"vuln_id":"GHSA-qr7j-h6gg-jmgc","artifact":"com.fasterxml.jackson.core:jackson-databind","version_ranges":[">= 2.0.0, <= 2.7.9.3",">= 2.8.0, <= 2.8.11.1",">= 2.9.0, <= 2.9.5"],"symbols":[{"class":"com/fasterxml/jackson/databind/ObjectMapper","method":null},{"class":"com/fasterxml/jackson/databind/ObjectMapper","method":"readValue"},{"class":"com/fasterxml/jackson/databind/ObjectMapper","method":"readValues"},{"class":"com/fasterxml/jackson/databind/ObjectReader","method":null},{"class":"com/fasterxml/jackson/databind/ObjectReader","method":"readValue"},{"class":"com/fasterxml/jackson/databind/ObjectReader","method":"readValues"}]},{"vuln_id":"GHSA-qrx8-8545-4wg2","artifact":"com.thoughtworks.xstream:xstream","version_ranges":["< 1.4.18"],"symbols":[{"class":"com/thoughtworks/xstream/XStream","method":"fromXML"}]},{"vuln_id":"GHSA-r4x2-3cq5-hqvp","artifact":"org.apache.tomcat.embed:tomcat-embed-core","version_ranges":[">= 9.0.0.M1, < 9.0.9",">= 8.5.0, < 8.5.32",">= 8.0.0-RC1, < 8.0.53",">= 7.0.41, < 7.0.88"],"symbols":[{"class":"org/apache/catalina/filters/CorsFilter","method":null}]},{"vuln_id":"GHSA-rmr5-cpv2-vgjf","artifact":"com.thoughtworks.xstream:xstream","version_ranges":["< 1.4.19"],"symbols":[{"class":"com/thoughtworks/xstream/XStream","method":"fromXML"}]},{"vuln_id":"GHSA-vmfg-rjjm-rjrj","artifact":"ch.qos.logback:logback-classic","version_ranges":["< 1.2.0"],"symbols":[{"class":"ch/qos/logback/classic/net/SimpleSocketServer","method":null},{"class":"ch/qos/logback/classic/net/SocketReceiver","method":null},{"class":"ch/qos/logback/classic/net/server/ServerSocketReceiver","method":null},{"class":"ch/qos/logback/classic/net/server/SSLServerSocketReceiver","method":null},{"class":"ch/qos/logback/classic/net/SimpleSSLSocketServer","method":null},{"class":"ch/qos/logback/access/net/SimpleSocketServer","method":null}]},{"vuln_id":"GHSA-vmfg-rjjm-rjrj","artifact":"ch.qos.logback:logback-access","version_ranges":["< 1.2.0"],"symbols":[{"class":"ch/qos/logback/classic/net/SimpleSocketServer","method":null},{"class":"ch/qos/logback/classic/net/SocketReceiver","method":null},{"class":"ch/qos/logback/classic/net/server/ServerSocketReceiver","method":null},{"class":"ch/qos/logback/classic/net/server/SSLServerSocketReceiver","method":null},{"class":"ch/qos/logback/classic/net/SimpleSSLSocketServer","method":null},{"class":"ch/qos/logback/access/net/SimpleSocketServer","method":null}]},{"vuln_id":"GHSA-ww97-9w65-2crx","artifact":"org.apache.solr:solr-core","version_ranges":[">= 5.0.0, <= 5.5.5",">= 6.0.0, <= 6.6.6",">= 7.0.0, <= 7.7.2",">= 8.0.0, <= 8.3.1"],"symbols":[{"class":"org/apache/velocity/app/Velocity","method":null},{"class":"org/apache/velocity/VelocityContext","method":null},{"class":"org/apache/velocity/Template","method":null}]},{"vuln_id":"GHSA-xw4p-crpj-vjx2","artifact":"com.thoughtworks.xstream:xstream","version_ranges":["< 1.4.18"],"symbols":[{"class":"com/thoughtworks/xstream/XStream","method":"fromXML"}]}]} \ No newline at end of file diff --git a/dd-java-agent/appsec/src/test/java/com/datadog/appsec/sca/ScaCveDatabaseTest.java b/dd-java-agent/appsec/src/test/java/com/datadog/appsec/sca/ScaCveDatabaseTest.java new file mode 100644 index 00000000000..c877cae1a7a --- /dev/null +++ b/dd-java-agent/appsec/src/test/java/com/datadog/appsec/sca/ScaCveDatabaseTest.java @@ -0,0 +1,131 @@ +package com.datadog.appsec.sca; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.StringReader; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ScaCveDatabaseTest { + + private static final String MINIMAL_JSON = + "{\"version\":1,\"entries\":[" + + "{\"vuln_id\":\"GHSA-test-1234-5678\"," + + "\"artifact\":\"com.example:lib\"," + + "\"version_ranges\":[\"< 2.0.0\"]," + + "\"symbols\":[" + + "{\"class\":\"com/example/Foo\",\"method\":null}," + + "{\"class\":\"com/example/Bar\",\"method\":null}" + + "]}]}"; + + @Test + void loadsFromJson() throws Exception { + ScaCveDatabase db = ScaCveDatabase.parse(new StringReader(MINIMAL_JSON)); + + assertFalse(db.isEmpty()); + assertEquals(2, db.size()); // 2 unique class names + } + + @Test + void indexedByClassName() throws Exception { + ScaCveDatabase db = ScaCveDatabase.parse(new StringReader(MINIMAL_JSON)); + + List entries = db.entriesForClass("com/example/Foo"); + assertNotNull(entries); + assertEquals(1, entries.size()); + assertEquals("GHSA-test-1234-5678", entries.get(0).vulnId()); + assertEquals("com.example:lib", entries.get(0).artifact()); + } + + @Test + void unknownClassReturnsNull() throws Exception { + ScaCveDatabase db = ScaCveDatabase.parse(new StringReader(MINIMAL_JSON)); + + assertNull(db.entriesForClass("com/example/Unknown")); + } + + @Test + void emptyEntriesProducesEmptyDatabase() throws Exception { + String json = "{\"version\":1,\"entries\":[]}"; + ScaCveDatabase db = ScaCveDatabase.parse(new StringReader(json)); + + assertTrue(db.isEmpty()); + } + + @Test + void malformedEntryIsSkipped() throws Exception { + String json = + "{\"version\":1,\"entries\":[" + + "{\"vuln_id\":null,\"artifact\":\"com.example:lib\"," + + "\"version_ranges\":[\"< 2.0.0\"],\"symbols\":[{\"class\":\"com/example/Foo\",\"method\":null}]}," + + "{\"vuln_id\":\"GHSA-good-0000-0000\",\"artifact\":\"com.example:other\"," + + "\"version_ranges\":[\"< 1.0.0\"],\"symbols\":[{\"class\":\"com/example/Good\",\"method\":null}]}" + + "]}"; + + ScaCveDatabase db = ScaCveDatabase.parse(new StringReader(json)); + + assertNull(db.entriesForClass("com/example/Foo")); + assertNotNull(db.entriesForClass("com/example/Good")); + } + + @Test + void multipleEntriesForSameClass() throws Exception { + String json = + "{\"version\":1,\"entries\":[" + + "{\"vuln_id\":\"GHSA-aaaa-0001-0001\",\"artifact\":\"com.example:lib\"," + + "\"version_ranges\":[\"< 2.0.0\"],\"symbols\":[{\"class\":\"com/example/Shared\",\"method\":null}]}," + + "{\"vuln_id\":\"GHSA-bbbb-0002-0002\",\"artifact\":\"com.example:lib\"," + + "\"version_ranges\":[\"< 3.0.0\"],\"symbols\":[{\"class\":\"com/example/Shared\",\"method\":null}]}" + + "]}"; + + ScaCveDatabase db = ScaCveDatabase.parse(new StringReader(json)); + + List entries = db.entriesForClass("com/example/Shared"); + assertNotNull(entries); + assertEquals(2, entries.size()); + } + + @Test + void entryWithMultipleSymbolsInSameClassIndexedOnce() throws Exception { + // An entry with two symbols for the same class (e.g. Yaml.load + Yaml.loadAll) must appear + // only once in the index. Duplicate entries cause the same bytecode callback to be injected + // twice into each method, producing redundant bootstrap calls on every invocation. + String json = + "{\"version\":1,\"entries\":[" + + "{\"vuln_id\":\"GHSA-mjmj-j48q-9wg2\",\"artifact\":\"org.yaml:snakeyaml\"," + + "\"version_ranges\":[\"<= 1.33\"],\"symbols\":[" + + "{\"class\":\"org/yaml/snakeyaml/Yaml\",\"method\":\"load\"}," + + "{\"class\":\"org/yaml/snakeyaml/Yaml\",\"method\":\"loadAll\"}" + + "]}]}"; + + ScaCveDatabase db = ScaCveDatabase.parse(new StringReader(json)); + + List entries = db.entriesForClass("org/yaml/snakeyaml/Yaml"); + assertNotNull(entries); + assertEquals(1, entries.size(), "same entry must not appear twice even with multiple symbols"); + assertEquals(2, entries.get(0).symbols().size(), "entry must retain all method symbols"); + } + + @Test + void loadFromClasspathSucceeds() { + // Verifies the real sca_cves.json generated by generateScaCvesJson is valid and loadable + ScaCveDatabase db = ScaCveDatabase.load(); + + assertFalse(db.isEmpty(), "sca_cves.json should be on the classpath and contain entries"); + assertTrue(db.size() > 0); + } + + @Test + void jacksonDatabindObjectMapperIsIndexed() { + // Spot-check a known entry from the real database + ScaCveDatabase db = ScaCveDatabase.load(); + + List entries = db.entriesForClass("com/fasterxml/jackson/databind/ObjectMapper"); + assertNotNull(entries, "jackson-databind ObjectMapper should be in the database"); + assertFalse(entries.isEmpty()); + } +} diff --git a/dd-java-agent/appsec/src/test/java/com/datadog/appsec/sca/ScaReachabilityMethodLevelTest.java b/dd-java-agent/appsec/src/test/java/com/datadog/appsec/sca/ScaReachabilityMethodLevelTest.java new file mode 100644 index 00000000000..068af7dd220 --- /dev/null +++ b/dd-java-agent/appsec/src/test/java/com/datadog/appsec/sca/ScaReachabilityMethodLevelTest.java @@ -0,0 +1,311 @@ +package com.datadog.appsec.sca; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.api.telemetry.ScaReachabilityDependencyRegistry; +import datadog.trace.api.telemetry.ScaReachabilityDependencyRegistry.DependencySnapshot; +import datadog.trace.api.telemetry.ScaReachabilityHit; +import datadog.trace.bootstrap.appsec.sca.ScaReachabilityCallback; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.StringReader; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Tests for method-level symbol detection ({@code method != null} in sca_cves.json). + * + *

Strategy: test {@link ScaReachabilityTransformer#injectMethodCallbacks} directly to verify the + * ASM injection mechanism, decoupled from JAR version resolution. The version resolution path is + * covered by {@link ScaReachabilityTransformerTest}. + */ +class ScaReachabilityMethodLevelTest { + + /** Target class that the transformer will instrument in tests. */ + public static class TargetClass { + public String vulnerableMethod() { + return "executed"; + } + + public String safeMethod() { + return "safe"; + } + } + + private ScaCveDatabase db; + private ScaReachabilityTransformer transformer; + + @BeforeEach + void setUp() throws Exception { + ScaReachabilityDependencyRegistry.INSTANCE.resetForTesting(); + // Register the same handler as ScaReachabilitySystem.start() does in production + ScaReachabilityCallback.register( + (vulnId, artifact, version, dotClassName, methodName, line) -> + ScaReachabilityDependencyRegistry.INSTANCE.recordHit( + artifact, version, vulnId, dotClassName, methodName, line)); + db = ScaCveDatabase.parse(new StringReader("{\"version\":1,\"entries\":[]}")); + transformer = new ScaReachabilityTransformer(db, null); + } + + @AfterEach + void tearDown() { + ScaReachabilityDependencyRegistry.INSTANCE.resetForTesting(); + ScaReachabilityCallback.register(null); + } + + // --------------------------------------------------------------------------- + // ASM injection: injectMethodCallbacks() + // --------------------------------------------------------------------------- + + @Test + void injectMethodCallbacks_returnsModifiedBytecode() throws Exception { + byte[] original = bytecodeOf(TargetClass.class); + Map> callbacks = + singleCallback("vulnerableMethod"); + + byte[] modified = transformer.injectMethodCallbacks(original, callbacks); + + assertNotNull(modified, "injectMethodCallbacks must return non-null modified bytecode"); + } + + @Test + void injectMethodCallbacks_callbackFiredOnMethodCall() throws Exception { + byte[] original = bytecodeOf(TargetClass.class); + Map> callbacks = + singleCallback("vulnerableMethod"); + + byte[] modified = transformer.injectMethodCallbacks(original, callbacks); + Class cls = loadModified(modified); + Object instance = cls.getDeclaredConstructor().newInstance(); + cls.getMethod("vulnerableMethod").invoke(instance); + + List hits = drainHits(); + assertEquals(1, hits.size()); + ScaReachabilityHit hit = hits.get(0); + assertEquals("GHSA-method-0001", hit.vulnId()); + assertEquals("com.example:test-lib", hit.artifact()); + assertEquals("1.2.3", hit.version()); + // Callsite semantics: path/symbol/line should be the APPLICATION frame that invoked the + // vulnerable method. In production (e.g. TargetClass = com.fasterxml.jackson.ObjectMapper), + // findCallsite() finds the caller and returns it. + // + // In this test, TargetClass is in com.datadog.appsec.sca.* which AbstractStackWalker + // treats as agent code and filters out. findCallsite() returns null and the handler falls + // back to reporting the vulnerable symbol itself (dotClassName/methodName). + // This verifies the fallback path works correctly. + assertFalse( + hit.className().isEmpty(), "className must be non-empty (fallback: vulnerable class)"); + assertFalse(hit.symbolName().isEmpty(), "symbolName must be non-empty"); + assertTrue(hit.line() >= 0, "line must be non-negative"); + } + + @Test + void injectMethodCallbacks_noCallbackForSafeMethod() throws Exception { + byte[] original = bytecodeOf(TargetClass.class); + Map> callbacks = + singleCallback("vulnerableMethod"); + + byte[] modified = transformer.injectMethodCallbacks(original, callbacks); + Class cls = loadModified(modified); + Object instance = cls.getDeclaredConstructor().newInstance(); + cls.getMethod("safeMethod").invoke(instance); // call only the safe method + + assertTrue( + drainHits().isEmpty(), "No hit expected when only non-instrumented methods are called"); + } + + @Test + void injectMethodCallbacks_deduplicatesOnMultipleCalls() throws Exception { + byte[] original = bytecodeOf(TargetClass.class); + Map> callbacks = + singleCallback("vulnerableMethod"); + + byte[] modified = transformer.injectMethodCallbacks(original, callbacks); + Class cls = loadModified(modified); + Object instance = cls.getDeclaredConstructor().newInstance(); + Method m = cls.getMethod("vulnerableMethod"); + + m.invoke(instance); + m.invoke(instance); + m.invoke(instance); + + assertEquals( + 1, + drainHits().size(), + "Hit must be reported only once regardless of how many times the method is called"); + } + + @Test + void injectMethodCallbacks_injectsMultipleMethodsIndependently() throws Exception { + byte[] original = bytecodeOf(TargetClass.class); + Map> callbacks = new HashMap<>(); + callbacks.put( + "vulnerableMethod", + Collections.singletonList( + spec("GHSA-m1", "com.example:lib", "1.0.0", "T", "vulnerableMethod"))); + callbacks.put( + "safeMethod", + Collections.singletonList(spec("GHSA-m2", "com.example:lib", "1.0.0", "T", "safeMethod"))); + + byte[] modified = transformer.injectMethodCallbacks(original, callbacks); + Class cls = loadModified(modified); + Object instance = cls.getDeclaredConstructor().newInstance(); + + cls.getMethod("vulnerableMethod").invoke(instance); + cls.getMethod("safeMethod").invoke(instance); + + List hits = drainHits(); + assertEquals(2, hits.size(), "Each instrumented method produces its own hit"); + assertTrue(hits.stream().anyMatch(h -> h.symbolName().equals("vulnerableMethod"))); + assertTrue(hits.stream().anyMatch(h -> h.symbolName().equals("safeMethod"))); + } + + @Test + void injectMethodCallbacks_sameMethodNameInDifferentClassesProduceIndependentHits() + throws Exception { + // Regression test for dedup key bug: if two classes in the same artifact share a method + // name (e.g. ClassA.parse and ClassB.parse), both must be reported independently. + // With the stateful RFC model, one hit per CVE is reported (first occurrence wins). + // The dedup key in ScaReachabilityCallback uses dotClassName to allow both classes to reach + // the registry handler, but the registry itself enforces "single occurrence per CVE". + // This verifies that ClassB's hit does NOT cause a NullPointerException or error — it is + // simply ignored since ClassA already provided the first callsite for GHSA-shared. + + Map> callbacksClassA = + new HashMap<>(); + callbacksClassA.put( + "vulnerableMethod", + Collections.singletonList( + spec( + "GHSA-shared", + "com.example:lib", + "1.0.0", + "com.example.ClassA", + "vulnerableMethod"))); + + Map> callbacksClassB = + new HashMap<>(); + callbacksClassB.put( + "vulnerableMethod", + Collections.singletonList( + spec( + "GHSA-shared", + "com.example:lib", + "1.0.0", + "com.example.ClassB", + "vulnerableMethod"))); + + byte[] original = bytecodeOf(TargetClass.class); + Class clsA = loadModified(transformer.injectMethodCallbacks(original, callbacksClassA)); + Class clsB = loadModified(transformer.injectMethodCallbacks(original, callbacksClassB)); + + clsA.getMethod("vulnerableMethod").invoke(clsA.getDeclaredConstructor().newInstance()); + clsB.getMethod("vulnerableMethod").invoke(clsB.getDeclaredConstructor().newInstance()); + + List hits = drainHits(); + // RFC: "reporting a single occurrence is sufficient" — only the first callsite per CVE is kept. + assertEquals(1, hits.size(), "Only the first hit per CVE is retained (RFC: single occurrence)"); + assertEquals("GHSA-shared", hits.get(0).vulnId()); + } + + // --------------------------------------------------------------------------- + // transform(): class-level symbols still report via Path A + // --------------------------------------------------------------------------- + + @Test + void transformReturnsNullForClassLevelSymbol() throws Exception { + String json = + "{\"version\":1,\"entries\":[{" + + "\"vuln_id\":\"GHSA-cls\",\"artifact\":\"com.example:lib\"," + + "\"version_ranges\":[\"< 999.0.0\"]," + + "\"symbols\":[{\"class\":\"" + + TargetClass.class.getName().replace('.', '/') + + "\",\"method\":null}]" + + "}]}"; + ScaCveDatabase classDb = ScaCveDatabase.parse(new StringReader(json)); + ScaReachabilityTransformer t = new ScaReachabilityTransformer(classDb, null); + + byte[] result = + t.transform( + null, + TargetClass.class.getName().replace('.', '/'), + null, + TargetClass.class.getProtectionDomain(), + bytecodeOf(TargetClass.class)); + + assertNull(result, "transform() must return null (observation only) for class-level symbols"); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + /** + * Extracts ScaReachabilityHit objects from pending dependencies in the registry. Only returns + * CVEs that have an actual hit (callsite recorded), not empty-reached CVEs. + */ + private static List drainHits() { + List result = new ArrayList<>(); + for (DependencySnapshot dep : + ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies()) { + for (ScaReachabilityDependencyRegistry.CveSnapshot cve : dep.cves) { + if (cve.hit != null) { + result.add(cve.hit); + } + } + } + return result; + } + + private static Map> singleCallback( + String methodName) { + Map> m = new HashMap<>(); + m.put( + methodName, + Collections.singletonList( + spec( + "GHSA-method-0001", + "com.example:test-lib", + "1.2.3", + TargetClass.class.getName(), + methodName))); + return m; + } + + private static ScaReachabilityTransformer.MethodCallbackSpec spec( + String vulnId, String artifact, String version, String dotClass, String method) { + return new ScaReachabilityTransformer.MethodCallbackSpec( + vulnId, artifact, version, dotClass, method); + } + + private static byte[] bytecodeOf(Class clazz) throws Exception { + String path = clazz.getName().replace('.', '/') + ".class"; + try (InputStream is = clazz.getClassLoader().getResourceAsStream(path)) { + assertNotNull(is, "Cannot load bytecode for " + clazz.getName()); + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + byte[] chunk = new byte[4096]; + int n; + while ((n = is.read(chunk)) != -1) buf.write(chunk, 0, n); + return buf.toByteArray(); + } + } + + private static Class loadModified(byte[] bytecode) { + return new ClassLoader(ScaReachabilityMethodLevelTest.class.getClassLoader()) { + Class define() { + return defineClass(null, bytecode, 0, bytecode.length); + } + }.define(); + } +} diff --git a/dd-java-agent/appsec/src/test/java/com/datadog/appsec/sca/ScaReachabilitySystemCallsiteTest.java b/dd-java-agent/appsec/src/test/java/com/datadog/appsec/sca/ScaReachabilitySystemCallsiteTest.java new file mode 100644 index 00000000000..3dc3d2e6d80 --- /dev/null +++ b/dd-java-agent/appsec/src/test/java/com/datadog/appsec/sca/ScaReachabilitySystemCallsiteTest.java @@ -0,0 +1,85 @@ +package com.datadog.appsec.sca; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link ScaReachabilitySystem#findCallsite(String, StackTraceElement[])}. */ +class ScaReachabilitySystemCallsiteTest { + + private static final String VULNERABLE_CLASS = "org.yaml.snakeyaml.Yaml"; + + @Test + void findCallsite_returnsNullWhenVulnerableClassIsNotInStack() { + StackTraceElement[] stack = { + frame("sca.test.TestController", "doSomething"), + }; + + assertNull( + ScaReachabilitySystem.findCallsite("com.example.ClassNotOnStack", stack), + "Should return null when vulnerable class is not on the stack"); + } + + @Test + void findCallsite_returnsDirectCallerWhenNoIntermediateLibrary() { + StackTraceElement[] stack = { + frame(VULNERABLE_CLASS, "load"), frame("sca.test.TestController", "yamlHitDirect"), + }; + + StackTraceElement result = ScaReachabilitySystem.findCallsite(VULNERABLE_CLASS, stack); + + assertEquals("sca.test.TestController", result.getClassName()); + assertEquals("yamlHitDirect", result.getMethodName()); + } + + @Test + void findCallsite_skipsIntermediateLibraryFrameAndReturnsClientCode() { + // com.google.* is excluded by the SCA trie (value >= 1) + StackTraceElement[] stack = { + frame(VULNERABLE_CLASS, "load"), + frame("com.google.yaml.YamlWrapper", "load"), + frame("sca.test.TestController", "yamlHitTransitive"), + }; + + StackTraceElement result = ScaReachabilitySystem.findCallsite(VULNERABLE_CLASS, stack); + + assertEquals( + "sca.test.TestController", + result.getClassName(), + "Should skip intermediate library frame and return application code"); + assertEquals("yamlHitTransitive", result.getMethodName()); + } + + @Test + void findCallsite_skipsMultipleIntermediateLibraryFrames() { + StackTraceElement[] stack = { + frame(VULNERABLE_CLASS, "load"), + frame("com.google.yaml.YamlWrapper", "load"), + frame("org.springframework.beans.factory.xml.XmlBeanFactory", "init"), + frame("sca.test.TestController", "yamlHitDeep"), + }; + + StackTraceElement result = ScaReachabilitySystem.findCallsite(VULNERABLE_CLASS, stack); + + assertEquals("sca.test.TestController", result.getClassName()); + assertEquals("yamlHitDeep", result.getMethodName()); + } + + @Test + void findCallsite_returnsNullWhenOnlyLibraryFramesFollowVulnerableClass() { + StackTraceElement[] stack = { + frame(VULNERABLE_CLASS, "load"), + frame("com.google.yaml.YamlWrapper", "load"), + frame("org.springframework.beans.factory.BeanFactory", "getBean"), + }; + + assertNull( + ScaReachabilitySystem.findCallsite(VULNERABLE_CLASS, stack), + "Should return null and trigger fallback when no application frame is found"); + } + + private static StackTraceElement frame(String className, String methodName) { + return new StackTraceElement(className, methodName, null, -1); + } +} diff --git a/dd-java-agent/appsec/src/test/java/com/datadog/appsec/sca/ScaReachabilityTransformerJava9Test.java b/dd-java-agent/appsec/src/test/java/com/datadog/appsec/sca/ScaReachabilityTransformerJava9Test.java new file mode 100644 index 00000000000..a62b41f8394 --- /dev/null +++ b/dd-java-agent/appsec/src/test/java/com/datadog/appsec/sca/ScaReachabilityTransformerJava9Test.java @@ -0,0 +1,72 @@ +package com.datadog.appsec.sca; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.io.StringReader; +import java.net.URLClassLoader; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; + +/** + * Verifies that Path B classpath scanning works on Java 9+, where the system classloader + * (jdk.internal.loader.ClassLoaders$AppClassLoader) no longer extends {@link URLClassLoader}. + * + *

The change was introduced in Java 9 (Project Jigsaw) and is permanent in all subsequent JDK + * versions (11, 17, 21, …). Without the {@code java.class.path} fallback, Path B would silently + * fail to find vulnerable artifacts on any modern JDK. + */ +class ScaReachabilityTransformerJava9Test { + + private static final String JACKSON_JSON = + "{\"version\":1,\"entries\":[{" + + "\"vuln_id\":\"GHSA-test-jackson\"," + + "\"artifact\":\"com.fasterxml.jackson.core:jackson-databind\"," + + "\"version_ranges\":[\"< 999.0.0\"]," + + "\"symbols\":[{\"class\":\"com/fasterxml/jackson/databind/ObjectMapper\",\"method\":null}]" + + "}]}"; + + @Test + @EnabledForJreRange(min = JRE.JAVA_9) + void systemClassLoaderIsNotUrlClassLoaderOnJava9Plus() { + // This is the root cause of the bug: the URLClassLoader chain walk misses the system + // classloader on Java 9+. Verify our assumption so the test has a clear failure message + // if the JDK ever reverts this (extremely unlikely). + assertFalse( + ClassLoader.getSystemClassLoader() instanceof URLClassLoader, + "On Java 9+, the system classloader must not be a URLClassLoader — " + + "this is the invariant that makes the java.class.path fallback necessary"); + } + + @Test + @EnabledForJreRange(min = JRE.JAVA_9) + void findArtifactVersionInClasspath_findsArtifactViaJavaClassPathOnJava9Plus() throws Exception { + // jackson-databind is on the test classpath (testImplementation dependency). + // On Java 9+, it would NOT be found by the URLClassLoader chain because the system + // classloader is not a URLClassLoader. The java.class.path fallback must find it. + ScaCveDatabase db = ScaCveDatabase.parse(new StringReader(JACKSON_JSON)); + ScaReachabilityTransformer transformer = new ScaReachabilityTransformer(db, null); + + String version = + transformer.findArtifactVersionInClasspath("com.fasterxml.jackson.core:jackson-databind"); + + assertNotNull( + version, + "jackson-databind must be found via java.class.path fallback on Java 9+. " + + "java.class.path=" + + System.getProperty("java.class.path", "")); + } + + @Test + @EnabledForJreRange(min = JRE.JAVA_9) + void findArtifactVersionInClasspath_returnsNullForUnknownArtifact() throws Exception { + ScaCveDatabase db = ScaCveDatabase.parse(new StringReader(JACKSON_JSON)); + ScaReachabilityTransformer transformer = new ScaReachabilityTransformer(db, null); + + String version = transformer.findArtifactVersionInClasspath("com.example:nonexistent-artifact"); + + assertNull(version, "Unknown artifacts must return null"); + } +} diff --git a/dd-java-agent/appsec/src/test/java/com/datadog/appsec/sca/VersionRangeParserTest.java b/dd-java-agent/appsec/src/test/java/com/datadog/appsec/sca/VersionRangeParserTest.java new file mode 100644 index 00000000000..1e74d9de5d6 --- /dev/null +++ b/dd-java-agent/appsec/src/test/java/com/datadog/appsec/sca/VersionRangeParserTest.java @@ -0,0 +1,181 @@ +package com.datadog.appsec.sca; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; + +class VersionRangeParserTest { + + // --- matchesAny: null / empty guards --- + + @Test + void nullVersionReturnsFalse() { + assertFalse(VersionRangeParser.matchesAny(null, Arrays.asList("< 2.0.0"))); + } + + @Test + void emptyVersionReturnsFalse() { + assertFalse(VersionRangeParser.matchesAny("", Arrays.asList("< 2.0.0"))); + } + + @Test + void nullRangesReturnsFalse() { + assertFalse(VersionRangeParser.matchesAny("1.0.0", null)); + } + + @Test + void emptyRangesReturnsFalse() { + assertFalse(VersionRangeParser.matchesAny("1.0.0", Collections.emptyList())); + } + + // --- single-condition operators --- + + @Test + void lessThan_belowBound() { + assertTrue(VersionRangeParser.matchesAny("2.6.7.2", Arrays.asList("< 2.6.7.3"))); + } + + @Test + void lessThan_atBound() { + assertFalse(VersionRangeParser.matchesAny("2.6.7.3", Arrays.asList("< 2.6.7.3"))); + } + + @Test + void lessThan_aboveBound() { + assertFalse(VersionRangeParser.matchesAny("2.6.7.4", Arrays.asList("< 2.6.7.3"))); + } + + @Test + void lessThanOrEqual_atBound() { + assertTrue(VersionRangeParser.matchesAny("2.6.7.3", Arrays.asList("<= 2.6.7.3"))); + } + + @Test + void lessThanOrEqual_aboveBound() { + assertFalse(VersionRangeParser.matchesAny("2.6.7.4", Arrays.asList("<= 2.6.7.3"))); + } + + @Test + void greaterThan_aboveBound() { + assertTrue(VersionRangeParser.matchesAny("9.5.1", Arrays.asList("> 9.5.0"))); + } + + @Test + void greaterThan_atBound() { + assertFalse(VersionRangeParser.matchesAny("9.5.0", Arrays.asList("> 9.5.0"))); + } + + @Test + void greaterThanOrEqual_atBound() { + assertTrue(VersionRangeParser.matchesAny("9.5.0", Arrays.asList(">= 9.5.0"))); + } + + @Test + void exactMatch_matches() { + assertTrue(VersionRangeParser.matchesAny("9.5.0", Arrays.asList("= 9.5.0"))); + } + + @Test + void exactMatch_doesNotMatch() { + assertFalse(VersionRangeParser.matchesAny("9.5.1", Arrays.asList("= 9.5.0"))); + } + + // --- compound condition (AND within one string) --- + + @Test + void compoundRange_withinBounds() { + assertTrue(VersionRangeParser.matchesAny("2.7.5", Arrays.asList(">= 2.7.0, < 2.7.9.5"))); + } + + @Test + void compoundRange_realGhsaJackson() { + List ranges = Arrays.asList("< 2.6.7.3", ">= 2.7.0, < 2.7.9.5", ">= 2.8.0, < 2.8.11.3"); + assertTrue(VersionRangeParser.matchesAny("2.8.5", ranges)); + assertTrue(VersionRangeParser.matchesAny("2.7.5", ranges)); + assertTrue(VersionRangeParser.matchesAny("2.6.0", ranges)); + assertFalse(VersionRangeParser.matchesAny("2.9.7", ranges)); + } + + @Test + void compoundRange_atLowerBound() { + assertTrue(VersionRangeParser.matchesAny("2.7.0", Arrays.asList(">= 2.7.0, < 2.7.9.5"))); + } + + @Test + void compoundRange_atUpperBound() { + assertFalse(VersionRangeParser.matchesAny("2.7.9.5", Arrays.asList(">= 2.7.0, < 2.7.9.5"))); + } + + @Test + void compoundRange_belowLowerBound() { + assertFalse(VersionRangeParser.matchesAny("2.6.9", Arrays.asList(">= 2.7.0, < 2.7.9.5"))); + } + + // --- OR across multiple range strings --- + + @Test + void multipleRanges_matchesFirstRange() { + List ranges = Arrays.asList("< 2.6.7.3", ">= 2.7.0, < 2.7.9.5"); + assertTrue(VersionRangeParser.matchesAny("2.6.0", ranges)); + } + + @Test + void multipleRanges_matchesSecondRange() { + List ranges = Arrays.asList("< 2.6.7.3", ">= 2.7.0, < 2.7.9.5"); + assertTrue(VersionRangeParser.matchesAny("2.7.5", ranges)); + } + + @Test + void multipleRanges_matchesNeitherRange() { + List ranges = Arrays.asList("< 2.6.7.3", ">= 2.7.0, < 2.7.9.5"); + assertFalse(VersionRangeParser.matchesAny("2.6.8", ranges)); + } + + // --- Maven qualifier handling (Gap 10) --- + + @Test + void releaseQualifier_belowBound() { + assertTrue(VersionRangeParser.matchesAny("5.2.19.RELEASE", Arrays.asList("< 5.2.20.RELEASE"))); + } + + @Test + void releaseQualifier_atBound() { + assertFalse(VersionRangeParser.matchesAny("5.2.20.RELEASE", Arrays.asList("< 5.2.20.RELEASE"))); + } + + @Test + void releaseQualifier_equivalentToPlain() { + // 5.2.20.RELEASE == 5.2.20 in Maven versioning + assertFalse(VersionRangeParser.matchesAny("5.2.20", Arrays.asList("< 5.2.20.RELEASE"))); + } + + @Test + void releaseQualifier_compoundRange() { + assertTrue(VersionRangeParser.matchesAny("5.3.10", Arrays.asList(">= 5.3.0, < 5.3.18"))); + assertFalse( + VersionRangeParser.matchesAny("5.2.20.RELEASE", Arrays.asList(">= 5.3.0, < 5.3.18"))); + } + + // --- 4-part versions --- + + @Test + void fourPartVersion() { + assertTrue(VersionRangeParser.matchesAny("2.6.7.2", Arrays.asList("< 2.6.7.3"))); + assertFalse(VersionRangeParser.matchesAny("2.6.7.3", Arrays.asList("< 2.6.7.3"))); + assertFalse(VersionRangeParser.matchesAny("2.6.7.4", Arrays.asList("< 2.6.7.3"))); + } + + // --- error handling --- + + @Test + void unknownOperatorThrows() { + assertThrows( + IllegalArgumentException.class, + () -> VersionRangeParser.matchesAny("1.0.0", Arrays.asList("~ 2.0.0"))); + } +} diff --git a/dd-smoke-tests/appsec/springboot/src/main/java/datadog/smoketest/appsec/springboot/ScaReachabilityInit.java b/dd-smoke-tests/appsec/springboot/src/main/java/datadog/smoketest/appsec/springboot/ScaReachabilityInit.java new file mode 100644 index 00000000000..1023e4d316c --- /dev/null +++ b/dd-smoke-tests/appsec/springboot/src/main/java/datadog/smoketest/appsec/springboot/ScaReachabilityInit.java @@ -0,0 +1,24 @@ +package datadog.smoketest.appsec.springboot; + +import javax.annotation.PostConstruct; +import org.springframework.stereotype.Component; + +/** + * Forces snakeyaml's Yaml class to load at application startup so that SCA Reachability can detect + * the dependency at boot time rather than waiting for the first request. + * + *

snakeyaml is on the classpath (used by Spring Boot's YAML support) but the smoke test app uses + * application.properties rather than application.yml, so Spring never triggers snakeyaml loading on + * its own. This component ensures the class is loaded during context initialization so the SCA + * transformer can register the CVE and the next telemetry heartbeat can report it. + */ +@Component +class ScaReachabilityInit { + + @PostConstruct + void init() { + // Instantiating Yaml loads org.yaml.snakeyaml.Yaml without calling any vulnerable method, + // so the SCA transformer registers the CVE with reached:[] (class-level detection only). + new org.yaml.snakeyaml.Yaml(); + } +} diff --git a/dd-smoke-tests/appsec/springboot/src/test/groovy/datadog/smoketest/appsec/ScaReachabilitySmokeTest.groovy b/dd-smoke-tests/appsec/springboot/src/test/groovy/datadog/smoketest/appsec/ScaReachabilitySmokeTest.groovy new file mode 100644 index 00000000000..5e0f0696446 --- /dev/null +++ b/dd-smoke-tests/appsec/springboot/src/test/groovy/datadog/smoketest/appsec/ScaReachabilitySmokeTest.groovy @@ -0,0 +1,107 @@ +package datadog.smoketest.appsec + +import groovy.json.JsonSlurper +import spock.lang.Shared + +/** + * Smoke test for SCA Reachability (DD_APPSEC_SCA_ENABLED=true). + * + * Verifies that the tracer reports vulnerable library classes via the + * app-dependencies-loaded telemetry heartbeat using the RFC stateful model: + * + * 1. At startup, vulnerable dependencies are reported with metadata: [{cve, reached:[]}] + * (signals the backend that SCA is monitoring those CVEs). + * + * The springboot smoke test app uses snakeyaml:1.29 (via Spring Boot 2.6.0), which falls + * in the vulnerable range "<= 1.33" for GHSA-mjmj-j48q-9wg2. ScaReachabilityInit instantiates + * org.yaml.snakeyaml.Yaml at startup (PostConstruct), triggering class-level CVE registration + * with reached:[]. The vulnerable methods (load, loadAll) are not called, so reached stays empty. + * + * Note: jackson-databind is also present but as version 2.13.0 (managed by Spring Boot BOM), + * which is outside the vulnerable ranges in sca_cves.json. snakeyaml is therefore the + * reliable test target. + */ +class ScaReachabilitySmokeTest extends AbstractAppSecServerSmokeTest { + + @Shared + String springBootShadowJar = System.getProperty("datadog.smoketest.appsec.springboot.shadowJar.path") + + @Override + ProcessBuilder createProcessBuilder() { + List command = new ArrayList<>() + command.add(javaPath()) + command.addAll(defaultJavaProperties) + command.addAll(defaultAppSecProperties) + // Enable SCA Reachability + command.add("-Ddd.appsec.sca.enabled=true") + command.addAll((String[]) ["-jar", springBootShadowJar, "--server.port=${httpPort}"]) + + ProcessBuilder processBuilder = new ProcessBuilder(command) + processBuilder.directory(new File(buildDirectory)) + return processBuilder + } + + void 'SCA reachability reports vulnerable snakeyaml via telemetry'() { + when: 'application starts and telemetry heartbeats arrive' + waitForTelemetryFlat({ event -> + if (event.get('request_type') != 'app-dependencies-loaded') { + return false + } + def deps = event.get('payload')?.get('dependencies') as List + deps?.any { dep -> + def d = dep as Map + d.get('name') == 'org.yaml:snakeyaml' && + (d.get('metadata') as List)?.any { (it as Map).get('type') == 'reachability' } + } + }) + + then: 'snakeyaml 1.29 appears with SCA reachability metadata' + // Collect all dependencies from all app-dependencies-loaded messages + def allDependencies = [] + telemetryFlatMessages.findAll { it.get('request_type') == 'app-dependencies-loaded' }.each { + def payload = it.get('payload') as Map + def deps = payload?.get('dependencies') as List + if (deps) { + allDependencies.addAll(deps) + } + } + + // Find the snakeyaml entry that has SCA reachability metadata. + // ScaReachabilityInit instantiates Yaml at startup (PostConstruct), loading the class + // and triggering CVE registration with reached:[]. + + def snakeyamlDep = allDependencies.find { dep -> + def d = dep as Map + d.get('name') == 'org.yaml:snakeyaml' && + (d.get('metadata') as List)?.any { (it as Map).get('type') == 'reachability' } + } as Map + + assert snakeyamlDep != null : + "snakeyaml must appear with SCA reachability metadata in app-dependencies-loaded" + assert snakeyamlDep.get('version') == '1.29' : "must be the vulnerable version 1.29" + + // Find the reachability metadata entry + def metadata = snakeyamlDep.get('metadata') as List + def reachabilityEntry = metadata.find { entry -> + (entry as Map).get('type') == 'reachability' + } as Map + + assert reachabilityEntry != null : "at least one reachability metadata entry expected" + + // Parse the stringified JSON value + def valueJson = reachabilityEntry.get('value') as String + assert valueJson != null && !valueJson.isEmpty() : "value must not be empty" + + def reachabilityPayload = new JsonSlurper().parseText(valueJson) as Map + assert reachabilityPayload.get('id') != null : "CVE id must be present" + assert reachabilityPayload.get('id').toString().startsWith('GHSA-') : + "id must be a GHSA identifier, got: ${reachabilityPayload.get('id')}" + assert reachabilityPayload.get('reached') instanceof List : "reached must be a list" + + // snakeyaml has method-level symbols (load, loadAll). ScaReachabilityInit calls new Yaml() + // (constructor only) so load/loadAll are never triggered — reached must stay empty. + def reached = reachabilityPayload.get('reached') as List + assert reached.isEmpty() : + "load/loadAll not called at startup — reached must be [] (class-level detection only)" + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/telemetry/ScaReachabilityDependencyRegistry.java b/internal-api/src/main/java/datadog/trace/api/telemetry/ScaReachabilityDependencyRegistry.java new file mode 100644 index 00000000000..68a772c4022 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/telemetry/ScaReachabilityDependencyRegistry.java @@ -0,0 +1,197 @@ +package datadog.trace.api.telemetry; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Stateful registry for SCA Reachability, implementing the RFC heartbeat model. + * + *

The RFC requires a stateful flow: + * + *

    + *
  1. When a class from a vulnerable version is loaded: register the CVE with {@code reached=[]} + * and mark as pending — the backend learns that SCA is monitoring this dependency. + *
  2. When a vulnerable method is called: record the callsite, mark as pending. + *
  3. On each heartbeat: report ALL CVEs for every dependency that has pending changes (including + * those with empty {@code reached}) then clear pending. Empty heartbeat otherwise. + *
+ * + *

Pattern mirrors {@code WafMetricCollector}: lives in {@code internal-api}, accessible by both + * the {@code appsec} writer and the {@code telemetry} reader without circular dependencies. + */ +public final class ScaReachabilityDependencyRegistry { + + public static final ScaReachabilityDependencyRegistry INSTANCE = + new ScaReachabilityDependencyRegistry(); + + /** Keyed by {@link #depKey(String, String)}. */ + private final ConcurrentHashMap dependencies = new ConcurrentHashMap<>(); + + public static String depKey(String artifact, String version) { + return artifact + "@" + version; + } + + /** + * Optional periodic work hook for retransformation of pending method-level classes. Registered by + * {@code ScaReachabilitySystem}, called by {@code ScaReachabilityPeriodicAction}. + */ + private volatile Runnable periodicWorkCallback; + + public void setPeriodicWorkCallback(Runnable callback) { + periodicWorkCallback = callback; + } + + public Runnable getPeriodicWorkCallback() { + return periodicWorkCallback; + } + + /** Clears all state. Used in tests to reset between test cases. */ + public void resetForTesting() { + dependencies.clear(); + } + + private ScaReachabilityDependencyRegistry() {} + + /** + * Registers a CVE for a dependency when a class from a vulnerable version is loaded. Creates a + * new entry with {@code reached=[]} if not already present. Marks the dependency as pending so + * the next heartbeat reports it (signalling that SCA is monitoring this CVE). + * + *

Called by {@code ScaReachabilityTransformer} on class load (class-level symbols) and before + * bytecode injection (method-level symbols). + */ + public void registerCve(String artifact, String version, String vulnId) { + String key = depKey(artifact, version); + DependencyState dep = + dependencies.computeIfAbsent(key, k -> new DependencyState(artifact, version)); + dep.registerCve(vulnId); + } + + /** + * Records the first callsite that triggered a vulnerable method. Only the first hit per CVE is + * stored (RFC: "reporting a single occurrence is sufficient"). Marks the dependency as pending. + * + *

Called by {@code ScaReachabilitySystem} handler when injected bytecode fires. + */ + public void recordHit( + String artifact, + String version, + String vulnId, + String callsiteClass, + String callsiteSymbol, + int callsiteLine) { + dependencies + .computeIfAbsent(depKey(artifact, version), k -> new DependencyState(artifact, version)) + .recordHit(vulnId, callsiteClass, callsiteSymbol, callsiteLine); + } + + /** + * Returns a snapshot of all dependencies that have pending changes since the last drain, then + * clears the pending flag. Called by {@code ScaReachabilityPeriodicAction} on each heartbeat. + * + *

Each returned {@link DependencySnapshot} contains ALL CVEs for that dependency (both with + * and without callsite hits), as required by the RFC stateful model. + */ + public List drainPendingDependencies() { + List result = new ArrayList<>(); + for (DependencyState dep : dependencies.values()) { + DependencySnapshot snapshot = dep.drainIfPending(); + if (snapshot != null) { + result.add(snapshot); + } + } + return result; + } + + // --------------------------------------------------------------------------- + // Internal state classes + // --------------------------------------------------------------------------- + + /** Mutable state for one (artifact, version) dependency. Thread-safe. */ + public static final class DependencyState { + public final String artifact; + public final String version; + + /** CVE ID → first callsite hit, or {@code null} if not yet reached. */ + private final ConcurrentHashMap cves = new ConcurrentHashMap<>(); + + private volatile boolean pendingReport = false; + + DependencyState(String artifact, String version) { + this.artifact = artifact; + this.version = version; + } + + void registerCve(String vulnId) { + cves.computeIfAbsent(vulnId, k -> new CveState()); + pendingReport = true; + } + + void recordHit(String vulnId, String callsiteClass, String callsiteSymbol, int callsiteLine) { + CveState state = cves.computeIfAbsent(vulnId, k -> new CveState()); + // compareAndSet guarantees exactly one callsite is stored (first hit wins). + // A plain volatile check-then-assign would allow two threads racing on different + // methods of the same CVE to both see null and both write, violating the invariant. + ScaReachabilityHit newHit = + new ScaReachabilityHit( + vulnId, artifact, version, callsiteClass, callsiteSymbol, callsiteLine); + if (state.hitRef.compareAndSet(null, newHit)) { + pendingReport = true; + } + } + + /** + * Returns a snapshot if pending, then clears the pending flag. Returns null if nothing to + * report. + */ + DependencySnapshot drainIfPending() { + if (!pendingReport) { + return null; + } + pendingReport = false; + List cveSnapshots = new ArrayList<>(cves.size()); + for (java.util.Map.Entry entry : cves.entrySet()) { + cveSnapshots.add(new CveSnapshot(entry.getKey(), entry.getValue().hitRef.get())); + } + return new DependencySnapshot(artifact, version, Collections.unmodifiableList(cveSnapshots)); + } + } + + /** Mutable state for one CVE within a dependency. */ + static final class CveState { + /** + * First callsite hit, or {@code null} if not yet reached. AtomicReference ensures compareAndSet + * atomicity so exactly one thread wins the "first hit" race. + */ + final AtomicReference hitRef = new AtomicReference<>(null); + } + + /** Immutable snapshot of a dependency's CVE state at drain time. */ + public static final class DependencySnapshot { + public final String artifact; + public final String version; + + /** All CVEs for this dependency: hit==null means known but not reached yet. */ + public final List cves; + + DependencySnapshot(String artifact, String version, List cves) { + this.artifact = artifact; + this.version = version; + this.cves = cves; + } + } + + /** Snapshot of one CVE: hit is null if not yet reached. */ + public static final class CveSnapshot { + public final String vulnId; + public final ScaReachabilityHit hit; // null = reached:[] + + public CveSnapshot(String vulnId, ScaReachabilityHit hit) { + this.vulnId = vulnId; + this.hit = hit; + } + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/telemetry/ScaReachabilityHit.java b/internal-api/src/main/java/datadog/trace/api/telemetry/ScaReachabilityHit.java new file mode 100644 index 00000000000..690ad7b30b2 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/telemetry/ScaReachabilityHit.java @@ -0,0 +1,90 @@ +package datadog.trace.api.telemetry; + +/** + * A single SCA reachability detection: a vulnerable class from a known artifact was loaded at + * runtime. Produced by {@code ScaReachabilityTransformer} and consumed by {@code + * ScaReachabilityPeriodicAction} to build the telemetry payload. + */ +public final class ScaReachabilityHit { + + /** + * JVM internal name for the class initializer. Used as the {@code symbolName} for class-level + * hits where no specific method was targeted (detection fires at class load time). + */ + public static final String CLASS_LEVEL_SYMBOL = ""; + + private final String vulnId; + private final String artifact; + private final String version; + // For class-level hits: the vulnerable library class (FQN, dot notation) + // For method-level hits: the APPLICATION class that called the vulnerable method (callsite) + private final String className; + // For class-level hits: CLASS_LEVEL_SYMBOL ("") + // For method-level hits: the APPLICATION method that called the vulnerable method (callsite) + private final String symbolName; + // For class-level hits: 1 (placeholder — no callsite at class load time) + // For method-level hits: line number in the application code of the call + private final int line; + + /** + * Convenience constructor for class-level hits ({@code symbolName = CLASS_LEVEL_SYMBOL}, line = + * 1). + */ + public ScaReachabilityHit(String vulnId, String artifact, String version, String className) { + this(vulnId, artifact, version, className, CLASS_LEVEL_SYMBOL, 1); + } + + public ScaReachabilityHit( + String vulnId, + String artifact, + String version, + String className, + String symbolName, + int line) { + this.vulnId = vulnId; + this.artifact = artifact; + this.version = version; + this.className = className; + this.symbolName = symbolName; + this.line = line; + } + + /** GHSA identifier, e.g. {@code "GHSA-645p-88qh-w398"}. */ + public String vulnId() { + return vulnId; + } + + /** Maven coordinate, e.g. {@code "com.fasterxml.jackson.core:jackson-databind"}. */ + public String artifact() { + return artifact; + } + + public String version() { + return version; + } + + /** + * For class-level hits: FQN of the vulnerable library class (dot notation). For method-level + * hits: FQN of the APPLICATION class that called the vulnerable method (callsite), not the + * vulnerable class itself. + */ + public String className() { + return className; + } + + /** + * For class-level hits: {@link #CLASS_LEVEL_SYMBOL} ({@code ""}). For method-level hits: + * the APPLICATION method that called the vulnerable method (callsite). + */ + public String symbolName() { + return symbolName; + } + + /** + * For class-level hits: {@code 1} (placeholder — no specific callsite at class load time). For + * method-level hits: line number in the application code where the call was made. + */ + public int line() { + return line; + } +} diff --git a/internal-api/src/main/java/datadog/trace/util/stacktrace/AbstractStackWalker.java b/internal-api/src/main/java/datadog/trace/util/stacktrace/AbstractStackWalker.java index fc248169641..34a002f47bb 100644 --- a/internal-api/src/main/java/datadog/trace/util/stacktrace/AbstractStackWalker.java +++ b/internal-api/src/main/java/datadog/trace/util/stacktrace/AbstractStackWalker.java @@ -16,7 +16,7 @@ final Stream doFilterStack(Stream stream) abstract T doGetStack(Function, T> consumer); - static boolean isNotDatadogTraceStackElement(final StackTraceElement el) { + public static boolean isNotDatadogTraceStackElement(final StackTraceElement el) { final String clazz = el.getClassName(); return !clazz.startsWith("datadog.trace.") && !clazz.startsWith("com.datadog.iast.") diff --git a/internal-api/src/test/java/datadog/trace/api/telemetry/ScaReachabilityDependencyRegistryTest.java b/internal-api/src/test/java/datadog/trace/api/telemetry/ScaReachabilityDependencyRegistryTest.java new file mode 100644 index 00000000000..459fbbf7d4a --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/telemetry/ScaReachabilityDependencyRegistryTest.java @@ -0,0 +1,98 @@ +package datadog.trace.api.telemetry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ScaReachabilityDependencyRegistryTest { + + @BeforeEach + void setUp() { + ScaReachabilityDependencyRegistry.INSTANCE.resetForTesting(); + } + + @AfterEach + void tearDown() { + ScaReachabilityDependencyRegistry.INSTANCE.resetForTesting(); + } + + /** + * Regression test for the first-hit-wins thread-safety race. + * + *

Before the fix (volatile + check-then-set), two threads calling {@code recordHit} for the + * same CVE from different methods could both observe {@code hit == null} simultaneously and both + * write, with the second overwriting the first. The fix uses {@link + * java.util.concurrent.atomic.AtomicReference#compareAndSet} to guarantee exactly one thread + * wins. + * + *

This test starts N threads simultaneously, each recording a hit for the same CVE but from a + * different callsite. After all threads complete, exactly one callsite must be recorded. + */ + @Test + void recordHit_concurrentCallsForSameCve_exactlyOneCallsiteStored() throws InterruptedException { + int threadCount = 20; + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-test"); + + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + ExecutorService pool = Executors.newFixedThreadPool(threadCount); + + for (int i = 0; i < threadCount; i++) { + final int idx = i; + pool.submit( + () -> { + try { + startLatch.await(); // wait until all threads are ready + ScaReachabilityDependencyRegistry.INSTANCE.recordHit( + "com.example:lib", + "1.0.0", + "GHSA-test", + "com.myapp.Controller" + idx, + "method" + idx, + idx); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + doneLatch.countDown(); + } + }); + } + + startLatch.countDown(); // release all threads simultaneously + doneLatch.await(10, TimeUnit.SECONDS); + pool.shutdown(); + + List snapshots = + ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); + + assertEquals(1, snapshots.size(), "exactly one dep snapshot"); + ScaReachabilityDependencyRegistry.DependencySnapshot dep = snapshots.get(0); + assertEquals(1, dep.cves.size(), "exactly one CVE"); + + ScaReachabilityDependencyRegistry.CveSnapshot cve = dep.cves.get(0); + assertNotNull(cve.hit, "exactly one hit must have been recorded"); + + // Verify the recorded callsite is one of the N valid options + String recordedClass = cve.hit.className(); + String recordedSymbol = cve.hit.symbolName(); + boolean isValidCallsite = false; + for (int i = 0; i < threadCount; i++) { + if (("com.myapp.Controller" + i).equals(recordedClass) + && ("method" + i).equals(recordedSymbol)) { + isValidCallsite = true; + break; + } + } + assertTrue( + isValidCallsite, "recorded callsite must be one of the " + threadCount + " valid options"); + } +} diff --git a/telemetry/build.gradle.kts b/telemetry/build.gradle.kts index 1b66facc063..17578a55877 100644 --- a/telemetry/build.gradle.kts +++ b/telemetry/build.gradle.kts @@ -49,6 +49,7 @@ dependencies { api(libs.moshi) testImplementation(project(":utils:test-utils")) + testImplementation(libs.bundles.mockito) testImplementation(group = "org.jboss", name = "jboss-vfs", version = "3.2.16.Final") } diff --git a/telemetry/src/main/java/datadog/telemetry/TelemetryRequestBody.java b/telemetry/src/main/java/datadog/telemetry/TelemetryRequestBody.java index 3c3cf3d35e8..40bd2e46471 100644 --- a/telemetry/src/main/java/datadog/telemetry/TelemetryRequestBody.java +++ b/telemetry/src/main/java/datadog/telemetry/TelemetryRequestBody.java @@ -271,6 +271,19 @@ public void writeDependency(Dependency d) throws IOException { bodyWriter.name("hash").value(d.hash); // optional bodyWriter.name("name").value(d.name); bodyWriter.name("version").value(d.version); // optional + if (d.reachabilityMetadata != null) { + // Write metadata array even when empty: empty list signals "SCA is active for this dep" + // (RFC: all deps get metadata:[] at startup when DD_APPSEC_SCA_ENABLED=true). + // Null means SCA is disabled; empty list means SCA is enabled but no CVEs detected yet. + bodyWriter.name("metadata").beginArray(); + for (String value : d.reachabilityMetadata) { + bodyWriter.beginObject(); + bodyWriter.name("type").value("reachability"); + bodyWriter.name("value").value(value); // stringified JSON per RFC + bodyWriter.endObject(); + } + bodyWriter.endArray(); + } bodyWriter.endObject(); } diff --git a/telemetry/src/main/java/datadog/telemetry/TelemetrySystem.java b/telemetry/src/main/java/datadog/telemetry/TelemetrySystem.java index d49d9c21bcc..de6b2feed19 100644 --- a/telemetry/src/main/java/datadog/telemetry/TelemetrySystem.java +++ b/telemetry/src/main/java/datadog/telemetry/TelemetrySystem.java @@ -19,6 +19,7 @@ import datadog.telemetry.metric.WafMetricPeriodicAction; import datadog.telemetry.products.ProductChangeAction; import datadog.telemetry.rum.RumPeriodicAction; +import datadog.telemetry.sca.ScaReachabilityPeriodicAction; import datadog.trace.api.Config; import datadog.trace.api.InstrumenterConfig; import datadog.trace.api.civisibility.config.BazelMode; @@ -75,7 +76,14 @@ static Thread createTelemetryRunnable( } } if (null != dependencyService) { - actions.add(new DependencyPeriodicAction(dependencyService)); + if (Config.get().isAppSecScaEnabled()) { + // ScaReachabilityPeriodicAction takes over all dep reporting when SCA is enabled: + // it merges DependencyService drains with CVE registry state into one entry per dep. + // DependencyPeriodicAction is skipped to avoid duplicate app-dependencies-loaded entries. + actions.add(new ScaReachabilityPeriodicAction(dependencyService)); + } else { + actions.add(new DependencyPeriodicAction(dependencyService)); + } } if (Config.get().isTelemetryLogCollectionEnabled()) { actions.add(new LogPeriodicAction()); diff --git a/telemetry/src/main/java/datadog/telemetry/dependency/Dependency.java b/telemetry/src/main/java/datadog/telemetry/dependency/Dependency.java index fbf30ae7ffe..7d8b36bf489 100644 --- a/telemetry/src/main/java/datadog/telemetry/dependency/Dependency.java +++ b/telemetry/src/main/java/datadog/telemetry/dependency/Dependency.java @@ -46,11 +46,32 @@ public final class Dependency { public final String source; public final String hash; + /** + * Optional SCA reachability metadata. Each entry is a stringified JSON object conforming to the + * RFC reachability payload: {@code + * {"id":"GHSA-xxx","reached":[{"path":"...","symbol":"","line":1}]}}. Null for regular + * dependencies; non-null only when injected by {@code ScaReachabilityPeriodicAction}. Not + * serialized by the telemetry pipeline unless explicitly written by {@link + * datadog.telemetry.TelemetryRequestBody#writeDependency}. + */ + @Nullable public final List reachabilityMetadata; + public Dependency(String name, String version, String source, @Nullable String hash) { + this(name, version, source, hash, null); + } + + public Dependency( + String name, + String version, + String source, + @Nullable String hash, + @Nullable List reachabilityMetadata) { this.name = name; this.version = version; this.source = source; this.hash = hash; + this.reachabilityMetadata = + reachabilityMetadata != null ? Collections.unmodifiableList(reachabilityMetadata) : null; } @Override diff --git a/telemetry/src/main/java/datadog/telemetry/sca/ScaReachabilityPeriodicAction.java b/telemetry/src/main/java/datadog/telemetry/sca/ScaReachabilityPeriodicAction.java new file mode 100644 index 00000000000..057b3572d35 --- /dev/null +++ b/telemetry/src/main/java/datadog/telemetry/sca/ScaReachabilityPeriodicAction.java @@ -0,0 +1,183 @@ +package datadog.telemetry.sca; + +import datadog.telemetry.TelemetryRunnable; +import datadog.telemetry.TelemetryService; +import datadog.telemetry.dependency.Dependency; +import datadog.telemetry.dependency.DependencyService; +import datadog.trace.api.telemetry.ScaReachabilityDependencyRegistry; +import datadog.trace.api.telemetry.ScaReachabilityDependencyRegistry.CveSnapshot; +import datadog.trace.api.telemetry.ScaReachabilityDependencyRegistry.DependencySnapshot; +import datadog.trace.api.telemetry.ScaReachabilityHit; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Reports SCA Reachability state on each telemetry heartbeat, implementing the RFC stateful model. + * + *

When SCA is enabled this action replaces {@link + * datadog.telemetry.dependency.DependencyPeriodicAction} as the sole emitter of {@code + * app-dependencies-loaded} events. It merges two sources into a single entry per dependency per + * heartbeat: + * + *

    + *
  1. {@link DependencyService} - newly detected JARs. Emitted with {@code metadata:[]} if no CVE + * state exists yet, or merged with CVE metadata if a state is already pending. Each resolved + * dependency is stored in {@link #knownDeps} for future lookups. + *
  2. {@link ScaReachabilityDependencyRegistry} - CVE state changes (registration and hits). + *
+ * + *

This ensures one entry per {@code name:version} per heartbeat - no duplicates. + * + *

The key invariant: whenever any CVE's state changes, ALL CVEs for the same dependency are + * re-reported together so the backend always has a complete picture. + * + *

Timing invariant

+ * + *

{@link DependencyService} resolves JARs asynchronously. A CVE may be registered before the + * corresponding JAR has been resolved. In that case, {@link #knownDeps} may not yet have an entry + * for the dep. Unmatched snapshots are emitted immediately without source/hash so CVE data is never + * delayed; when the dep is eventually resolved and stored in {@link #knownDeps}, subsequent CVE + * emissions (e.g., after a method hit) will include source/hash automatically. + */ +public final class ScaReachabilityPeriodicAction + implements TelemetryRunnable.TelemetryPeriodicAction { + + private final DependencyService dependencyService; + + /** + * Persistent across heartbeats: accumulates every dep resolved by {@link DependencyService}. + * Keyed by {@link ScaReachabilityDependencyRegistry#depKey(String, String)}. + * + *

This cache allows Step 3 to enrich CVE snapshots with {@code source} and {@code hash} even + * when the dep was drained from {@link DependencyService} in an earlier heartbeat than the CVE + * hit. + */ + private final Map knownDeps = new HashMap<>(); + + public ScaReachabilityPeriodicAction(DependencyService dependencyService) { + this.dependencyService = dependencyService; + } + + /** + * Pre-populates {@link #knownDeps} with a dep entry. Used in tests to simulate a dep that was + * resolved by {@link DependencyService} in a prior heartbeat without running a full iteration. + */ + void addKnownDepForTesting(String name, String version) { + knownDeps.put( + ScaReachabilityDependencyRegistry.depKey(name, version), + new Dependency(name, version, null, null)); + } + + @Override + public void doIteration(TelemetryService telService) { + // Trigger pending retransformations (method-level symbols on already-loaded classes, or + // classes where JAR version resolution failed at load time and needs a retry). + Runnable work = ScaReachabilityDependencyRegistry.INSTANCE.getPeriodicWorkCallback(); + if (work != null) { + work.run(); + } + + // Step 1: drain registry → map keyed by "artifact@version" for O(1) lookup below. + List pending = + ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); + Map snapshotByKey = new HashMap<>(pending.size() * 2); + for (DependencySnapshot snapshot : pending) { + snapshotByKey.put( + ScaReachabilityDependencyRegistry.depKey(snapshot.artifact, snapshot.version), snapshot); + } + + // Step 2: drain DependencyService (newly detected JARs this heartbeat). + // Store each dep in knownDeps regardless of whether it has a CVE match — future heartbeats + // with CVE hits will look it up in Step 3. + if (dependencyService != null) { + for (Dependency dep : dependencyService.drainDeterminedDependencies()) { + String key = ScaReachabilityDependencyRegistry.depKey(dep.name, dep.version); + knownDeps.put(key, dep); + DependencySnapshot snapshot = snapshotByKey.remove(key); + if (snapshot != null) { + // New dep AND has CVE state - emit the full picture in one entry. + telService.addDependency( + new Dependency(dep.name, dep.version, dep.source, dep.hash, buildMetadata(snapshot))); + } else { + // New dep, no CVE state yet - metadata:[] signals "SCA is monitoring this dep". + telService.addDependency( + new Dependency(dep.name, dep.version, dep.source, dep.hash, Collections.emptyList())); + } + } + } + + // Step 3: handle CVE state changes for deps not in DependencyService this heartbeat. + // Always emit — never block CVE data. Use knownDeps for source/hash enrichment when the JAR + // was resolved in a prior heartbeat; otherwise emit without source/hash so the backend still + // receives the CVE state (it may correlate by name:version alone, and subsequent emissions + // for the same dep — triggered by method hits — will carry source/hash once knownDeps is + // populated). + for (DependencySnapshot snapshot : snapshotByKey.values()) { + String key = ScaReachabilityDependencyRegistry.depKey(snapshot.artifact, snapshot.version); + Dependency known = knownDeps.get(key); + if (known != null) { + // Dep was resolved in a prior heartbeat — emit enriched with source/hash. + telService.addDependency( + new Dependency( + known.name, known.version, known.source, known.hash, buildMetadata(snapshot))); + } else { + // Dep not yet resolved — emit without source/hash so CVE data is not delayed. + // When the dep is eventually resolved (stored in knownDeps via Step 2), subsequent + // CVE emissions (e.g., after a method hit) will include source/hash automatically. + telService.addDependency( + new Dependency( + snapshot.artifact, snapshot.version, null, null, buildMetadata(snapshot))); + } + } + } + + private static List buildMetadata(DependencySnapshot snapshot) { + List metadataValues = new ArrayList<>(snapshot.cves.size()); + for (CveSnapshot cve : snapshot.cves) { + metadataValues.add(buildMetadataValue(cve)); + } + return metadataValues; + } + + /** + * Builds the stringified JSON value for one CVE snapshot, per RFC: + * + *

    + *
  • Not yet reached: {@code {"id":"GHSA-xxx","reached":[]}} + *
  • Reached: {@code + * {"id":"GHSA-xxx","reached":[{"path":"com.foo.Bar","symbol":"...","line":N}]}} + *
+ * + *

Values are JSON-escaped via {@link #jsonEscape} even though GHSA IDs, JVM class names, and + * method names are structurally guaranteed not to contain {@code "} or {@code \} by the JVM spec. + * The escaping is a safety net against future changes to the value sources. + */ + static String buildMetadataValue(CveSnapshot cve) { + ScaReachabilityHit hit = cve.hit; + if (hit == null) { + // CVE known but no callsite yet - signals "monitoring, not reached" + return "{\"id\":\"" + jsonEscape(cve.vulnId) + "\",\"reached\":[]}"; + } + // CVE has been reached - include the callsite + return "{\"id\":\"" + + jsonEscape(hit.vulnId()) + + "\",\"reached\":[{\"path\":\"" + + jsonEscape(hit.className()) + + "\",\"symbol\":\"" + + jsonEscape(hit.symbolName()) + + "\",\"line\":" + + hit.line() + + "}]}"; + } + + /** Escapes a string for embedding in a JSON string literal. */ + private static String jsonEscape(String value) { + if (value.indexOf('"') == -1 && value.indexOf('\\') == -1) { + return value; // fast path: no escaping needed (the common case) + } + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } +} diff --git a/telemetry/src/test/groovy/datadog/telemetry/dependency/DependencyPeriodActionSpecification.groovy b/telemetry/src/test/groovy/datadog/telemetry/dependency/DependencyPeriodActionSpecification.groovy index 88ac3c65cf1..c74e8bb6cf2 100644 --- a/telemetry/src/test/groovy/datadog/telemetry/dependency/DependencyPeriodActionSpecification.groovy +++ b/telemetry/src/test/groovy/datadog/telemetry/dependency/DependencyPeriodActionSpecification.groovy @@ -17,7 +17,8 @@ class DependencyPeriodActionSpecification extends DDSpecification { 1 * telemetryService.addDependency({ Dependency dep -> dep.name == 'name' && dep.version == '1.2.3' && - dep.hash == 'DEADBEEF' + dep.hash == 'DEADBEEF' && + dep.reachabilityMetadata == null }) 0 * _._ } diff --git a/telemetry/src/test/java/datadog/telemetry/TelemetryRequestBodyDependencyMetadataTest.java b/telemetry/src/test/java/datadog/telemetry/TelemetryRequestBodyDependencyMetadataTest.java new file mode 100644 index 00000000000..9751aaf68d6 --- /dev/null +++ b/telemetry/src/test/java/datadog/telemetry/TelemetryRequestBodyDependencyMetadataTest.java @@ -0,0 +1,96 @@ +package datadog.telemetry; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.telemetry.api.RequestType; +import datadog.telemetry.dependency.Dependency; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import okio.Buffer; +import org.junit.jupiter.api.Test; + +/** + * Verifies that {@link TelemetryRequestBody#writeDependency} correctly serializes the optional + * {@code metadata} array introduced for SCA Reachability. + */ +class TelemetryRequestBodyDependencyMetadataTest { + + @Test + void writeDependency_includesMetadataArrayWhenPresent() throws IOException { + String metadataValue = + "{\"id\":\"GHSA-645p-88qh-w398\"," + + "\"reached\":[{\"path\":\"com.fasterxml.jackson.databind.ObjectMapper\"," + + "\"symbol\":\"\",\"line\":1}]}"; + Dependency dep = + new Dependency( + "com.fasterxml.jackson.core:jackson-databind", + "2.8.5", + null, + null, + Collections.singletonList(metadataValue)); + + String json = serializeDependency(dep); + + assertTrue(json.contains("\"metadata\""), "metadata array must be present"); + assertTrue(json.contains("\"type\":\"reachability\""), "type field must be reachability"); + assertTrue(json.contains("\"value\":"), "value field must be present"); + assertTrue(json.contains("GHSA-645p-88qh-w398"), "GHSA ID must appear in value"); + } + + @Test + void writeDependency_includesAllMetadataEntriesForMultipleCves() throws IOException { + Dependency dep = + new Dependency( + "com.example:lib", + "1.0.0", + null, + null, + Arrays.asList( + "{\"id\":\"GHSA-aaa-1111-2222\",\"reached\":[]}", + "{\"id\":\"GHSA-bbb-3333-4444\",\"reached\":[]}")); + + String json = serializeDependency(dep); + + assertTrue(json.contains("GHSA-aaa-1111-2222"), "first CVE must be present"); + assertTrue(json.contains("GHSA-bbb-3333-4444"), "second CVE must be present"); + } + + @Test + void writeDependency_omitsMetadataFieldWhenNull() throws IOException { + Dependency dep = new Dependency("com.example:lib", "1.0.0", null, null); + + String json = serializeDependency(dep); + + assertFalse(json.contains("\"metadata\""), "metadata field must be absent when null"); + } + + @Test + void writeDependency_includesEmptyMetadataArrayWhenListIsEmpty() throws IOException { + // RFC: metadata:[] (non-null, empty) means "SCA is active for this dep but no CVEs detected". + // Must be written so the backend knows SCA is monitoring the dependency. + Dependency dep = + new Dependency("com.example:lib", "1.0.0", null, null, Collections.emptyList()); + + String json = serializeDependency(dep); + + assertTrue(json.contains("\"metadata\":[]"), "metadata:[] must be present when list is empty"); + } + + private static String serializeDependency(Dependency dep) throws IOException { + TelemetryRequestBody req = new TelemetryRequestBody(RequestType.APP_DEPENDENCIES_LOADED); + req.beginRequest(false); + req.beginDependencies(); + req.writeDependency(dep); + req.endDependencies(); + req.endRequest(); + + Buffer buf = new Buffer(); + req.writeTo(buf); + byte[] bytes = new byte[(int) buf.size()]; + buf.read(bytes); + return new String(bytes, StandardCharsets.UTF_8); + } +} diff --git a/telemetry/src/test/java/datadog/telemetry/sca/ScaReachabilityPeriodicActionTest.java b/telemetry/src/test/java/datadog/telemetry/sca/ScaReachabilityPeriodicActionTest.java new file mode 100644 index 00000000000..cc97c341d93 --- /dev/null +++ b/telemetry/src/test/java/datadog/telemetry/sca/ScaReachabilityPeriodicActionTest.java @@ -0,0 +1,455 @@ +package datadog.telemetry.sca; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentCaptor.forClass; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import datadog.telemetry.TelemetryService; +import datadog.telemetry.dependency.Dependency; +import datadog.telemetry.dependency.DependencyService; +import datadog.trace.api.telemetry.ScaReachabilityDependencyRegistry; +import datadog.trace.api.telemetry.ScaReachabilityHit; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class ScaReachabilityPeriodicActionTest { + + private TelemetryService telService; + private ScaReachabilityPeriodicAction action; + + @BeforeEach + void setUp() { + ScaReachabilityDependencyRegistry.INSTANCE.resetForTesting(); + telService = mock(TelemetryService.class); + DependencyService depService = mock(DependencyService.class); + when(depService.drainDeterminedDependencies()).thenReturn(Collections.emptyList()); + action = new ScaReachabilityPeriodicAction(depService); + // Pre-populate knownDeps with the common test dep so registry-only tests can emit via Step 3. + // This simulates the dep having been resolved by DependencyService in a prior heartbeat. + action.addKnownDepForTesting("com.example:lib", "1.0.0"); + action.addKnownDepForTesting("com.example:lib-a", "1.0.0"); + action.addKnownDepForTesting("com.example:lib-b", "2.0.0"); + action.addKnownDepForTesting("com.other:lib", "2.0.0"); + } + + @Test + void doesNothingWhenNoPendingDependencies() { + action.doIteration(telService); + verify(telService, never()).addDependency(org.mockito.Mockito.any()); + } + + @Test + void reportsRegisteredCveWithEmptyReached() { + // CVE registered but no hit yet → metadata: [{cve-1, reached:[]}] + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-xxx"); + + action.doIteration(telService); + + ArgumentCaptor captor = forClass(Dependency.class); + verify(telService, times(1)).addDependency(captor.capture()); + Dependency dep = captor.getValue(); + assertEquals("com.example:lib", dep.name); + assertEquals(1, dep.reachabilityMetadata.size()); + assertTrue( + dep.reachabilityMetadata.get(0).contains("\"reached\":[]"), + "CVE with no hit must have reached:[]"); + assertTrue(dep.reachabilityMetadata.get(0).contains("\"id\":\"GHSA-xxx\"")); + } + + @Test + void reportsRegisteredCveWithCallsiteAfterHit() { + // CVE registered, then hit → metadata: [{cve-1, reached:[callsite]}] + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-xxx"); + ScaReachabilityDependencyRegistry.INSTANCE.recordHit( + "com.example:lib", "1.0.0", "GHSA-xxx", "com.myapp.Service", "process", 42); + + action.doIteration(telService); + + ArgumentCaptor captor = forClass(Dependency.class); + verify(telService, times(1)).addDependency(captor.capture()); + String metaValue = captor.getValue().reachabilityMetadata.get(0); + assertTrue(metaValue.contains("\"path\":\"com.myapp.Service\"")); + assertTrue(metaValue.contains("\"symbol\":\"process\"")); + assertTrue(metaValue.contains("\"line\":42")); + assertFalse(metaValue.contains("\"reached\":[]"), "Hit must not produce empty reached"); + } + + @Test + void groupsTwoCvesForSameArtifactIntoOneEntry() { + ScaReachabilityDependencyRegistry.INSTANCE.registerCve( + "com.example:lib", "1.0.0", "GHSA-cve-1"); + ScaReachabilityDependencyRegistry.INSTANCE.registerCve( + "com.example:lib", "1.0.0", "GHSA-cve-2"); + + action.doIteration(telService); + + ArgumentCaptor captor = forClass(Dependency.class); + verify(telService, times(1)).addDependency(captor.capture()); + Dependency dep = captor.getValue(); + assertEquals(2, dep.reachabilityMetadata.size()); + assertTrue(dep.reachabilityMetadata.stream().anyMatch(v -> v.contains("GHSA-cve-1"))); + assertTrue(dep.reachabilityMetadata.stream().anyMatch(v -> v.contains("GHSA-cve-2"))); + } + + @Test + void reportsAllCvesWhenOneIsHit() { + // RFC requirement: when cve-1 is hit, re-report BOTH cve-1 (with callsite) and cve-2 (empty) + ScaReachabilityDependencyRegistry.INSTANCE.registerCve( + "com.example:lib", "1.0.0", "GHSA-cve-1"); + ScaReachabilityDependencyRegistry.INSTANCE.registerCve( + "com.example:lib", "1.0.0", "GHSA-cve-2"); + // First heartbeat: both sent with empty reached + action.doIteration(telService); + + // Now hit cve-1 + ScaReachabilityDependencyRegistry.INSTANCE.recordHit( + "com.example:lib", "1.0.0", "GHSA-cve-1", "com.myapp.Svc", "call", 10); + + // Second heartbeat: BOTH CVEs re-reported — cve-1 with callsite, cve-2 still empty + action.doIteration(telService); + + ArgumentCaptor captor = forClass(Dependency.class); + verify(telService, times(2)).addDependency(captor.capture()); + List reported = captor.getAllValues(); + Dependency secondReport = reported.get(1); + assertEquals(2, secondReport.reachabilityMetadata.size()); + // cve-1 now has a callsite + assertTrue( + secondReport.reachabilityMetadata.stream() + .anyMatch(v -> v.contains("GHSA-cve-1") && v.contains("\"path\""))); + // cve-2 still has empty reached + assertTrue( + secondReport.reachabilityMetadata.stream() + .anyMatch(v -> v.contains("GHSA-cve-2") && v.contains("\"reached\":[]"))); + } + + @Test + void separateEntriesForDifferentArtifacts() { + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib-a", "1.0.0", "GHSA-a"); + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib-b", "2.0.0", "GHSA-b"); + + action.doIteration(telService); + + verify(telService, times(2)).addDependency(org.mockito.Mockito.any()); + } + + @Test + void drainsClearsPendingState() { + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-x"); + + action.doIteration(telService); + verify(telService, times(1)).addDependency(org.mockito.Mockito.any()); + + // Second iteration with no new state — nothing to report + TelemetryService telService2 = mock(TelemetryService.class); + action.doIteration(telService2); + verify(telService2, never()).addDependency(org.mockito.Mockito.any()); + } + + /** + * Validates the full RFC heartbeat flow (Heartbeats #2–#6 from the spec): + * + *

    + *
  1. Heartbeat after CVE registration: both CVEs reported with reached:[] + *
  2. Heartbeat with no changes: nothing reported + *
  3. Heartbeat after first CVE hit: both CVEs reported (one with callsite, one empty) + *
  4. Heartbeat with no changes: nothing reported + *
  5. Heartbeat after second CVE hit: both CVEs reported with their respective callsites + *
+ */ + @Test + void rfcFullHeartbeatFlow_twoCveSameDepBothHitSequentially() { + // Phase 1 — CVE registration (Heartbeat #2) + ScaReachabilityDependencyRegistry.INSTANCE.registerCve( + "com.example:lib", "1.0.0", "GHSA-cve-1"); + ScaReachabilityDependencyRegistry.INSTANCE.registerCve( + "com.example:lib", "1.0.0", "GHSA-cve-2"); + + action.doIteration(telService); + + ArgumentCaptor captor1 = ArgumentCaptor.forClass(Dependency.class); + verify(telService, times(1)).addDependency(captor1.capture()); + Dependency hb2 = captor1.getValue(); + assertEquals(2, hb2.reachabilityMetadata.size()); + assertTrue( + hb2.reachabilityMetadata.stream().allMatch(v -> v.contains("\"reached\":[]")), + "Heartbeat #2: both CVEs must have reached:[]"); + + // Phase 2 — No changes (Heartbeat #3) + TelemetryService telService3 = mock(TelemetryService.class); + action.doIteration(telService3); + verify(telService3, never()).addDependency(org.mockito.Mockito.any()); + + // Phase 3 — First CVE hit (Heartbeat #4) + ScaReachabilityDependencyRegistry.INSTANCE.recordHit( + "com.example:lib", "1.0.0", "GHSA-cve-1", "com.myapp.Controller", "handleRequest", 10); + + TelemetryService telService4 = mock(TelemetryService.class); + action.doIteration(telService4); + + ArgumentCaptor captor4 = ArgumentCaptor.forClass(Dependency.class); + verify(telService4, times(1)).addDependency(captor4.capture()); + Dependency hb4 = captor4.getValue(); + assertEquals(2, hb4.reachabilityMetadata.size()); + assertTrue( + hb4.reachabilityMetadata.stream() + .anyMatch(v -> v.contains("GHSA-cve-1") && v.contains("\"path\"")), + "Heartbeat #4: cve-1 must have callsite"); + assertTrue( + hb4.reachabilityMetadata.stream() + .anyMatch(v -> v.contains("GHSA-cve-2") && v.contains("\"reached\":[]")), + "Heartbeat #4: cve-2 must still have reached:[]"); + + // Phase 4 — No changes (Heartbeat #5) + TelemetryService telService5 = mock(TelemetryService.class); + action.doIteration(telService5); + verify(telService5, never()).addDependency(org.mockito.Mockito.any()); + + // Phase 5 — Second CVE hit (Heartbeat #6) + ScaReachabilityDependencyRegistry.INSTANCE.recordHit( + "com.example:lib", "1.0.0", "GHSA-cve-2", "com.myapp.Service", "processData", 44); + + TelemetryService telService6 = mock(TelemetryService.class); + action.doIteration(telService6); + + ArgumentCaptor captor6 = ArgumentCaptor.forClass(Dependency.class); + verify(telService6, times(1)).addDependency(captor6.capture()); + Dependency hb6 = captor6.getValue(); + assertEquals(2, hb6.reachabilityMetadata.size()); + assertTrue( + hb6.reachabilityMetadata.stream() + .anyMatch(v -> v.contains("GHSA-cve-1") && v.contains("\"path\"")), + "Heartbeat #6: cve-1 must retain callsite"); + assertTrue( + hb6.reachabilityMetadata.stream() + .anyMatch(v -> v.contains("GHSA-cve-2") && v.contains("\"path\"")), + "Heartbeat #6: cve-2 must now have callsite"); + } + + @Test + void buildMetadataValue_emptyReachedWhenNoHit() { + ScaReachabilityDependencyRegistry.CveSnapshot cve = + new ScaReachabilityDependencyRegistry.CveSnapshot("GHSA-645p-88qh-w398", null); + + String value = ScaReachabilityPeriodicAction.buildMetadataValue(cve); + + assertEquals( + "{\"id\":\"GHSA-645p-88qh-w398\",\"reached\":[]}", + value, + "CVE with no hit must produce reached:[]"); + } + + @Test + void buildMetadataValue_includesCallsiteWhenHit() { + ScaReachabilityHit hit = + new ScaReachabilityHit( + "GHSA-645p-88qh-w398", + "com.fasterxml.jackson.core:jackson-databind", + "2.8.5", + "com.fasterxml.jackson.databind.ObjectMapper", + "", + 1); + ScaReachabilityDependencyRegistry.CveSnapshot cve = + new ScaReachabilityDependencyRegistry.CveSnapshot("GHSA-645p-88qh-w398", hit); + + String value = ScaReachabilityPeriodicAction.buildMetadataValue(cve); + + assertEquals( + "{\"id\":\"GHSA-645p-88qh-w398\"," + + "\"reached\":[{" + + "\"path\":\"com.fasterxml.jackson.databind.ObjectMapper\"," + + "\"symbol\":\"\"," + + "\"line\":1}]}", + value); + } + + // --------------------------------------------------------------------------- + // Merge logic: DependencyService + ScaReachabilityDependencyRegistry + // --------------------------------------------------------------------------- + + private static ScaReachabilityPeriodicAction actionWithDeps(Dependency... deps) { + DependencyService svc = mock(DependencyService.class); + org.mockito.Mockito.when(svc.drainDeterminedDependencies()) + .thenReturn(java.util.Arrays.asList(deps)); + return new ScaReachabilityPeriodicAction(svc); + } + + @Test + void newDep_noCveState_emitsWithEmptyMetadata() { + // DependencyService returns a new dep; registry has nothing for it. + // Expected: one entry with metadata:[] (SCA-active signal, no CVE data yet). + Dependency incoming = new Dependency("com.example:lib", "1.0.0", "lib-1.0.0.jar", null); + ScaReachabilityPeriodicAction merged = actionWithDeps(incoming); + + merged.doIteration(telService); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Dependency.class); + verify(telService, times(1)).addDependency(captor.capture()); + Dependency emitted = captor.getValue(); + assertEquals("com.example:lib", emitted.name); + assertEquals("1.0.0", emitted.version); + assertNotNull(emitted.reachabilityMetadata, "metadata must not be null when SCA active"); + assertTrue(emitted.reachabilityMetadata.isEmpty(), "metadata must be [] when no CVE state"); + } + + @Test + void newDep_withCveState_emitsMergedSingleEntry() { + // DependencyService returns dep X; registry has a pending CVE state for the same dep. + // Expected: ONE entry with the CVE metadata merged in — no separate dep:[] entry. + ScaReachabilityDependencyRegistry.INSTANCE.registerCve( + "com.example:lib", "1.0.0", "GHSA-test-1234"); + ScaReachabilityDependencyRegistry.INSTANCE.recordHit( + "com.example:lib", "1.0.0", "GHSA-test-1234", "com.myapp.Ctrl", "handle", 10); + + Dependency incoming = new Dependency("com.example:lib", "1.0.0", "lib-1.0.0.jar", "ABCD"); + ScaReachabilityPeriodicAction merged = actionWithDeps(incoming); + + merged.doIteration(telService); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Dependency.class); + verify(telService, times(1)).addDependency(captor.capture()); + Dependency emitted = captor.getValue(); + assertEquals("com.example:lib", emitted.name); + assertEquals("1.0.0", emitted.version); + assertEquals("ABCD", emitted.hash, "source/hash from DependencyService must be preserved"); + assertEquals(1, emitted.reachabilityMetadata.size()); + assertTrue(emitted.reachabilityMetadata.get(0).contains("GHSA-test-1234")); + assertTrue( + emitted.reachabilityMetadata.get(0).contains("\"path\""), + "merged entry must include callsite"); + } + + @Test + void newDepAndUnrelatedCveState_emitsTwoIndependentEntries() { + // DependencyService returns depA; registry has pending state for depB (different dep). + // Expected: two separate entries — one for depA (metadata:[]), one for depB (CVE metadata). + ScaReachabilityDependencyRegistry.INSTANCE.registerCve( + "com.other:lib", "2.0.0", "GHSA-other-5678"); + + Dependency incomingA = new Dependency("com.example:lib", "1.0.0", "lib-1.0.0.jar", null); + ScaReachabilityPeriodicAction merged = actionWithDeps(incomingA); + // Simulate com.other:lib having been resolved by DependencyService in a prior heartbeat + merged.addKnownDepForTesting("com.other:lib", "2.0.0"); + + merged.doIteration(telService); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Dependency.class); + verify(telService, times(2)).addDependency(captor.capture()); + java.util.List emitted = captor.getAllValues(); + + Dependency depA = + emitted.stream() + .filter(d -> "com.example:lib".equals(d.name)) + .findFirst() + .orElseThrow(() -> new AssertionError("dep not found")); + Dependency depB = + emitted.stream() + .filter(d -> "com.other:lib".equals(d.name)) + .findFirst() + .orElseThrow(() -> new AssertionError("dep not found")); + + assertTrue(depA.reachabilityMetadata.isEmpty(), "depA: no CVE state → metadata:[]"); + assertTrue( + depB.reachabilityMetadata.get(0).contains("GHSA-other-5678"), "depB: must carry CVE state"); + } + + // --------------------------------------------------------------------------- + // knownDeps / timing invariant tests + // --------------------------------------------------------------------------- + + /** + * Dep resolved by DependencyService in heartbeat N; CVE fires in heartbeat N+1. The dep is + * already in knownDeps, so Step 3 emits it with source/hash. + */ + @Test + void cveFiresAfterDepResolved_usesKnownDepsForSourceHash() { + DependencyService svc = mock(DependencyService.class); + // Heartbeat 1: DependencyService returns the dep, no CVE yet + when(svc.drainDeterminedDependencies()) + .thenReturn( + Collections.singletonList( + new Dependency("com.example:lib", "1.0.0", "lib.jar", "ABCD"))) + .thenReturn(Collections.emptyList()); // heartbeat 2: nothing new + ScaReachabilityPeriodicAction merged = new ScaReachabilityPeriodicAction(svc); + + // Heartbeat 1: dep detected, no CVE → emits metadata:[] + merged.doIteration(telService); + + // CVE fires between heartbeat 1 and 2 + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-late"); + + // Heartbeat 2: DependencyService is empty, but CVE is pending + TelemetryService telService2 = mock(TelemetryService.class); + merged.doIteration(telService2); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Dependency.class); + verify(telService2, times(1)).addDependency(captor.capture()); + Dependency emitted = captor.getValue(); + assertEquals("lib.jar", emitted.source, "source from knownDeps must be preserved"); + assertEquals("ABCD", emitted.hash, "hash from knownDeps must be preserved"); + assertTrue(emitted.reachabilityMetadata.get(0).contains("GHSA-late")); + } + + /** + * CVE fires before DependencyService has resolved the dep (timing race). + * + *

Step 3 emits immediately without source/hash so CVE data is never delayed (system tests need + * data within seconds). When the dep is later resolved and stored in knownDeps, subsequent CVE + * emissions (e.g., after a method hit) carry source/hash automatically. + */ + @Test + void cveFiresBeforeDepResolved_emitsImmediatelyWithoutSourceHash() { + DependencyService svc = mock(DependencyService.class); + // Heartbeat 1: DependencyService is empty (dep not yet resolved) + when(svc.drainDeterminedDependencies()).thenReturn(Collections.emptyList()); + ScaReachabilityPeriodicAction merged = new ScaReachabilityPeriodicAction(svc); + + // CVE fires before DependencyService resolves the dep + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-race"); + ScaReachabilityDependencyRegistry.INSTANCE.recordHit( + "com.example:lib", "1.0.0", "GHSA-race", "com.app.Ctrl", "handle", 10); + + // Heartbeat 1: emits immediately without source/hash — CVE data is not delayed + merged.doIteration(telService); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Dependency.class); + verify(telService, times(1)).addDependency(captor.capture()); + Dependency emitted = captor.getValue(); + assertNull(emitted.source, "source is null when dep not yet in knownDeps"); + assertNull(emitted.hash, "hash is null when dep not yet in knownDeps"); + assertTrue(emitted.reachabilityMetadata.get(0).contains("GHSA-race")); + assertTrue(emitted.reachabilityMetadata.get(0).contains("\"path\""), "must include callsite"); + } + + /** + * Dep and CVE arrive simultaneously (same heartbeat) — existing Step 2 merge path. This existing + * behavior must still work after the knownDeps refactor. + */ + @Test + void cveAndDepArriveSameHeartbeat_step2MergeStillWorks() { + ScaReachabilityDependencyRegistry.INSTANCE.registerCve( + "com.example:lib", "1.0.0", "GHSA-simultaneous"); + + Dependency incoming = new Dependency("com.example:lib", "1.0.0", "lib.jar", "HASH"); + ScaReachabilityPeriodicAction merged = actionWithDeps(incoming); + + merged.doIteration(telService); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Dependency.class); + verify(telService, times(1)).addDependency(captor.capture()); + Dependency emitted = captor.getValue(); + assertEquals("lib.jar", emitted.source, "Step 2 merge must preserve source"); + assertTrue(emitted.reachabilityMetadata.get(0).contains("GHSA-simultaneous")); + } +}