Skip to content

Implement SCA Reachability: detect and report vulnerable library classes loaded at runtime#11352

Draft
jandro996 wants to merge 24 commits into
masterfrom
alejandro.gonzalez/sca-reachability
Draft

Implement SCA Reachability: detect and report vulnerable library classes loaded at runtime#11352
jandro996 wants to merge 24 commits into
masterfrom
alejandro.gonzalez/sca-reachability

Conversation

@jandro996
Copy link
Copy Markdown
Member

@jandro996 jandro996 commented May 12, 2026

What Does This Do

Build — symbol database generation

  • New Gradle task generateScaCvesJson in appsec/build.gradle downloads GHSA enrichments from the private DataDog/sca-reachability-database repo and bundles sca_cves.json in the agent JAR as a versioned classpath resource (CI builds use the committed copy — no network access required)
  • GhsaEnrichmentParser (buildSrc, Kotlin) transforms the GHSA JSON format into the internal sca_cves.json format: one entry per Maven artifact, class names in JVM internal format (slashes), method: null for current class-level symbols with the field reserved for future method-level symbols
  • Multi-package GHSA entries (e.g. Spring4Shell affecting 5 artifacts with different version ranges) are expanded into N independent records matching the RFC one-dependency-per-target contract

Runtime — symbol matching

  • ScaCveDatabase loads and indexes sca_cves.json at startup, mapping JVM internal class names to ScaEntry vulnerability records for O(1) lookup
  • VersionRangeParser evaluates GHSA version range strings (< 2.6.7.3, >= 2.7.0, < 2.7.9.5, = 9.5.0) using ComparableVersion (Maven 3.9.9 semantics — handles 4-part versions and qualifiers such as .RELEASE)

Runtime — detection

  • ScaReachabilityTransformer implements ClassFileTransformer: observation-only for class-level symbols (always returns null); injects ASM bytecode callbacks at method entry for method-level symbols
  • Two-step version resolution: resolveVersionForArtifact() first checks the class's own JAR, then falls back to a classpath scan for aggregator/starter POM artifacts (e.g. spring-boot-starter-web watches @Controller which lives in spring-context.jar)
  • JDK/bootstrap classes (protectionDomain == null) are not used as reachability proxies in the startup scan — they are always loaded regardless of which library is present and would produce classpath-presence false positives rather than runtime reachability signals
  • ScaReachabilitySystem is the subsystem entry point called from Agent.java via reflection (same pattern as AppSecSystem/IastSystem), gated on DD_APPSEC_SCA_ENABLED

Telemetry

  • ScaReachabilityHit and ScaReachabilityCollector (in internal-api) bridge the transformer (appsec) and the periodic action (telemetry) without circular dependencies — same pattern as WafMetricCollector
  • ScaReachabilityPeriodicAction drains hits on each heartbeat and reports them via app-dependencies-loaded with a metadata array of type "reachability", grouping multiple CVEs for the same artifact version into a single dependency entry
  • Dependency extended with optional reachabilityMetadata: List<String> field; TelemetryRequestBody.writeDependency() serializes it when present

Callsite capture (method-level)

  • For method-level hits, path/symbol/line in the telemetry payload represent the application frame that invoked the vulnerable method (the callsite), not the vulnerable method itself — mirrors the Python tracer (dd-trace-py#17156)
  • ScaReachabilitySystem.findCallsite() walks Thread.getStackTrace() using AbstractStackWalker.isNotDatadogTraceStackElement (made public) to skip agent/JDK frames and return the first application frame above the vulnerable class
  • Stack walk happens in the handler (ScaReachabilitySystem, appsec module) — ScaReachabilityCallback (bootstrap) stays minimal with only dedup and dispatch
  • Fallback: if no application frame is found, reports the vulnerable symbol so the backend knows the method was reached

Motivation

Static SCA produces false positives because a vulnerable symbol may be in the dependency graph but never invoked at runtime. This subsystem provides a runtime reachability signal — reporting which vulnerable library classes/methods were actually loaded/called — without writing to spans or traces.

RFC: https://docs.google.com/document/d/1xDw9iG6h41VCEgJGTqoJdruRaNS4pYgNifO6nhiizWA/

Additional Notes

Class-level vs method-level symbols: The enrichment database currently contains only type: "class" symbols. The implementation supports both from day one: ScaSymbol.method() is null today and will carry the method name when the database adds method-level entries. For class-level hits, ScaReachabilityHit.CLASS_LEVEL_SYMBOL ("<clinit>") is used as the symbol name and 1 as a placeholder line number, per agreement with the backend team (APPSEC-62260).

GHSA IDs as vulnerability identifiers: The enrichment database uses GHSA IDs as filenames with no embedded CVE IDs. GHSA IDs are used directly as vuln_id in the telemetry payload, per agreement with the backend team.

Dedup strategy: Class-level dedup lives in ScaReachabilityTransformer.reportedHits (transformer-side). Method-level dedup lives in ScaReachabilityCallback.reported (bootstrap-side, persists across retransformClasses() calls). The dedup key is vulnId|artifact|dotClassName|methodName — including the class name prevents suppression when two different classes in the same artifact share a method name.

Path B removed: JDK symbols in the database (e.g. java.sql.PreparedStatement in the PostgreSQL advisory) were previously used to infer library presence via classpath scan. This produced classpath-presence false positives. The same entries also include library-specific classes (e.g. org.postgresql.ds.PGSimpleDataSource) that are detected correctly when actually loaded.

Contributor Checklist

Jira ticket: APPSEC-62260

Note: Once your PR is ready to merge, add it to the merge queue by commenting /merge. /merge -c cancels the queue request. /merge -f --reason "reason" skips all merge queue checks; please use this judiciously, as some checks do not run at the PR-level. For more information, see this doc.

jandro996 added 2 commits May 12, 2026 16:08
Adds a new SCA Reachability subsystem that reports which vulnerable
library classes were actually loaded at runtime, reducing false positives
from static dependency scanning. Gated on DD_APPSEC_SCA_ENABLED.

Key components:
- Gradle task downloads GHSA enrichments from sca-reachability-database
  and generates sca_cves.json bundled in the agent jar at build time
- ClassFileTransformer (observation-only) detects when vulnerable classes
  are loaded, resolves JAR versions via pom.properties, and checks semver
  ranges using ComparableVersion (Maven semantics)
- ScaReachabilityCollector bridges the transformer and telemetry without
  circular dependencies, following the WafMetricCollector pattern
- ScaReachabilityPeriodicAction reports hits on each app-dependencies-loaded
  heartbeat by adding reachability metadata to existing dependency entries
…n task

The Gradle task now writes to src/main/resources/ and runs only when
-PrefreshSca is passed or the file is absent, so CI builds never need
network access to the private sca-reachability-database repo.
@jandro996
Copy link
Copy Markdown
Member Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e607887e99

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

jandro996 added 6 commits May 13, 2026 10:40
On Java 9+, the system classloader (jdk.internal.loader.ClassLoaders$AppClassLoader)
no longer extends URLClassLoader, so the URLClassLoader chain walk misses all
main classpath entries. Add a fallback that reads java.class.path to cover
this case, deduplicating with a HashSet<URL> to avoid scanning the same JAR twice.
…rivate

Test verifies: (1) system classloader is not URLClassLoader on Java 9+, and
(2) findArtifactVersionInClasspath finds artifacts via java.class.path fallback.
Applies to Java 9 and all subsequent JDKs (permanent JDK design change).
When sca_cves.json contains symbols with method != null, the transformer
injects a static callback at method entry using ASM. The callback fires
the first time the method is called and reports via ScaReachabilityCallback
(bootstrap classloader, accessible from any application class).

Key changes:
- ScaReachabilityCallback in agent-bootstrap: bootstrap-visible callback
  with runtime dedup (vulnId|artifact|methodName) and handler registration
- ScaReachabilityTransformer: injectMethodCallbacks() uses ByteBuddy ASM
  to inject INVOKESTATIC at first line number of each target method;
  processClass() routes class-level vs method-level symbols separately
- ScaReachabilityHit: adds symbolName + line fields; existing constructor
  defaults to <clinit>/1 for class-level hits (backward compatible)
- ScaReachabilityPeriodicAction: buildMetadataValue() now uses
  hit.symbolName() and hit.line() instead of hardcoded values
- 6 tests: ASM injection, callback fires on method call only, dedup,
  multiple methods, safe method not reported, class-level unaffected
…rsion-unresolved

Two cases required deferred retransformation:
1. Classes already loaded at startup (before transformer registered): the bytecode
   callback cannot be injected without retransformClasses()
2. Classes where DependencyResolver returned empty deps at load time (version not yet
   resolvable): empty results are now not cached to allow retries

ScaReachabilityTransformer now stores Instrumentation and exposes
performPendingRetransforms() called on each telemetry heartbeat via a Runnable
callback in ScaReachabilityCollector.periodicWorkCallback. Classes are queued via:
- pendingRetransform (Class<?> queue) from checkAlreadyLoadedClasses
- pendingRetransformNames (String set) from processClass on empty deps
retransformClasses() always starts from the ORIGINAL class file bytes,
not from the previously-transformed bytes. A dedup check in injectCallbacks()
that blocked re-injection on the second pass caused the callback to be
removed (the class was returned to its original, un-instrumented state).

The authoritative dedup for method-level hits is ScaReachabilityCallback.reported
(bootstrap-side), which persists across retransformations regardless of how
many times transform() is called on the same class.

Also update .claude-invariants.md: retransformClasses is now used (for
method-level only), the cache constraint clarified, and the dedup invariant
documents the two-level approach (transformer for class-level, bootstrap for
method-level).
…avadoc, add retransform tests

- performPendingRetransforms(): early return when instrumentation is null (unit test safety)
- ScaReachabilityCollector: encapsulate periodicWorkCallback as private with getter/setter
- ScaReachabilityTransformer class Javadoc: update dedup description from (vulnId,artifact)
  pair to (vulnId,artifact,symbolName) tuple; document two-level dedup strategy
- Add 3 tests for performPendingRetransforms(): no-op with null inst, retransformClasses
  called for pending queue, no-op when both queues empty
@jandro996
Copy link
Copy Markdown
Member Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 82ea8065d9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread dd-java-agent/appsec/src/main/java/com/datadog/appsec/sca/ScaCveDatabase.java Outdated
jandro996 added 5 commits May 13, 2026 12:42
…esolution

P1: Replace StandardCharsets.UTF_8 with "UTF-8" string in ScaCveDatabase.load().
java.nio.* is forbidden during premain (bootstrap_design_guidelines.md) because
it can trigger premature provider initialization before the app configures the
runtime.

P2: Add classpath fallback in resolveVersionForArtifact() for entries where the
vulnerable artifact is an aggregator/starter POM whose watched classes live in a
transitive dependency JAR (e.g., spring-boot-starter-web watches @controller but
@controller is defined in spring-context.jar, not the starter). The new helper
first checks the class's own JAR, then falls back to findArtifactVersionInClasspath
with a hit cache (classpathArtifactCache). processPathA uses the same helper for
consistency.
…IfPresent helper

- Add CLASS_LEVEL_SYMBOL = "<clinit>" constant to avoid magic string repetition
  (appeared 3 times in the same class; a typo would silently produce wrong symbol names)
- Extract reportClassLevelHitIfPresent(entry, version, internalClassName) helper
  to unify identical class-level symbol matching loops in processPathA, processPathB,
  and processClass — all three now delegate to the single helper
Move CLASS_LEVEL_SYMBOL = "<clinit>" to ScaReachabilityHit (internal-api) as a public
constant so both the transformer (appsec) and the telemetry payload builder share the
canonical definition without cross-module string duplication. The convenience constructor
also uses the constant now. ScaReachabilityTransformer delegates to ScaReachabilityHit.CLASS_LEVEL_SYMBOL.

Fix misleading comment in processClass: "We enqueue via classBeingRedefined is null here"
→ explains that classBeingRedefined is null on first class load, preventing direct
Class<?> queuing, so scheduleRetransformByName is used instead.
…lback

- ScaCveDatabase: move "java.nio.* forbidden in premain" comment from the
  imports block to inline at the InputStreamReader construction site
  (comments in imports are unusual and smola flags verbose placement)

- ScaReachabilityTransformer.resolveVersionForArtifact: make package-private
  for testing; add 4 tests covering the two-step fallback:
  (1) version from classJarDeps directly
  (2) classpath fallback when classJarDeps is empty (transitive JAR case)
  (3) classpathArtifactCache hit on second call
  (4) null for absent artifact
- Remove empty visitCode() in MethodEntryInjector: the method only called
  super.visitCode() and its comment was misleading — the actual no-debug-info
  fallback injection is handled by ensureInjected() in the visitInsn/visitVarInsn/
  visitMethodInsn/visitFieldInsn overrides, not here

- Remove private CLASS_LEVEL_SYMBOL alias in ScaReachabilityTransformer: the
  constant is used in exactly one place (reportClassLevelHitIfPresent) and
  ScaReachabilityHit.CLASS_LEVEL_SYMBOL is self-documenting at that site;
  the alias added a private field with no benefit after the constant was moved
  to ScaReachabilityHit in a previous commit
@jandro996
Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

jandro996 added 8 commits May 13, 2026 14:57
Per the RFC and Python implementation (dd-trace-py#17156), the telemetry
payload path/symbol/line for method-level hits must report the APPLICATION
FRAME that called the vulnerable method (the callsite), not the vulnerable
method itself.

ScaReachabilityCallback.onMethodHit() now walks Thread.getStackTrace() to
find the first non-agent, non-JDK frame after the vulnerable class:
  ScaReachabilityCallback.onMethodHit  (skip - us)
  com.foo.VulnerableClass.method       (skip - vulnerable class)
  com.myapp.UserService.processRequest (CALLSITE - report this)

The dotClassName/methodName params are still baked into the bytecode and
used only for deduplication (vulnId|artifact|methodName key). The handler
receives the callsite's class/method/line for telemetry.

Fallback: if no application frame is found (e.g. called from JDK internals),
reports the vulnerable symbol itself so the backend knows it was reached.

Class-level hits (<clinit>) are unchanged — no callsite at class load time.
ScaReachabilityCallback (bootstrap) must stay minimal — complex logic
does not belong there. Move findCallsite() to ScaReachabilitySystem
which has access to internal-api utilities.

The handler runs synchronously so the full call stack is still present:
  ScaReachabilitySystem handler
  ScaReachabilityCallback.onMethodHit
  <vulnerable method>
  <application callsite>  ← reported

Uses the same class-prefix predicate as AbstractStackWalker.
isNotDatadogTraceStackElement (package-private, so replicated inline)
to skip agent/JDK frames, consistent with the IAST trie-based filtering
infrastructure used elsewhere in the codebase.
…ltering

Make isNotDatadogTraceStackElement public in AbstractStackWalker so SCA
Reachability can use the existing predicate directly rather than duplicating
the 3 class-prefix conditions inline.
… path

ScaReachabilitySystemCallsiteTest covers:
- findCallsite returns null when vulnerable class is not on the stack
  (triggers fallback: handler reports the vulnerable symbol itself)
- findCallsite skips the vulnerable class frame and returns the first
  non-agent frame above it (using java.lang.Thread as a non-agent class
  guaranteed to be at the top of getStackTrace())

Note on the method-level integration test: TargetClass is in
com.datadog.appsec.sca.* (agent namespace) so AbstractStackWalker filters
it as agent code and findCallsite() returns null. The test now documents
this fallback behaviour explicitly. In production the vulnerable class is
always a 3rd-party library (e.g. com.fasterxml.jackson.*) and the happy
path fires correctly — verified by ScaReachabilitySystemCallsiteTest.
The stream().anyMatch() for detecting method-level symbols was computed
for every entry unconditionally. It is only needed when version == null
(deps not yet resolvable). Moving the check inside the version==null
branch eliminates the stream allocation on the common path where the
version resolves successfully.
@jandro996
Copy link
Copy Markdown
Member Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6008ac9ca0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

…cators

JDK classes (e.g. java.sql.PreparedStatement, protectionDomain==null) are
loaded by ANY app that uses JDBC, regardless of which driver is present.
Using their presence to infer that a specific library (e.g. PostgreSQL) is
"reachable" produces classpath-presence false positives, not runtime
reachability signals.

Entries that list JDK symbols (e.g. the PostgreSQL advisory) also include
library-specific classes (e.g. org.postgresql.ds.PGSimpleDataSource) that
Path A correctly detects when those classes are actually loaded.

In checkAlreadyLoadedClasses(), classes with no code source (JDK/bootstrap)
are now skipped silently. The invariants and KB are updated accordingly.
@jandro996
Copy link
Copy Markdown
Member Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 750b3c3877

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

The key vulnId|artifact|methodName collapses hits for different classes
in the same artifact that share a method name (e.g. ClassA.parse and
ClassB.parse would both map to the same key, suppressing the second).

Fix: add dotClassName to the key → vulnId|artifact|dotClassName|methodName
so each class+method combination is tracked independently.

Add regression test that verifies both hits are reported when the same
method name exists in two different vulnerable classes of the same artifact.
@jandro996
Copy link
Copy Markdown
Member Author

@codex revew

@chatgpt-codex-connector
Copy link
Copy Markdown

I reviewed the PR context and the currently checked-out code for the flagged areas. I don’t see any new blocking issues beyond what has already been discussed/resolved.

Review result

Commands used

  • git status --short && git rev-parse --abbrev-ref HEAD
  • sed -n ... ScaReachabilityTransformer.java / ScaCveDatabase.java / ScaReachabilityCallback.java
  • rg -n "checkAlreadyLoadedClasses|findArtifactVersionInClasspath|java.class.path|protectionDomain == null|Path B|isJdk" dd-java-agent/appsec/src/main/java/com/datadog/appsec/sca/ScaReachabilityTransformer.java
  • nl -ba ... | sed -n ... (to capture exact line references)

No code changes were made, so no commit or follow-up PR was created.

View task →

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant