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..692391f0c3e --- /dev/null +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/appsec/sca/ScaReachabilityCallback.java @@ -0,0 +1,71 @@ +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) { + Handler h = handler; + if (h == null) { + return; + } + // Include dotClassName so that two different classes in the same artifact that share + // a method name (e.g. ClassA.parse and ClassB.parse) produce independent hits. + String key = vulnId + "|" + artifact + "|" + dotClassName + "|" + methodName; + if (reported.add(key)) { + h.onMethodHit(vulnId, artifact, version, dotClassName, methodName, line); + } + } + + 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..caa5958d54c --- /dev/null +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/sca/ScaCveDatabase.java @@ -0,0 +1,157 @@ +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.List; +import java.util.Map; +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++; + for (ScaSymbol symbol : entry.symbols()) { + 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..3ad54aa27a9 --- /dev/null +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/sca/ScaReachabilitySystem.java @@ -0,0 +1,134 @@ +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) + // ← what we report + // We use the IAST trie-based filter (AbstractStackWalker.isNotDatadogTraceStackElement) to + // identify application frames, matching the existing callsite-detection infrastructure. + 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(instrumentation); + 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. Uses {@link AbstractStackWalker#isNotDatadogTraceStackElement} (backed by the IAST + * exclusion trie) to distinguish application code from agent/JDK/framework frames. + * + *

The stack at call time is: + * + *

+   *   ScaReachabilitySystem handler lambda  (skip — agent)
+   *   ScaReachabilityCallback.onMethodHit   (skip — agent)
+   *   <vulnerableClass>.<method>           (skip — the instrumented library class)
+   *   <application callsite>               ← return this
+   * 
+ * + * @param vulnerableClass dot-notation FQN of the instrumented class (used to skip library frames) + * @return first application callsite frame, or {@code null} if not found + */ + static StackTraceElement findCallsite(String vulnerableClass) { + StackTraceElement[] stack = Thread.currentThread().getStackTrace(); + boolean pastVulnerableClass = false; + + for (StackTraceElement frame : stack) { + String cls = frame.getClassName(); + + // Skip agent and JDK frames using the shared predicate from AbstractStackWalker + if (!AbstractStackWalker.isNotDatadogTraceStackElement(frame)) { + continue; + } + + if (!pastVulnerableClass) { + // Skip frames until we have passed all frames from the vulnerable class + if (cls.equals(vulnerableClass)) { + pastVulnerableClass = true; + } + continue; + } + + // Skip any additional frames still inside the vulnerable class (library-internal chains) + if (cls.equals(vulnerableClass)) { + 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..ed7035075fd --- /dev/null +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/sca/ScaReachabilityTransformer.java @@ -0,0 +1,621 @@ +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 ScaReachabilityCollector}. + * + *

Design principles (see APPSEC-62260 and .claude-invariants.md): + * + *

    + *
  • 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 URL → resolved dependencies. 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<>(); + + 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 + * ScaReachabilityCollector} 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; + + 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, + className.replace('/', '.'), + 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. + scheduleRetransformByName(className); + } + + if (methodCallbacks.isEmpty()) { + return null; + } + return injectMethodCallbacks(classfileBuffer, methodCallbacks); + } + + /** Stores a class name (internal format) for deferred retransformation. */ + private final Set pendingRetransformNames = ConcurrentHashMap.newKeySet(); + + private void scheduleRetransformByName(String internalClassName) { + pendingRetransformNames.add(internalClassName); + } + + // --------------------------------------------------------------------------- + // 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(Instrumentation instrumentation) { + 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 ScaReachabilityCollector}. + */ + 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) { + 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()) { + if (scanned.add(url)) { + String version = findArtifactInUrl(artifactName, url); + if (version != null) { + return version; + } + } + } + } + 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 { + URL url = new File(entry).toURI().toURL(); + if (scanned.add(url)) { + String version = findArtifactInUrl(artifactName, url); + 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) { + // Dedup key prevents registering the same (vulnId, artifact, symbol) twice. + String dedupKey = entry.vulnId() + "|" + entry.artifact() + "|" + 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) { + List cached = jarCache.get(url); + if (cached != null) { + return cached; + } + List resolved; + try { + URI uri = url.toURI(); + resolved = DependencyResolver.resolve(uri); + if (resolved == null) { + resolved = Collections.emptyList(); + } + } catch (Exception e) { + log.debug("SCA Reachability: could not resolve {}", url, e); + 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(url, resolved); + return existing != null ? existing : resolved; + } + return resolved; + } + + 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..a90aebb1225 --- /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/sca_cves.json b/dd-java-agent/appsec/src/main/resources/sca_cves.json new file mode 100644 index 00000000000..05414d3d8ec --- /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":"java/sql/PreparedStatement","method":null},{"class":"java/sql/Statement","method":null},{"class":"java/sql/Connection","method":null},{"class":"java/sql/DriverManager","method":null},{"class":"javax/sql/DataSource","method":null},{"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":null},{"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":null},{"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":null},{"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":null},{"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":null},{"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":null},{"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":null},{"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":null},{"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":null},{"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":null},{"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":null},{"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":null},{"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":null},{"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":null},{"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":null},{"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":null},{"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":null},{"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..791130f43fe --- /dev/null +++ b/dd-java-agent/appsec/src/test/java/com/datadog/appsec/sca/ScaCveDatabaseTest.java @@ -0,0 +1,110 @@ +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 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..f14ce830639 --- /dev/null +++ b/dd-java-agent/appsec/src/test/java/com/datadog/appsec/sca/ScaReachabilityMethodLevelTest.java @@ -0,0 +1,310 @@ +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.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 java.util.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..27d0f2c5102 --- /dev/null +++ b/dd-java-agent/appsec/src/test/java/com/datadog/appsec/sca/ScaReachabilitySystemCallsiteTest.java @@ -0,0 +1,63 @@ +package com.datadog.appsec.sca; + +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link ScaReachabilitySystem#findCallsite(String)}. + * + *

Design notes on testability: + * + *

    + *
  • This test class is in {@code com.datadog.appsec.sca.*}, which {@link + * datadog.trace.util.stacktrace.AbstractStackWalker#isNotDatadogTraceStackElement} treats as + * agent code and skips. That means test frames are filtered before reaching the {@code + * pastVulnerableClass} state machine. + *
  • To exercise the "skip the vulnerable class and return the caller" path, the vulnerable + * class must be a non-agent class that IS on the stack. {@code java.lang.Thread} is always + * Frame 0 of {@code getStackTrace()} and is not filtered. + *
  • The end-to-end callsite assertion (callsite != vulnerable class) is validated in {@link + * ScaReachabilityMethodLevelTest#injectMethodCallbacks_callbackFiredOnMethodCall}. + *
+ */ +class ScaReachabilitySystemCallsiteTest { + + @Test + void findCallsite_returnsNullWhenVulnerableClassIsNotInStack() { + // If the vulnerable class is never on the call stack, pastVulnerableClass never becomes true, + // and findCallsite returns null. This triggers the fallback path in the handler + // (reports the vulnerable symbol itself instead of a callsite). + StackTraceElement result = ScaReachabilitySystem.findCallsite("com.example.ClassNotOnStack"); + + // pastVulnerableClass never fires → no frame is ever returned + assertNull(result, "Should return null when vulnerable class is not on the stack"); + } + + @Test + void findCallsite_skipsVulnerableClassAndReturnsFrameAboveIt() { + // java.lang.Thread is always Frame 0 of getStackTrace(), and it is NOT filtered + // by isNotDatadogTraceStackElement (not a datadog.* class). Using it as the + // "vulnerable class" exercises the full "find frame above vulnerable class" path: + // + // Frame 0: java.lang.Thread ← cls == vulnerableClass → pastVulnerableClass = true, skip + // Frame 1: ScaReachabilitySystem.findCallsite ← agent frame, skip + // Frame 2: this test class ← agent frame (com.datadog.appsec.*), skip + // Frame 3+: JUnit runner (org.junit.*) ← first non-agent frame → RETURNED + StackTraceElement result = ScaReachabilitySystem.findCallsite("java.lang.Thread"); + + assertNotNull( + result, + "Should find a frame above java.lang.Thread — a JUnit runner frame should be present"); + assertNotEquals( + "java.lang.Thread", + result.getClassName(), + "Callsite must not be the vulnerable class (java.lang.Thread) itself"); + assertNotEquals( + true, + result.getClassName().startsWith("datadog."), + "Callsite must not be an agent (datadog.*) frame"); + } +} 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/ScaReachabilityTransformerTest.java b/dd-java-agent/appsec/src/test/java/com/datadog/appsec/sca/ScaReachabilityTransformerTest.java new file mode 100644 index 00000000000..4a5bc47f83c --- /dev/null +++ b/dd-java-agent/appsec/src/test/java/com/datadog/appsec/sca/ScaReachabilityTransformerTest.java @@ -0,0 +1,325 @@ +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 java.io.File; +import java.io.StringReader; +import java.net.URL; +import java.security.CodeSource; +import java.security.ProtectionDomain; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ScaReachabilityTransformerTest { + + private static final String JACKSON_JSON = + "{\"version\":1,\"entries\":[{" + + "\"vuln_id\":\"GHSA-test-jackson\"," + + "\"artifact\":\"com.fasterxml.jackson.core:jackson-databind\"," + + "\"version_ranges\":[\"< 2.9.0\"]," + + "\"symbols\":[{\"class\":\"com/fasterxml/jackson/databind/ObjectMapper\",\"method\":null}]" + + "}]}"; + + private ScaReachabilityTransformer transformer; + + @BeforeEach + void setUp() throws Exception { + // Drain any hits left from previous tests + ScaReachabilityDependencyRegistry.INSTANCE.resetForTesting(); + ScaCveDatabase db = ScaCveDatabase.parse(new StringReader(JACKSON_JSON)); + transformer = new ScaReachabilityTransformer(db, null); + } + + // --- transform() return value --- + + @Test + void transformAlwaysReturnsNull() throws Exception { + ProtectionDomain pd = protectionDomainFor(jarUrl("jackson-databind-2.8.5.jar")); + byte[] result = + transformer.transform( + null, "com/fasterxml/jackson/databind/ObjectMapper", null, pd, new byte[0]); + assertNull(result, "transform() must never return non-null for class-level symbols"); + } + + @Test + void transformReturnsNullForArrayTypes() { + byte[] result = + transformer.transform( + null, "[Lcom/fasterxml/jackson/databind/ObjectMapper;", null, null, new byte[0]); + assertNull(result); + } + + @Test + void transformReturnsNullForNullClassName() { + byte[] result = transformer.transform(null, null, null, null, new byte[0]); + assertNull(result); + } + + @Test + void transformReturnsNullForNullProtectionDomain() { + // JDK class — protectionDomain is null; handled at startup via Path B + byte[] result = + transformer.transform( + null, "com/fasterxml/jackson/databind/ObjectMapper", null, null, new byte[0]); + assertNull(result); + assertTrue( + ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies().isEmpty(), + "No hit expected for JDK-sourced class in transform()"); + } + + @Test + void transformReturnsNullForClassNotInDatabase() throws Exception { + ProtectionDomain pd = protectionDomainFor(jarUrl("some-other-lib.jar")); + byte[] result = + transformer.transform(null, "com/example/UnrelatedClass", null, pd, new byte[0]); + assertNull(result); + assertTrue(ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies().isEmpty()); + } + + // --- hit detection --- + + @Test + void detectsVulnerableClassFromRealJar() throws Exception { + // Use the actual jackson-databind JAR on the test classpath as the location + URL jacksonJar = findJarOnClasspath("jackson-databind"); + if (jacksonJar == null) { + // If the JAR is not on the test classpath, skip this test + return; + } + ProtectionDomain pd = protectionDomainFor(jacksonJar); + transformer.transform( + null, "com/fasterxml/jackson/databind/ObjectMapper", null, pd, new byte[0]); + + List pending = + ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); + // Only assert if the version is actually vulnerable (< 2.9.0) + // We can't assert a specific hit here since the test classpath version may vary + assertTrue(pending.size() <= 1, "At most one dependency entry per (vulnId, artifact)"); + } + + @Test + void deduplicatesHitsForSameVulnAndArtifact() throws Exception { + URL fakeJar = getClass().getResource("/"); // any URL will do; DependencyResolver returns empty + if (fakeJar == null) return; + + ProtectionDomain pd = protectionDomainFor(fakeJar); + // Call transform twice for the same class — should produce at most one hit + transformer.transform( + null, "com/fasterxml/jackson/databind/ObjectMapper", null, pd, new byte[0]); + transformer.transform( + null, "com/fasterxml/jackson/databind/ObjectMapper", null, pd, new byte[0]); + + List pending = + ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); + assertTrue( + pending.size() <= 1, + "Deduplication must ensure at most one dependency entry per (vulnId, artifact)"); + } + + // --- checkAlreadyLoadedClasses --- + + @Test + void checkAlreadyLoadedClasses_completesWithoutThrowingForUnresolvableClass() throws Exception { + // Verify the method runs without throwing for a class not in the database + // (ScaReachabilityTransformer itself is a class guaranteed to be loaded) + java.lang.instrument.Instrumentation inst = + fakeInstrumentationReturning(ScaReachabilityTransformer.class); + + transformer.checkAlreadyLoadedClasses(inst); + + // No hit — this class is not in our test DB (which only has jackson) + assertTrue(ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies().isEmpty()); + } + + @Test + void checkAlreadyLoadedClasses_doesNotAbortOnException() throws Exception { + // A class in the DB whose CodeSource URL points to a non-existent JAR should not abort + // processing of subsequent classes. We pass two classes: one that will fail resolution + // (fake JAR), and one that is not in the DB at all — both should be processed without throw. + java.lang.instrument.Instrumentation inst = + fakeInstrumentationReturning( + // ScaReachabilityTransformer is NOT in the DB → quick exit, no error + ScaReachabilityTransformer.class, + // Object.class has null protectionDomain → Path B → no artifact found → no hit + Object.class); + + // Must complete without throwing even when individual class processing fails + transformer.checkAlreadyLoadedClasses(inst); + ScaReachabilityDependencyRegistry.INSTANCE.resetForTesting(); + } + + private static java.lang.instrument.Instrumentation fakeInstrumentationReturning( + Class... classes) { + return (java.lang.instrument.Instrumentation) + java.lang.reflect.Proxy.newProxyInstance( + ScaReachabilityTransformerTest.class.getClassLoader(), + new Class[] {java.lang.instrument.Instrumentation.class}, + (proxy, method, args) -> { + if ("getAllLoadedClasses".equals(method.getName())) { + return classes; + } + return null; + }); + } + + // --- resolveVersionForArtifact (transitive JAR fallback) --- + + @Test + void resolveVersionForArtifact_returnsVersionFromClassJarWhenPresent() throws Exception { + // When classJarDeps contains the artifact, the version is returned directly without + // hitting the classpath scan. + ScaCveDatabase db = ScaCveDatabase.parse(new StringReader(JACKSON_JSON)); + ScaReachabilityTransformer t = new ScaReachabilityTransformer(db, null); + datadog.telemetry.dependency.Dependency dep = + new datadog.telemetry.dependency.Dependency( + "com.fasterxml.jackson.core:jackson-databind", "2.8.5", null, null); + + String version = + t.resolveVersionForArtifact( + "com.fasterxml.jackson.core:jackson-databind", + java.util.Collections.singletonList(dep)); + + assertEquals("2.8.5", version, "Should return version from classJarDeps directly"); + } + + @Test + void resolveVersionForArtifact_fallsBackToClasspathWhenClassJarLacksArtifact() throws Exception { + // When classJarDeps is empty (class loaded from a transitive JAR, e.g. @Controller from + // spring-context.jar while the CVE is on spring-boot-starter-web), resolveVersionForArtifact + // must fall back to scanning the application classpath. jackson-databind IS a + // testImplementation + // dependency, so if it's present in the classpath the fallback should find it. + ScaCveDatabase db = ScaCveDatabase.parse(new StringReader(JACKSON_JSON)); + ScaReachabilityTransformer t = new ScaReachabilityTransformer(db, null); + + String version = + t.resolveVersionForArtifact( + "com.fasterxml.jackson.core:jackson-databind", java.util.Collections.emptyList()); + + // jackson-databind is a testImplementation dep — it should be found on java.class.path. + assertNotNull(version, "Classpath fallback must find jackson-databind on the test classpath"); + assertFalse(version.isEmpty(), "Found version must be non-empty"); + } + + @Test + void resolveVersionForArtifact_cachesClasspathLookupResult() throws Exception { + ScaCveDatabase db = ScaCveDatabase.parse(new StringReader(JACKSON_JSON)); + ScaReachabilityTransformer t = new ScaReachabilityTransformer(db, null); + + // First call — populates classpathArtifactCache + String v1 = + t.resolveVersionForArtifact( + "com.fasterxml.jackson.core:jackson-databind", java.util.Collections.emptyList()); + // Second call — should return the cached result + String v2 = + t.resolveVersionForArtifact( + "com.fasterxml.jackson.core:jackson-databind", java.util.Collections.emptyList()); + + assertEquals(v1, v2, "Classpath fallback result must be cached across calls"); + } + + @Test + void resolveVersionForArtifact_returnsNullForAbsentArtifact() throws Exception { + ScaCveDatabase db = ScaCveDatabase.parse(new StringReader(JACKSON_JSON)); + ScaReachabilityTransformer t = new ScaReachabilityTransformer(db, null); + + String version = + t.resolveVersionForArtifact( + "com.example:nonexistent-artifact-xyz", java.util.Collections.emptyList()); + + assertNull(version, "Artifact not on classpath must return null"); + } + + // --- performPendingRetransforms --- + + @Test + void performPendingRetransforms_noOpWhenNullInstrumentation() { + // Transformer constructed with null instrumentation (test context) must not throw + transformer.performPendingRetransforms(); + // No exception = pass + } + + @Test + void performPendingRetransforms_callsRetransformClassesForPendingQueue() throws Exception { + List> retransformed = new java.util.ArrayList<>(); + java.lang.instrument.Instrumentation inst = + (java.lang.instrument.Instrumentation) + java.lang.reflect.Proxy.newProxyInstance( + ScaReachabilityTransformerTest.class.getClassLoader(), + new Class[] {java.lang.instrument.Instrumentation.class}, + (proxy, method, args) -> { + if ("getAllLoadedClasses".equals(method.getName())) { + return new Class[0]; + } + if ("retransformClasses".equals(method.getName())) { + retransformed.addAll(java.util.Arrays.asList((Class[]) args[0])); + return null; + } + return null; + }); + ScaCveDatabase db = ScaCveDatabase.parse(new StringReader(JACKSON_JSON)); + ScaReachabilityTransformer t = new ScaReachabilityTransformer(db, inst); + + // Simulate checkAlreadyLoadedClasses adding a class to the pending queue + t.pendingRetransform.add(ScaReachabilityTransformer.class); + t.performPendingRetransforms(); + + assertEquals(1, retransformed.size()); + assertEquals(ScaReachabilityTransformer.class, retransformed.get(0)); + } + + @Test + void performPendingRetransforms_noOpWhenQueuesEmpty() throws Exception { + List methodsCalled = new java.util.ArrayList<>(); + java.lang.instrument.Instrumentation inst = + (java.lang.instrument.Instrumentation) + java.lang.reflect.Proxy.newProxyInstance( + ScaReachabilityTransformerTest.class.getClassLoader(), + new Class[] {java.lang.instrument.Instrumentation.class}, + (proxy, method, args) -> { + methodsCalled.add(method.getName()); + return method.getName().equals("getAllLoadedClasses") ? new Class[0] : null; + }); + ScaCveDatabase db = ScaCveDatabase.parse(new StringReader(JACKSON_JSON)); + ScaReachabilityTransformer t = new ScaReachabilityTransformer(db, inst); + + t.performPendingRetransforms(); + + // With empty queues: getAllLoadedClasses is skipped (pendingRetransformNames is empty), + // retransformClasses is never called + assertFalse( + methodsCalled.contains("retransformClasses"), + "retransformClasses must not be called when both queues are empty"); + } + + // --- helpers --- + + private static ProtectionDomain protectionDomainFor(URL location) throws Exception { + CodeSource cs = new CodeSource(location, (java.security.cert.Certificate[]) null); + return new ProtectionDomain(cs, null); + } + + private static URL jarUrl(String name) throws Exception { + return new File("/tmp/" + name).toURI().toURL(); + } + + private static URL findJarOnClasspath(String partialName) { + String cp = System.getProperty("java.class.path", ""); + for (String entry : cp.split(File.pathSeparator)) { + if (entry.contains(partialName) && entry.endsWith(".jar")) { + try { + return new File(entry).toURI().toURL(); + } catch (Exception ignored) { + } + } + } + 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/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..67890896826 --- /dev/null +++ b/dd-smoke-tests/appsec/springboot/src/test/groovy/datadog/smoketest/appsec/ScaReachabilitySmokeTest.groovy @@ -0,0 +1,98 @@ +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). + * 2. Once a class from the vulnerable library is loaded, reached is populated + * with the first callsite. + * + * The springboot smoke test app uses jackson-databind:2.6.0, which falls in the + * vulnerable range "< 2.6.7.3" for GHSA-645p-88qh-w398. Spring Boot auto-configures + * Jackson at startup so com.fasterxml.jackson.databind.ObjectMapper is always loaded. + */ +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 jackson-databind via telemetry'() { + when: 'application starts and telemetry heartbeats arrive' + waitForTelemetryFlat { it.get('request_type') == 'app-dependencies-loaded' } + + then: 'jackson-databind 2.6.0 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 jackson-databind entry that has SCA reachability metadata. + // The same dependency may appear multiple times: once from the regular dependency + // detector (no metadata) and once from the SCA periodic action (with metadata). + // We must search for the entry that actually carries reachability metadata. + def jacksonDep = allDependencies.find { dep -> + def d = dep as Map + d.get('name') == 'com.fasterxml.jackson.core:jackson-databind' && + (d.get('metadata') as List)?.any { (it as Map).get('type') == 'reachability' } + } as Map + + assert jacksonDep != null : + "jackson-databind must appear with SCA reachability metadata in app-dependencies-loaded" + assert jacksonDep.get('version') == '2.6.0' : "must be the vulnerable version 2.6.0" + + // Find the reachability metadata entry + def metadata = jacksonDep.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" + + // ObjectMapper is always loaded by Spring Boot — reached must be non-empty + def reached = reachabilityPayload.get('reached') as List + assert !reached.isEmpty() : + "ObjectMapper is loaded at Spring Boot startup — reached must contain at least one callsite" + + def callsite = reached[0] as Map + assert callsite.get('path') != null : "callsite path must be present" + assert callsite.get('symbol') != null : "callsite symbol must be present" + assert (callsite.get('line') as int) >= 0 : "callsite line must be non-negative" + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/telemetry/ScaReachabilityCollector.java b/internal-api/src/main/java/datadog/trace/api/telemetry/ScaReachabilityCollector.java new file mode 100644 index 00000000000..0b5e82ab70a --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/telemetry/ScaReachabilityCollector.java @@ -0,0 +1,57 @@ +package datadog.trace.api.telemetry; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +/** + * Singleton queue bridging the SCA reachability transformer (appsec module) and the periodic + * telemetry action (telemetry module) without creating a circular dependency. + * + *

Pattern mirrors {@code WafMetricCollector}: both modules depend on {@code internal-api}, which + * owns this class. The transformer enqueues hits; the periodic action drains them. + */ +public final class ScaReachabilityCollector { + + public static final ScaReachabilityCollector INSTANCE = new ScaReachabilityCollector(); + + private final BlockingQueue hits = new LinkedBlockingQueue<>(); + + /** + * Optional periodic work hook registered by {@code ScaReachabilityTransformer}. Called by {@code + * ScaReachabilityPeriodicAction} on each heartbeat to trigger retransformation of classes that + * could not be instrumented earlier (method-level symbols on already-loaded classes, or classes + * where JAR version resolution failed at load time). + */ + private volatile Runnable periodicWorkCallback; + + public void setPeriodicWorkCallback(Runnable callback) { + periodicWorkCallback = callback; + } + + public Runnable getPeriodicWorkCallback() { + return periodicWorkCallback; + } + + private ScaReachabilityCollector() {} + + /** Called by {@code ScaReachabilityTransformer} when a vulnerable class is detected. */ + public void addHit(ScaReachabilityHit hit) { + hits.offer(hit); + } + + /** + * Called by {@code ScaReachabilityPeriodicAction} on each telemetry heartbeat. Drains and returns + * all pending hits. + */ + public List drain() { + if (hits.isEmpty()) { + return Collections.emptyList(); + } + List result = new ArrayList<>(hits.size()); + hits.drainTo(result); + return result; + } +} 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..a460df3a318 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/telemetry/ScaReachabilityDependencyRegistry.java @@ -0,0 +1,191 @@ +package datadog.trace.api.telemetry; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 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 "artifact@version". */ + private final ConcurrentHashMap dependencies = new ConcurrentHashMap<>(); + + /** + * 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 = 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) { + String key = artifact + "@" + version; + DependencyState dep = dependencies.get(key); + if (dep == null) { + // CVE was not pre-registered — register it now and immediately add the hit + dep = dependencies.computeIfAbsent(key, k -> new DependencyState(artifact, version)); + dep.registerCve(vulnId); + } + dep.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()); + if (state.hit == null) { + // Only store the first hit (RFC: single occurrence sufficient) + state.hit = + new ScaReachabilityHit( + vulnId, artifact, version, callsiteClass, callsiteSymbol, callsiteLine); + 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().hit)); + } + return new DependencySnapshot(artifact, version, Collections.unmodifiableList(cveSnapshots)); + } + } + + /** Mutable state for one CVE within a dependency. */ + static final class CveState { + volatile ScaReachabilityHit hit; // null = registered but not yet reached + } + + /** 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/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..0d6a001425c 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; @@ -89,6 +90,9 @@ static Thread createTelemetryRunnable( if (Config.get().isApiSecurityEndpointCollectionEnabled()) { actions.add(new EndpointPeriodicAction()); } + if (Config.get().isAppSecScaEnabled()) { + actions.add(new ScaReachabilityPeriodicAction()); + } TelemetryRunnable telemetryRunnable = new TelemetryRunnable(telemetryService, actions); return AgentThreadFactory.newAgentThread( 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..9e2137e9de0 --- /dev/null +++ b/telemetry/src/main/java/datadog/telemetry/sca/ScaReachabilityPeriodicAction.java @@ -0,0 +1,87 @@ +package datadog.telemetry.sca; + +import datadog.telemetry.TelemetryRunnable; +import datadog.telemetry.TelemetryService; +import datadog.telemetry.dependency.Dependency; +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.List; + +/** + * Reports SCA Reachability state on each telemetry heartbeat, implementing the RFC stateful model: + * + *

    + *
  1. When a CVE is first detected (class load): reports {@code metadata: + * [{"type":"reachability","value":"{\"id\":\"...\",\"reached\":[]}"}]} — signals the backend + * that SCA is monitoring this CVE even before any symbol is called. + *
  2. When a vulnerable symbol is called: re-reports the dependency with ALL its CVEs, now + * including the callsite in {@code reached} for the CVE that was hit. + *
  3. When nothing changes: reports {@code dependencies:[]} (empty heartbeat). + *
+ * + *

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. + * + *

Registered in {@link datadog.telemetry.TelemetrySystem} when {@code DD_APPSEC_SCA_ENABLED} is + * true. + */ +public final class ScaReachabilityPeriodicAction + implements TelemetryRunnable.TelemetryPeriodicAction { + + @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(); + } + + List pending = + ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); + if (pending.isEmpty()) { + return; + } + + for (DependencySnapshot dep : pending) { + // Build one metadata entry per CVE — both those with and without callsite hits. + // This ensures the backend always sees ALL CVEs for the dependency, not just new ones. + List metadataValues = new ArrayList<>(dep.cves.size()); + for (CveSnapshot cve : dep.cves) { + metadataValues.add(buildMetadataValue(cve)); + } + telService.addDependency( + new Dependency(dep.artifact, dep.version, null, null, 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}]}} + *
+ */ + static String buildMetadataValue(CveSnapshot cve) { + ScaReachabilityHit hit = cve.hit; + if (hit == null) { + // CVE known but no callsite yet — signals "monitoring, not reached" + return "{\"id\":\"" + cve.vulnId + "\",\"reached\":[]}"; + } + // CVE has been reached — include the callsite + return "{\"id\":\"" + + hit.vulnId() + + "\",\"reached\":[{\"path\":\"" + + hit.className() + + "\",\"symbol\":\"" + + hit.symbolName() + + "\",\"line\":" + + hit.line() + + "}]}"; + } +} 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..85ab5b78fbf --- /dev/null +++ b/telemetry/src/test/java/datadog/telemetry/sca/ScaReachabilityPeriodicActionTest.java @@ -0,0 +1,263 @@ +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.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 datadog.telemetry.TelemetryService; +import datadog.telemetry.dependency.Dependency; +import datadog.trace.api.telemetry.ScaReachabilityDependencyRegistry; +import datadog.trace.api.telemetry.ScaReachabilityHit; +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); + action = new ScaReachabilityPeriodicAction(); + } + + @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); + } +}