-
Notifications
You must be signed in to change notification settings - Fork 342
Implement SCA Reachability runtime detection: report vulnerable classes and callsites via telemetry #11352
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
gh-worker-dd-mergequeue-cf854d
merged 79 commits into
master
from
alejandro.gonzalez/sca-reachability
Jun 9, 2026
Merged
Implement SCA Reachability runtime detection: report vulnerable classes and callsites via telemetry #11352
Changes from all commits
Commits
Show all changes
79 commits
Select commit
Hold shift + click to select a range
0a5c473
Implement SCA Reachability: detect vulnerable library classes at runtime
jandro996 056e10a
Commit sca_cves.json as versioned resource; update generateScaCvesJso…
jandro996 d4f8583
Fix Path B classpath scan for Java 9+: fall back to java.class.path
jandro996 467af34
Add Java 9+ test for Path B classpath fallback; make method package-p…
jandro996 28a82e4
Implement method-level symbol detection with ASM bytecode injection
jandro996 314b73c
Retransform classes for method-level detection: already-loaded and ve…
jandro996 c296e3f
Fix: remove incorrect dedup from injectCallbacks; update invariants
jandro996 eb84339
pr-review: fix null guard, encapsulate periodicWorkCallback, update J…
jandro996 611db60
Fix two Codex review issues: java.nio in premain and transitive JAR r…
jandro996 a757731
Refactor: extract CLASS_LEVEL_SYMBOL constant and reportClassLevelHit…
jandro996 ee2b7fb
Move CLASS_LEVEL_SYMBOL to ScaReachabilityHit; fix misleading comment
jandro996 67ab3ac
Move java.nio comment to usage site; add tests for transitive JAR fal…
jandro996 8492fd0
Remove dead visitCode() override and redundant CLASS_LEVEL_SYMBOL alias
jandro996 737d134
Capture callsite for method-level hits (mirrors Python tracer)
jandro996 71d479f
Move callsite detection from bootstrap to ScaReachabilitySystem handler
jandro996 f4bd262
Use AbstractStackWalker.isNotDatadogTraceStackElement for callsite fi…
jandro996 e062d2d
Add tests for ScaReachabilitySystem.findCallsite(); document fallback…
jandro996 89ded4c
Update ScaReachabilityHit Javadoc to reflect dual callsite/symbol sem…
jandro996 74c7431
Move findCallsite() after start() — helpers after main public method
jandro996 7daadaf
Use ConcurrentHashMap.newKeySet() instead of verbose newSetFromMap idiom
jandro996 03d0cd6
Lazy entryHasMethodLevelSymbol check — avoid stream alloc on normal path
jandro996 9b1a5de
Remove Path B from startup scan — JDK symbols are false positive indi…
jandro996 aa91be1
Remove dead processPathB() — never called after Path B removal
jandro996 b2b16ac
Fix dedup key to include class name for method-level hits
jandro996 85ec05a
Implement stateful RFC heartbeat model for SCA telemetry
jandro996 79c452d
Add smoke test for SCA Reachability telemetry (APPSEC-62260)
jandro996 3f26179
Add method-level symbols for jackson-databind deserialization CVEs
jandro996 92571c7
Add method-level symbols for xstream, log4j, snakeyaml, jackson-mappe…
jandro996 32e34d2
Fix SCA smoke test, RFC compliance and add heartbeat flow tests
jandro996 8989380
fix(smoke): add braces to if statement to satisfy CodeNarc IfStatemen…
jandro996 eaef6c2
fix(spotbugs): make periodicWorkCallback private, expose via getter
jandro996 cedab00
refactor: replace Map<?,?> casts with typed Moshi DTOs in ScaCveDatabase
jandro996 96ccb10
cleanup: remove stale Path A/B terminology after Path B was removed
jandro996 c036032
chore: remove .claude-invariants.md from tracking, add to .gitignore
jandro996 f49edc8
fix(forbiddenapis): replace String#split() with pre-compiled Pattern.…
jandro996 4a2db95
fix: remove class-level symbols from all xstream entries
jandro996 e52d031
feat: emit metadata:[] for all deps in DependencyPeriodicAction when …
jandro996 7a57f08
fix(spotbugs): replace URL collections with URI to avoid DNS lookups …
jandro996 30e5121
chore: remove dead ScaReachabilityCollector, fix stale Javadoc, drop …
jandro996 9563f1a
refactor: unify dep reporting into ScaReachabilityPeriodicAction when…
jandro996 4ab3a03
fix(techdebt): static imports, remove inline java.util refs, replace …
jandro996 70bbd6e
fix: restore ScaReachabilityPeriodicActionTest; fix raw type Class[0]…
jandro996 9dbbbc3
fix(techdebt): move pendingRetransformNames to field section; json-es…
jandro996 ecd5310
fix(techdebt): extract depKey helper; delete empty test stub ScaReach…
jandro996 5824236
fix(thread-safety): use AtomicReference.compareAndSet for first-hit-w…
jandro996 ad8df24
fix: remove stale .claude-invariants.md reference from ScaReachabilit…
jandro996 2331eea
fix: wrap onMethodHit in try/catch to prevent exception propagation t…
jandro996 54b5afc
fix: use knownDeps to enrich CVE snapshots with source/hash from prio…
jandro996 e55f602
fix: emit CVE data immediately in Step 3, use knownDeps only for sour…
jandro996 31ca279
fix(sca): force snakeyaml class load in smoke test via PostConstruct
jandro996 b99745e
ci: retrigger CI
jandro996 ad77e9e
refactor(sca): remove dead markPending, inline scheduleRetransformByN…
jandro996 15f088f
fix(sca): register only ScaReachabilityPeriodicAction when SCA enable…
jandro996 1c39656
revert: restore pre-existing em dashes in GatewayBridge, ObjectIntros…
jandro996 a0c8af1
fix: hoist dotClassName conversion outside inner loop in processClass
jandro996 472dd13
fix(sca): deduplicate index entries per class when entry has multiple…
jandro996 6fe3340
fix(sca): include version in hit dedup keys to isolate multi-version …
jandro996 9a1f078
fix(sca): skip intermediate library frames in callsite detection
jandro996 fbaec49
fix(sca): add TODO for inner-class format in GhsaEnrichmentParser
jandro996 bf9df22
refactor(sca): defer class processing off class-loading thread; cleanup
jandro996 43c0213
refactor(sca): remove unused 4-arg convenience constructor from ScaRe…
jandro996 2c57819
test(sca): add regression tests for multi-classloader retransform fix
jandro996 65e2c9d
fix(sca): widen catch to Throwable in performPendingRetransforms
jandro996 9eba5b2
ci: retrigger pipeline
jandro996 51b5c25
fix(sca): log swallowed exceptions in ScaReachabilityCallback at debu…
jandro996 a1759e1
refactor(sca): move generateScaCvesJson into ScaEnrichmentsPlugin in …
jandro996 a33437e
test(sca): add ScaEnrichmentsPluginTest; fix processResources wiring
jandro996 a875064
fix(sca): scope processResources JSON minification to sca_cves.json only
jandro996 196a71d
fix(sca): address jpbempel review comments
jandro996 83191ea
nit(sca): use project VisibleForTesting annotation in ScaReachability…
jandro996 51492dc
nit(sca): add @VisibleForTesting to remaining package-private methods
jandro996 18d9540
perf(sca): use StackWalkerFactory for lazy stack evaluation in findCa…
jandro996 5589832
fix(sca): address bric3 review comments on PR #11352
jandro996 98f3180
nit(sca): extract isCapExceeded helper, fix resetForTesting, use Stri…
jandro996 a0cf4ce
test(sca): add registry unit tests; add @VisibleForTesting to addKnow…
jandro996 bd965f1
fix(sca): prevent metadata:[] overwrite when JAR resolves after CVE w…
jandro996 e8a3cc7
nit(sca): remove dead isEmpty() guards and add @VisibleForTesting to …
jandro996 2c4ea2d
Merge branch 'master' into alejandro.gonzalez/sca-reachability
jandro996 850b5c2
nit(sca): add TODO for future trie consideration in ScaCveDatabase.en…
jandro996 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
127 changes: 127 additions & 0 deletions
127
buildSrc/src/main/kotlin/datadog/gradle/plugin/sca/ScaEnrichmentsPlugin.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| 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_DEFAULT = | ||
| "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") { | ||
| description = | ||
| "Downloads GHSA enrichments from sca-reachability-database and updates " + | ||
| "src/main/resources/sca_cves.json. Run with -PrefreshSca to force a refresh. " + | ||
| "Override the source URL with -PscaEnrichmentsUrl=<url>. " + | ||
| "sca_cves.json is committed to the repo so CI does not need network access." | ||
| group = "build" | ||
| outputs.file(outputFile) | ||
| // upToDateWhen: when -PrefreshSca is set, always consider outputs stale (force re-run). | ||
| outputs.upToDateWhen { !project.hasProperty("refreshSca") } | ||
| // onlyIf: skip entirely when the file already exists and no refresh was requested, | ||
| // so that normal builds (no network, no -PrefreshSca) never touch GitHub. | ||
| onlyIf { project.hasProperty("refreshSca") || !outputFile.exists() } | ||
|
bric3 marked this conversation as resolved.
|
||
|
|
||
| doLast { | ||
| val token = System.getenv("GITHUB_TOKEN") | ||
| val apiUrl = | ||
| project.findProperty("scaEnrichmentsUrl")?.toString() ?: SCA_ENRICHMENTS_API_DEFAULT | ||
|
|
||
| logger.lifecycle("Fetching GHSA enrichment index from $apiUrl ...") | ||
| @Suppress("UNCHECKED_CAST") | ||
| val fileList = githubFetch(apiUrl, token) as List<Map<String, Any>> | ||
| 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() | ||
| } | ||
| } | ||
| } | ||
83 changes: 83 additions & 0 deletions
83
buildSrc/src/main/kotlin/datadog/gradle/sca/GhsaEnrichmentParser.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
|
jandro996 marked this conversation as resolved.
|
||
| } | ||
| } | ||
|
|
||
| return symbols | ||
| } | ||
| } | ||
75 changes: 75 additions & 0 deletions
75
buildSrc/src/test/kotlin/datadog/gradle/plugin/sca/ScaEnrichmentsPluginTest.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.