Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
0a5c473
Implement SCA Reachability: detect vulnerable library classes at runtime
jandro996 May 12, 2026
056e10a
Commit sca_cves.json as versioned resource; update generateScaCvesJso…
jandro996 May 13, 2026
d4f8583
Fix Path B classpath scan for Java 9+: fall back to java.class.path
jandro996 May 13, 2026
467af34
Add Java 9+ test for Path B classpath fallback; make method package-p…
jandro996 May 13, 2026
28a82e4
Implement method-level symbol detection with ASM bytecode injection
jandro996 May 13, 2026
314b73c
Retransform classes for method-level detection: already-loaded and ve…
jandro996 May 13, 2026
c296e3f
Fix: remove incorrect dedup from injectCallbacks; update invariants
jandro996 May 13, 2026
eb84339
pr-review: fix null guard, encapsulate periodicWorkCallback, update J…
jandro996 May 13, 2026
611db60
Fix two Codex review issues: java.nio in premain and transitive JAR r…
jandro996 May 13, 2026
a757731
Refactor: extract CLASS_LEVEL_SYMBOL constant and reportClassLevelHit…
jandro996 May 13, 2026
ee2b7fb
Move CLASS_LEVEL_SYMBOL to ScaReachabilityHit; fix misleading comment
jandro996 May 13, 2026
67ab3ac
Move java.nio comment to usage site; add tests for transitive JAR fal…
jandro996 May 13, 2026
8492fd0
Remove dead visitCode() override and redundant CLASS_LEVEL_SYMBOL alias
jandro996 May 13, 2026
737d134
Capture callsite for method-level hits (mirrors Python tracer)
jandro996 May 13, 2026
71d479f
Move callsite detection from bootstrap to ScaReachabilitySystem handler
jandro996 May 13, 2026
f4bd262
Use AbstractStackWalker.isNotDatadogTraceStackElement for callsite fi…
jandro996 May 13, 2026
e062d2d
Add tests for ScaReachabilitySystem.findCallsite(); document fallback…
jandro996 May 13, 2026
89ded4c
Update ScaReachabilityHit Javadoc to reflect dual callsite/symbol sem…
jandro996 May 13, 2026
74c7431
Move findCallsite() after start() — helpers after main public method
jandro996 May 13, 2026
7daadaf
Use ConcurrentHashMap.newKeySet() instead of verbose newSetFromMap idiom
jandro996 May 13, 2026
03d0cd6
Lazy entryHasMethodLevelSymbol check — avoid stream alloc on normal path
jandro996 May 13, 2026
9b1a5de
Remove Path B from startup scan — JDK symbols are false positive indi…
jandro996 May 13, 2026
aa91be1
Remove dead processPathB() — never called after Path B removal
jandro996 May 13, 2026
b2b16ac
Fix dedup key to include class name for method-level hits
jandro996 May 13, 2026
85ec05a
Implement stateful RFC heartbeat model for SCA telemetry
jandro996 May 14, 2026
79c452d
Add smoke test for SCA Reachability telemetry (APPSEC-62260)
jandro996 May 14, 2026
3f26179
Add method-level symbols for jackson-databind deserialization CVEs
jandro996 May 14, 2026
92571c7
Add method-level symbols for xstream, log4j, snakeyaml, jackson-mappe…
jandro996 May 14, 2026
32e34d2
Fix SCA smoke test, RFC compliance and add heartbeat flow tests
jandro996 May 14, 2026
8989380
fix(smoke): add braces to if statement to satisfy CodeNarc IfStatemen…
jandro996 May 14, 2026
eaef6c2
fix(spotbugs): make periodicWorkCallback private, expose via getter
jandro996 May 14, 2026
cedab00
refactor: replace Map<?,?> casts with typed Moshi DTOs in ScaCveDatabase
jandro996 May 14, 2026
96ccb10
cleanup: remove stale Path A/B terminology after Path B was removed
jandro996 May 14, 2026
c036032
chore: remove .claude-invariants.md from tracking, add to .gitignore
jandro996 May 14, 2026
f49edc8
fix(forbiddenapis): replace String#split() with pre-compiled Pattern.…
jandro996 May 14, 2026
4a2db95
fix: remove class-level symbols from all xstream entries
jandro996 May 15, 2026
e52d031
feat: emit metadata:[] for all deps in DependencyPeriodicAction when …
jandro996 May 15, 2026
7a57f08
fix(spotbugs): replace URL collections with URI to avoid DNS lookups …
jandro996 May 15, 2026
30e5121
chore: remove dead ScaReachabilityCollector, fix stale Javadoc, drop …
jandro996 May 15, 2026
9563f1a
refactor: unify dep reporting into ScaReachabilityPeriodicAction when…
jandro996 May 18, 2026
4ab3a03
fix(techdebt): static imports, remove inline java.util refs, replace …
jandro996 May 18, 2026
70bbd6e
fix: restore ScaReachabilityPeriodicActionTest; fix raw type Class[0]…
jandro996 May 18, 2026
9dbbbc3
fix(techdebt): move pendingRetransformNames to field section; json-es…
jandro996 May 18, 2026
ecd5310
fix(techdebt): extract depKey helper; delete empty test stub ScaReach…
jandro996 May 18, 2026
5824236
fix(thread-safety): use AtomicReference.compareAndSet for first-hit-w…
jandro996 May 18, 2026
ad8df24
fix: remove stale .claude-invariants.md reference from ScaReachabilit…
jandro996 May 18, 2026
2331eea
fix: wrap onMethodHit in try/catch to prevent exception propagation t…
jandro996 May 18, 2026
54b5afc
fix: use knownDeps to enrich CVE snapshots with source/hash from prio…
jandro996 May 18, 2026
e55f602
fix: emit CVE data immediately in Step 3, use knownDeps only for sour…
jandro996 May 18, 2026
31ca279
fix(sca): force snakeyaml class load in smoke test via PostConstruct
jandro996 May 19, 2026
b99745e
ci: retrigger CI
jandro996 May 19, 2026
ad77e9e
refactor(sca): remove dead markPending, inline scheduleRetransformByN…
jandro996 May 19, 2026
15f088f
fix(sca): register only ScaReachabilityPeriodicAction when SCA enable…
jandro996 May 19, 2026
1c39656
revert: restore pre-existing em dashes in GatewayBridge, ObjectIntros…
jandro996 May 19, 2026
a0c8af1
fix: hoist dotClassName conversion outside inner loop in processClass
jandro996 May 19, 2026
472dd13
fix(sca): deduplicate index entries per class when entry has multiple…
jandro996 May 20, 2026
6fe3340
fix(sca): include version in hit dedup keys to isolate multi-version …
jandro996 May 20, 2026
9a1f078
fix(sca): skip intermediate library frames in callsite detection
jandro996 May 22, 2026
fbaec49
fix(sca): add TODO for inner-class format in GhsaEnrichmentParser
jandro996 May 28, 2026
bf9df22
refactor(sca): defer class processing off class-loading thread; cleanup
jandro996 May 28, 2026
43c0213
refactor(sca): remove unused 4-arg convenience constructor from ScaRe…
jandro996 May 28, 2026
2c57819
test(sca): add regression tests for multi-classloader retransform fix
jandro996 May 28, 2026
65e2c9d
fix(sca): widen catch to Throwable in performPendingRetransforms
jandro996 May 29, 2026
9eba5b2
ci: retrigger pipeline
jandro996 May 29, 2026
51b5c25
fix(sca): log swallowed exceptions in ScaReachabilityCallback at debu…
jandro996 May 29, 2026
a1759e1
refactor(sca): move generateScaCvesJson into ScaEnrichmentsPlugin in …
jandro996 May 29, 2026
a33437e
test(sca): add ScaEnrichmentsPluginTest; fix processResources wiring
jandro996 May 29, 2026
a875064
fix(sca): scope processResources JSON minification to sca_cves.json only
jandro996 May 29, 2026
196a71d
fix(sca): address jpbempel review comments
jandro996 Jun 6, 2026
83191ea
nit(sca): use project VisibleForTesting annotation in ScaReachability…
jandro996 Jun 6, 2026
51492dc
nit(sca): add @VisibleForTesting to remaining package-private methods
jandro996 Jun 6, 2026
18d9540
perf(sca): use StackWalkerFactory for lazy stack evaluation in findCa…
jandro996 Jun 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,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
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ out/
# Claude Code local custom settings #
#####################################
.claude/*.local.*
.claude-invariants.md
.claude-status.md

# Vim #
#######
Expand Down
5 changes: 5 additions & 0 deletions buildSrc/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ gradlePlugin {
id = "dd-trace-java.instrumentation-naming"
implementationClass = "datadog.gradle.plugin.naming.InstrumentationNamingPlugin"
}

create("sca-enrichments-plugin") {
id = "dd-trace-java.sca-enrichments"
implementationClass = "datadog.gradle.plugin.sca.ScaEnrichmentsPlugin"
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package datadog.gradle.plugin.sca

import datadog.gradle.sca.GhsaEnrichmentParser
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
import java.net.HttpURLConnection
import java.net.URL
import org.gradle.api.GradleException
import org.gradle.api.Plugin
import org.gradle.api.Project

/**
* Registers the [generateScaCvesJson] task that downloads GHSA enrichments from
* `sca-reachability-database` and generates `sca_cves.json` bundled in the appsec JAR.
*
* This is a **temporary** build-time approach. The symbol database will be delivered
* via Remote Config in a future iteration, at which point this plugin and the committed
* `sca_cves.json` file will be removed.
*
* Usage: `apply plugin: 'dd-trace-java.sca-enrichments'`. The task runs only when
* `-PrefreshSca` is passed or the output file is absent; CI uses the committed copy.
*/
@Suppress("unused")
class ScaEnrichmentsPlugin : Plugin<Project> {

companion object {
private const val SCA_ENRICHMENTS_API =
"https://api.github.com/repos/DataDog/sca-reachability-database/contents/enrichments"
}

override fun apply(project: Project) {
val outputFile = project.file("src/main/resources/sca_cves.json")

val generateTask =
project.tasks.register("generateScaCvesJson") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: THis is fine for a temporary plugin, but if it's a longer term approach, I suggest to make a concrete task type.

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."
group = "build"
outputs.file(outputFile)
onlyIf { project.hasProperty("refreshSca") || !outputFile.exists() }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: I don't believe that works, the task up-to-date-ness can still make the task not refreshing forcibly the file, since it's based in task's inputs.

Instead, if you need to forcibly refresh depending only on the property, you. can use upToDateWhen API.

Suggested change
onlyIf { project.hasProperty("refreshSca") || !outputFile.exists() }
outputs.upToDateWhen { !project.hasProperty("refreshSca") }


doLast {
val token = System.getenv("GITHUB_TOKEN")

logger.lifecycle("Fetching GHSA enrichment index from GitHub...")
@Suppress("UNCHECKED_CAST")
val fileList = githubFetch(SCA_ENRICHMENTS_API, token) as List<Map<String, Any>>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: I'd suggest the add the ability to pass a custom URL, e.g. via a gradle property, that could be handy in CI.

Also, what happens if for some reason the content cannot be grabbed, 404, network error ?
Is it acceptable to not ship this file, otherwise that might be something to strengthen.
Maybe it's better to get these files via a git clone from within the CI ?

val ghsaFiles =
fileList.filter {
it["name"]?.toString()?.endsWith(".json") == true && it["type"] == "file"
}
logger.lifecycle("Found ${ghsaFiles.size} enrichment files")

val entries = mutableListOf<Any>()
ghsaFiles.forEach { fileInfo ->
val ghsaId = fileInfo["name"]!!.toString().removeSuffix(".json")
val rawContent = githubFetchRaw(fileInfo["download_url"]!!.toString(), token)
entries.addAll(GhsaEnrichmentParser.parse(ghsaId, rawContent))
}

outputFile.writeText(JsonOutput.toJson(mapOf("version" to 1, "entries" to 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.")
}
}

// Defer wiring until after the java plugin adds processResources.
project.pluginManager.withPlugin("java") {
project.tasks.named("processResources") {
dependsOn(generateTask)
doLast {
// Minify only sca_cves.json — not all JSON files in the module output.
project
.fileTree(mapOf("dir" to outputs.files.asPath, "includes" to listOf("**/sca_cves.json")))
.forEach { f -> f.writeText(JsonOutput.toJson(JsonSlurper().parse(f))) }
}
}
}
}

private fun githubConnect(url: String, token: String?): HttpURLConnection {
val connection = URL(url).openConnection() as HttpURLConnection
connection.setRequestProperty("Accept", "application/vnd.github+json")
connection.setRequestProperty("X-GitHub-Api-Version", "2022-11-28")
if (!token.isNullOrEmpty()) {
connection.setRequestProperty("Authorization", "Bearer $token")
}
connection.connectTimeout = 10_000
connection.readTimeout = 30_000
val code = connection.responseCode
if (code != 200) {
throw GradleException(
"GitHub API returned HTTP $code for $url.\n" +
"Unauthenticated rate limit is 60 req/hr. Set GITHUB_TOKEN to raise it.")
}
return connection
}

private fun githubFetch(url: String, token: String?): Any {
val conn = githubConnect(url, token)
return try {
JsonSlurper().parse(conn.inputStream)
} finally {
conn.disconnect()
}
}

private fun githubFetchRaw(url: String, token: String?): String {
val conn = githubConnect(url, token)
return try {
conn.inputStream.bufferedReader().readText()
} finally {
conn.disconnect()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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<Map<String, Any?>> {
val root = mapper.readTree(jsonContent)
require(root.isArray) { "GHSA enrichment file $ghsaId must be a JSON array, got ${root.nodeType}" }

val entries = mutableListOf<Map<String, Any?>>()

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<Map<String, Any?>> {
val symbols = mutableListOf<Map<String, Any?>>()
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.
// TODO(APPSEC-62260): verify inner-class format when database adds method-level symbols.
// If GHSA uses dot notation for inner classes (e.g. name="Outer.Inner"), the replace below
// produces com/example/Outer/Inner instead of the correct com/example/Outer$Inner.
// When the database team defines the format, update this to handle the $ separator.
val internalName = "$pkg.$name".replace('.', '/')
symbols += mapOf("class" to internalName, "method" to null)
Comment thread
jandro996 marked this conversation as resolved.
}
}

return symbols
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package datadog.gradle.plugin.sca

import datadog.gradle.plugin.GradleFixture
import org.assertj.core.api.Assertions.assertThat
import org.gradle.testkit.runner.TaskOutcome
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test

class ScaEnrichmentsPluginTest : GradleFixture() {

@BeforeEach
fun setup() {
writeSettings("""rootProject.name = "test-appsec"""")
writeRootProject(
"""
plugins {
java
id("dd-trace-java.sca-enrichments")
}
"""
)
}

@Test
fun `generateScaCvesJson is SKIPPED when file exists and refreshSca is not set`() {
file("src/main/resources/sca_cves.json").also {
it.parentFile.mkdirs()
it.writeText("{\"version\":1,\"entries\":[]}")
}

val result = run("generateScaCvesJson")

assertThat(result.task(":generateScaCvesJson")?.outcome).isEqualTo(TaskOutcome.SKIPPED)
}

@Test
fun `generateScaCvesJson attempts to run when refreshSca is set even if file exists`() {
file("src/main/resources/sca_cves.json").also {
it.parentFile.mkdirs()
it.writeText("{}")
}

// With -PrefreshSca the onlyIf condition is true; task will fail at the GitHub fetch
// (no network in tests) but must NOT be SKIPPED
val result = run("generateScaCvesJson", "-PrefreshSca", expectFailure = true)

assertThat(result.task(":generateScaCvesJson")?.outcome)
.isNotNull
.isNotEqualTo(TaskOutcome.SKIPPED)
}

@Test
fun `generateScaCvesJson attempts to run when output file does not exist`() {
// File absent: onlyIf returns true; task will fail at GitHub fetch but must not be SKIPPED
val result = run("generateScaCvesJson", expectFailure = true)

assertThat(result.task(":generateScaCvesJson")?.outcome)
.isNotNull
.isNotEqualTo(TaskOutcome.SKIPPED)
}

@Test
fun `processResources depends on generateScaCvesJson`() {
file("src/main/resources/sca_cves.json").also {
it.parentFile.mkdirs()
it.writeText("{\"version\":1,\"entries\":[]}")
}

val result = run("processResources")

// generateScaCvesJson must appear as SKIPPED (file exists, no -PrefreshSca)
assertThat(result.task(":generateScaCvesJson")?.outcome).isEqualTo(TaskOutcome.SKIPPED)
assertThat(result.task(":processResources")?.outcome).isEqualTo(TaskOutcome.SUCCESS)
}
}
Loading