Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
28f12e0
Implement SCA Reachability: detect vulnerable library classes at runtime
jandro996 May 12, 2026
e607887
Commit sca_cves.json as versioned resource; update generateScaCvesJso…
jandro996 May 13, 2026
fb9d011
Fix Path B classpath scan for Java 9+: fall back to java.class.path
jandro996 May 13, 2026
62b290d
Add Java 9+ test for Path B classpath fallback; make method package-p…
jandro996 May 13, 2026
93d58f2
Implement method-level symbol detection with ASM bytecode injection
jandro996 May 13, 2026
a5ccd80
Retransform classes for method-level detection: already-loaded and ve…
jandro996 May 13, 2026
f8f9d02
Fix: remove incorrect dedup from injectCallbacks; update invariants
jandro996 May 13, 2026
82ea806
pr-review: fix null guard, encapsulate periodicWorkCallback, update J…
jandro996 May 13, 2026
39eef44
Fix two Codex review issues: java.nio in premain and transitive JAR r…
jandro996 May 13, 2026
dc8ffd3
Refactor: extract CLASS_LEVEL_SYMBOL constant and reportClassLevelHit…
jandro996 May 13, 2026
849f376
Move CLASS_LEVEL_SYMBOL to ScaReachabilityHit; fix misleading comment
jandro996 May 13, 2026
3ea0e05
Move java.nio comment to usage site; add tests for transitive JAR fal…
jandro996 May 13, 2026
525a81c
Remove dead visitCode() override and redundant CLASS_LEVEL_SYMBOL alias
jandro996 May 13, 2026
7f5e116
Capture callsite for method-level hits (mirrors Python tracer)
jandro996 May 13, 2026
e0c7fee
Move callsite detection from bootstrap to ScaReachabilitySystem handler
jandro996 May 13, 2026
a79907d
Use AbstractStackWalker.isNotDatadogTraceStackElement for callsite fi…
jandro996 May 13, 2026
77ba03f
Add tests for ScaReachabilitySystem.findCallsite(); document fallback…
jandro996 May 13, 2026
7c69a89
Update ScaReachabilityHit Javadoc to reflect dual callsite/symbol sem…
jandro996 May 13, 2026
19a5813
Move findCallsite() after start() — helpers after main public method
jandro996 May 13, 2026
3b76b33
Use ConcurrentHashMap.newKeySet() instead of verbose newSetFromMap idiom
jandro996 May 13, 2026
6008ac9
Lazy entryHasMethodLevelSymbol check — avoid stream alloc on normal path
jandro996 May 13, 2026
17146c0
Remove Path B from startup scan — JDK symbols are false positive indi…
jandro996 May 13, 2026
750b3c3
Remove dead processPathB() — never called after Path B removal
jandro996 May 13, 2026
fdb74e4
Fix dedup key to include class name for method-level hits
jandro996 May 13, 2026
b90b654
Implement stateful RFC heartbeat model for SCA telemetry
jandro996 May 14, 2026
fdcb421
Add smoke test for SCA Reachability telemetry (APPSEC-62260)
jandro996 May 14, 2026
579bbd0
Add method-level symbols for jackson-databind deserialization CVEs
jandro996 May 14, 2026
9c89c1b
Add method-level symbols for xstream, log4j, snakeyaml, jackson-mappe…
jandro996 May 14, 2026
00185f1
Fix SCA smoke test, RFC compliance and add heartbeat flow tests
jandro996 May 14, 2026
b3582e8
Merge branch 'master' into alejandro.gonzalez/sca-reachability
jandro996 May 14, 2026
a12d3f9
fix(smoke): add braces to if statement to satisfy CodeNarc IfStatemen…
jandro996 May 14, 2026
32fb0d1
fix(spotbugs): make periodicWorkCallback private, expose via getter
jandro996 May 14, 2026
54332fa
refactor: replace Map<?,?> casts with typed Moshi DTOs in ScaCveDatabase
jandro996 May 14, 2026
d469821
cleanup: remove stale Path A/B terminology after Path B was removed
jandro996 May 14, 2026
ab5850b
chore: remove .claude-invariants.md from tracking, add to .gitignore
jandro996 May 14, 2026
2fbf3ed
fix(forbiddenapis): replace String#split() with pre-compiled Pattern.…
jandro996 May 14, 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 @@ -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
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
Original file line number Diff line number Diff line change
@@ -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<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
val internalName = "$pkg.$name".replace('.', '/')
symbols += mapOf("class" to internalName, "method" to null)
}
}

return symbols
}
}
Original file line number Diff line number Diff line change
@@ -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<Map<String, Any?>>
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<Map<String, Any?>>
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<Map<String, Any?>>
@Suppress("UNCHECKED_CAST")
val symbols1 = entries[1]["symbols"] as List<Map<String, Any?>>
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")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[
{
"language": "jvm",
"package": [
{
"ecosystem": "maven",
"name": "org.example:some-lib",
"version_range": ["< 1.0.0"]
}
],
"ecosystem_specific": {
"imports": []
}
}
]
Original file line number Diff line number Diff line change
@@ -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"}
]
}
]
}
}
]
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
]
}
}
]
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
]
}
}
]
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {

Expand Down
Loading
Loading