Skip to content

Read agent jar resources through the retained jar handle - #12426

Open
claponcet wants to merge 2 commits into
masterfrom
clara.poncet/agent-jar-resource-fallback
Open

Read agent jar resources through the retained jar handle#12426
claponcet wants to merge 2 commits into
masterfrom
clara.poncet/agent-jar-resource-fallback

Conversation

@claponcet

@claponcet claponcet commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

What Does This Do

DatadogClassLoader now reads indexed agent resources from the JarFile handle it opened at construction time — the same source class loading already reads from — instead of resolving the agent jar by pathname on every read. Resources not owned by the retained jar, including the manifest which is deliberately excluded from the index, continue through the previous delegation path unchanged.

If the retained jar owns an entry but opening it fails, the lookup is not delegated: doing so could serve the same entry from a different agent jar now present at the original pathname.

Motivation

A JVM can outlive the dd-java-agent.jar pathname it started with. For example, package upgrades and configuration management commonly replace the jar on disk while the JVM keeps running. Class loading continues through the retained jar handle, but resource loading went through the jar:file:...!/entry URL returned by findResource(), which re-resolves the pathname on every read.

Once the jar at that pathname has been replaced or removed, that read can either:

  • fail — and ClassLoader.getResourceAsStream() turns the IOException into a silent null, so an unreadable resource is indistinguishable from an absent one; or
  • succeed — and serve a resource belonging to a different build of the agent, while classes keep coming from the old handle. The worst case is appsec/native_libs/.../libddwaf.so, where a native library could be loaded against JNI bindings from another build.

APPSEC-69906 recorded the same symptom for default_config.json: AppSec received null and reported java.io.IOException: Resource default_config.json not found, leaving the WAF with no ruleset and no signal that it was unprotected. The available evidence does not establish that live jar replacement caused that incident; this change hardens the classloader against that concrete failure mode and enforces that indexed resource streams come from the same jar handle as agent classes.

Resources whose first read happens late are the most exposed to pathname changes. Most agent resources (InstrumenterIndex, KnownTypesIndex, ClassFileLocators, and the system-classloader reads) resolve during startup. Two late readers are AppSec's default ruleset and the WAF native library, both deferred to the remote-config poller thread under ENABLED_INACTIVE.

Additional Notes

For indexed agent resources, getResourceAsStream() now intentionally gives the retained agent jar precedence over BootstrapProxy and the parent loader. In normal agent wiring, BootstrapProxy is initially registered with the same agent jar URL and the parent is the bootstrap or platform loader. In CLI wiring the parent may be an application loader, so an application resource with the same logical name as an indexed agent resource will no longer take precedence.

Across a built shadow jar's 17528 entries there are 15 cases where a module-prefixed entry also has a literal copy at the un-prefixed path. All are META-INF/MANIFEST.MF, which AgentJarIndex.IndexGenerator excludes from the index, so they take the unchanged fallback path. Every product resource (default_config.json, native_libs/**, third_party_libraries.json, metricconfigs.txt, appsec.version, and the blocking templates) resolves to exactly the entry findResource() would have built a URL for.

Fallback is preserved when the retained jar does not own the requested resource or the index maps it to an entry the retained jar does not contain. It is deliberately not used after an IOException opening an entry that the retained jar does contain, because the fallback could serve that entry from a replacement build.

Deliberately out of scope, since each needs a jar swap inside the startup window and all three want the same URLStreamHandler work:

  • getResource()/getResources() still return pathname-resolved URLs, so a caller that opens the URL itself can still read a replaced jar.
  • ClassFileLocators reads class bytes via BootstrapProxy without going through DatadogClassLoader.
  • components/native-loader locates via getResource() then openStream(); it has no non-test consumers today.

A resource present only in the new jar can still fall through to BootstrapProxy and be served from the replacement. The guarantee is that an entry present in the retained jar is never read from the replacement, not that the replacement can never be consulted.

Follow-up worth doing independently of this change: AppSecConfigServiceImpl.init() failing on the post-startup remote-config path throws AbortStartupException, which is caught as a plain RuntimeException and rate-limit-logged hours after startup, leaving defaultConfigActivated false. Any cause of that failure is currently silent.

Contributor Checklist

Jira ticket: APPSEC-69906

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claponcet claponcet added type: bug fix Bug fix comp: core Tracer core tag: ai generated Largely based on code generated by an AI or LLM labels Sep 8, 2026
@datadog-datadog-prod-us1

This comment has been minimized.

@dd-octo-sts

dd-octo-sts Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

🟢 Java Benchmark SLOs — All performance SLOs passed

Suite Status
Startup 🟢 pass

SLO thresholds are defined here based on automatically generated metrics. A warning is raised when results are within 5% of the threshold.

PR vs. master results
Scenario Candidate master Δ (95% CI of mean)
startup:insecure-bank:iast:Agent 14.80 s 14.67 s [-0.2%; +1.9%] (no difference)
startup:insecure-bank:tracing:Agent 13.56 s 13.67 s [-1.6%; +0.0%] (no difference)
startup:petclinic:appsec:Agent 17.39 s 17.44 s [-1.0%; +0.5%] (no difference)
startup:petclinic:iast:Agent 17.28 s 17.53 s [-2.2%; -0.6%] (maybe better)
startup:petclinic:profiling:Agent 17.47 s 17.08 s [+1.2%; +3.3%] (significantly worse)
startup:petclinic:sca:Agent 17.47 s 17.49 s [-1.2%; +1.0%] (no difference)
startup:petclinic:tracing:Agent 16.35 s 16.56 s [-2.3%; -0.1%] (maybe better)

Commit: b6d3bec3 · CI Pipeline · Benchmarking Platform UI


Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion.

@claponcet
claponcet marked this pull request as ready for review September 8, 2026 14:59
@claponcet
claponcet requested a review from a team as a code owner September 8, 2026 14:59
@claponcet
claponcet requested review from amarziali and removed request for a team September 8, 2026 14:59
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 8, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-08T15:04:06.785588Z c57654d Draft marked ready
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@datadog-datadog-prod-us1 datadog-datadog-prod-us1 Bot left a comment

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.

Datadog Autotest: PASS

More details

The retained jar handle supplies indexed resources. The old lookup path still supplies resources that the retained jar does not contain.

Was this helpful? React 👍 or 👎

Open Bits AI session

🤖 Datadog Autotest · Commit c57654d · What is Autotest? · @DataDog review to ask questions · Any feedback? Reach out in #autotest

Comment on lines +79 to +82
InputStream is = findResourceAsStream(name);
if (null == is) {
is = super.getResourceAsStream(name);
}

@amarziali amarziali Sep 8, 2026

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.

Here it seems we are flipping the resolution order. Is that a safe change?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, this is intentional for indexed agent resources so their bytes always match the classes loaded from the retained jar, rather than being shadowed by a parent or replacement jar. Unindexed resources still delegate normally; b6d3bec336 adds tests for both the parent-collision case and manifest fallback.

@dougqh dougqh left a comment

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.

Automated review findings (see inline comments).

return super.getResource(name);
}

@Override

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.

This fix only patches getResourceAsStream(). getResource()/findResource() still build a pathname-based jar: URL that has the same live-jar-replacement vulnerability. A caller doing classLoader.getResource(name).openStream() after the agent jar is replaced/deleted on disk would still hit the original bug (stale/foreign content or an IOException) since only the getResourceAsStream path was patched. Worth confirming this is intentionally out of scope, or fixing both paths.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point—this remains intentionally out of scope. The reported AppSec resource and other bundled AppSec resources use getResourceAsStream(), which now reads indexed entries from the retained jar.

Fixing getResource() requires a retained-jar URL implementation and changes to BootstrapProxy-first resolution, with broader compatibility implications.

}
}
}
return null;

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.

findResourceAsStream duplicates the entryName/getJarEntry lookup already in findResource() just above/below it in this file. Consider extracting a shared private JarEntry resourceJarEntry(String name) used by both, so a future change to jar-entry resolution (e.g. AgentJarIndex versioning or case-insensitive lookup) doesn't need to be applied in two places and risk silent divergence.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good suggestion—done in b6d3bec336. Both getResourceAsStream() and findResource() now use the shared findResourceEntry() helper for index resolution and jar-entry lookup.

* Reads a resource we ship from the agent jar, or returns {@code null} if the agent jar does not
* contain it. Note {@link BootstrapProxy} is backed by the very same jar, so consulting this
* before delegating cannot shadow a different resource of the same name.
*/

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.

This javadoc states BootstrapProxy is "backed by the very same jar", but that's only true by convention today (both call sites happen to pass the same URL) — BootstrapProxy is explicitly designed to hold multiple jars. If a future change adds a second BootstrapProxy.addBootstrapResource(differentJarUrl) call, findResourceAsStream's own-jar-first ordering could silently shadow a resource BootstrapProxy would otherwise have served, contradicting this documented guarantee. Consider noting this is an invariant maintained by callers, not enforced here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed—updated in b6d3bec336. The Javadoc now states that production initially registers the same agent jar URL, while BootstrapProxy may contain additional URLs, and documents the intentional precedence for indexed agent resources.

// deleting a file that is still open is rejected on Windows, so the scenario cannot arise there
assumeFalse(System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("win"));

File jar = new File(tempDir, "testjar-jdk8");

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.

The two new regression tests duplicate identical jar-copy-and-construct-loader setup boilerplate. Consider extracting a shared private DatadogClassLoader loadCopyOfTestJar(File tempDir) helper so a future change to the test fixture path or the constructor signature only needs updating in one place.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I considered this, but kept the small setup explicit because the jar pathname and the point where the loader retains its handle are central to each test’s lifecycle. The tests diverge immediately afterward, while the less scenario-specific jar-writing and byte-reading operations are shared through helpers.

Comment on lines +79 to +84
InputStream is = findResourceAsStream(name);
if (null == is) {
is = super.getResourceAsStream(name);
}
return is;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

findResourceAsStream returns null both when the agent jar does not contain the
resource and when JarFile.getInputStream throws an IOException (line 100-103).
In the second case, getResourceAsStream (line 79-81) falls through to
super.getResourceAsStream, which resolves the resource through getResource() and
can reopen the jar by pathname. If that pathname now holds a replacement build, this
would serve the replacement's copy of the resource - the exact cross-build mixing this
change is meant to prevent.

This requires a narrow double condition (entry present in the retained handle's index,
but its read independently throwing IOException), so I'd call it non-blocking rather
than a blocker, but worth a comment or a follow-up: e.g. treating a caught IOException
here as "resource not found, and not exposed to the fallback either" (return a marker
distinct from null, or rethrow) would close the gap.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch—fixed in b6d3bec336. We now resolve the JarEntry first: a missing entry delegates normally, while an IOException opening an owned entry is logged and returns null without falling back to a potentially different jar.

@jandro996

jandro996 commented Sep 9, 2026

Copy link
Copy Markdown
Member

Just a note on the framing in the PR description: APPSEC-69906's own root-cause analysis is flagged as "weak-moderate confidence" and points at classloading isolation/timing during early Tomcat/Catalina bootstrap, not necessarily a live jar replacement on disk - the ticket itself says reproduction against a live Tomcat
bootstrap classloader would be needed to confirm. The fix is sound regardless of which mechanism is the actual cause (resources should come from the retained jar handle the same way classes already do), but it might be worth softening the certainty in the description, or noting the jar-replacement scenario as "a" cause the fix also covers
rather than "the" confirmed cause.

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

Labels

comp: core Tracer core tag: ai generated Largely based on code generated by an AI or LLM type: bug fix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants