Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 9 additions & 1 deletion dd-java-agent/agent-profiling/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ excludedClassesCoverage += [
'com.datadog.profiling.agent.ProfilingAgent',
'com.datadog.profiling.agent.ProfilingAgent.ShutdownHook',
'com.datadog.profiling.agent.ProfilingAgent.DataDumper',
'com.datadog.profiling.agent.ProfilerFlare'
'com.datadog.profiling.agent.ProfilerFlare',
'com.datadog.profiling.agent.ScrubRecordingDataListener',
'com.datadog.profiling.agent.ScrubRecordingDataListener.ScrubbedRecordingData'
]

dependencies {
Expand All @@ -23,6 +25,7 @@ dependencies {
api project(':dd-java-agent:agent-profiling:profiling-ddprof')
api project(':dd-java-agent:agent-profiling:profiling-uploader')
api project(':dd-java-agent:agent-profiling:profiling-controller')
implementation project(':dd-java-agent:agent-profiling:profiling-scrubber')
api project(':dd-java-agent:agent-profiling:profiling-controller-jfr')
api project(':dd-java-agent:agent-profiling:profiling-controller-jfr:implementation')
api project(':dd-java-agent:agent-profiling:profiling-controller-ddprof')
Expand All @@ -42,6 +45,11 @@ configurations {

tasks.named("shadowJar", ShadowJar) {
dependencies deps.excludeShared

// Exclude multi-release versioned classes from jafar-parser.
// These are duplicates of base classes for newer Java APIs and confuse
// the GraalVM native-image builder when the profiling jar is embedded in the agent.
exclude 'META-INF/versions/**'
}

tasks.named("jar", Jar) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import java.nio.file.Path;
import java.time.Instant;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;

final class DatadogProfilerRecordingData extends RecordingData {
private final Path recordingFile;
Expand Down Expand Up @@ -36,4 +37,10 @@ public void release() {
public String getName() {
return "ddprof";
}

@Nullable
@Override
public Path getPath() {
return recordingFile;
}
}
17 changes: 17 additions & 0 deletions dd-java-agent/agent-profiling/profiling-scrubber/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
apply from: "$rootDir/gradle/java.gradle"

minimumInstructionCoverage = 0.0
minimumBranchCoverage = 0.0

dependencies {
api libs.slf4j

implementation(libs.jafar.tools) {
// Agent has its own slf4j binding
exclude group: 'org.slf4j', module: 'slf4j-simple'
}

testImplementation libs.bundles.junit5
testImplementation libs.bundles.mockito
testImplementation libs.bundles.jmc
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.datadog.profiling.scrubber;

import io.jafar.tools.Scrubber;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

/** Provides the default scrub definition targeting sensitive JFR event fields. */
public final class DefaultScrubDefinition {

private static final Map<String, Scrubber.ScrubField> DEFAULT_SCRUB_FIELDS;

static {
Map<String, Scrubber.ScrubField> fields = new HashMap<>();
// ScrubField(keyField, valueField, predicate): null keyField = scrub all values unconditionally
// System properties may contain API keys, passwords
fields.put("jdk.InitialSystemProperty", new Scrubber.ScrubField(null, "value", (k, v) -> true));
// JVM args may contain credentials in -D flags
fields.put("jdk.JVMInformation", new Scrubber.ScrubField(null, "jvmArguments", (k, v) -> true));
// Env vars may contain secrets
fields.put(
"jdk.InitialEnvironmentVariable", new Scrubber.ScrubField(null, "value", (k, v) -> true));
// Process command lines may reveal infrastructure
fields.put("jdk.SystemProcess", new Scrubber.ScrubField(null, "commandLine", (k, v) -> true));
DEFAULT_SCRUB_FIELDS = Collections.unmodifiableMap(fields);
}

/**
* Creates a scrubber with the default scrub definition.
*
* @param excludeEventTypes list of event type names to exclude from scrubbing, or null for none
* @return a configured {@link JfrScrubber}
*/
public static JfrScrubber create(List<String> excludeEventTypes) {
Set<String> excludeSet =
excludeEventTypes != null
? new HashSet<>(excludeEventTypes)
: Collections.<String>emptySet();

return new JfrScrubber(
eventTypeName -> {
if (excludeSet.contains(eventTypeName)) {
return null;
}
return DEFAULT_SCRUB_FIELDS.get(eventTypeName);
});
}

private DefaultScrubDefinition() {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.datadog.profiling.scrubber;

import io.jafar.tools.Scrubber;
import java.nio.file.Path;
import java.util.function.Function;

/**
* Thin wrapper around {@link Scrubber} from jafar-tools, hiding jafar types from consumers outside
* the profiling-scrubber module.
*/
public final class JfrScrubber {

private final Function<String, Scrubber.ScrubField> scrubDefinition;

/** Package-private: use {@link DefaultScrubDefinition#create} to obtain an instance. */
JfrScrubber(Function<String, Scrubber.ScrubField> scrubDefinition) {
this.scrubDefinition = scrubDefinition;
}

/**
* Scrub the given file by replacing targeted field values with 'x' bytes.
*
* @param input the input file to scrub
* @param output the output file to write the scrubbed content to
* @throws Exception if an error occurs during parsing or writing
*/
public void scrubFile(Path input, Path output) throws Exception {
Scrubber.scrubFile(input, output, scrubDefinition);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package com.datadog.profiling.scrubber;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.openjdk.jmc.common.item.Attribute.attr;
import static org.openjdk.jmc.common.unit.UnitLookup.PLAIN_TEXT;

import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Collections;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.openjdk.jmc.common.item.IAttribute;
import org.openjdk.jmc.common.item.IItem;
import org.openjdk.jmc.common.item.IItemCollection;
import org.openjdk.jmc.common.item.IItemIterable;
import org.openjdk.jmc.common.item.IMemberAccessor;
import org.openjdk.jmc.common.item.ItemFilters;
import org.openjdk.jmc.flightrecorder.JfrLoaderToolkit;

class JfrScrubberTest {

@TempDir Path tempDir;

private Path inputFile;

@BeforeEach
void setUp() throws IOException {
inputFile = tempDir.resolve("input.jfr");
try (InputStream is = getClass().getResourceAsStream("/test-recording.jfr")) {
if (is == null) {
throw new IllegalStateException("test-recording.jfr not found in test resources");
}
Files.copy(is, inputFile, StandardCopyOption.REPLACE_EXISTING);
}
}

@Test
void scrubInitialSystemPropertyValues() throws Exception {
JfrScrubber scrubber = DefaultScrubDefinition.create(null);
Path outputFile = tempDir.resolve("output.jfr");
scrubber.scrubFile(inputFile, outputFile);

assertTrue(Files.exists(outputFile));
assertTrue(Files.size(outputFile) > 0, "Scrubbed file should not be empty");

// Verify scrubbed values contain only 'x' characters
IItemCollection events = JfrLoaderToolkit.loadEvents(outputFile.toFile());
IItemCollection systemPropertyEvents =
events.apply(ItemFilters.type("jdk.InitialSystemProperty"));
assertTrue(systemPropertyEvents.hasItems(), "Expected jdk.InitialSystemProperty events");

IAttribute<String> valueAttr = attr("value", "value", "value", PLAIN_TEXT);
for (IItemIterable itemIterable : systemPropertyEvents) {
IMemberAccessor<String, IItem> accessor = valueAttr.getAccessor(itemIterable.getType());
for (IItem item : itemIterable) {
String value = accessor.getMember(item);
if (value != null && !value.isEmpty()) {
assertTrue(
value.chars().allMatch(c -> c == 'x'),
"System property value should be scrubbed: " + value);
}
}
}
}

@Test
void scrubWithNoMatchingEvents() throws Exception {
// Scrubber with all default events excluded — nothing matches
JfrScrubber scrubber = new JfrScrubber(name -> null);
Path outputFile = tempDir.resolve("output.jfr");
scrubber.scrubFile(inputFile, outputFile);

// Output should be identical to input when no events match
assertEquals(Files.size(inputFile), Files.size(outputFile));
}

@Test
void scrubWithExcludedEventType() throws Exception {
// Exclude jdk.InitialSystemProperty from scrubbing
JfrScrubber scrubber =
DefaultScrubDefinition.create(Collections.singletonList("jdk.InitialSystemProperty"));
Path outputFile = tempDir.resolve("output.jfr");
scrubber.scrubFile(inputFile, outputFile);

assertTrue(Files.exists(outputFile));
assertTrue(Files.size(outputFile) > 0);
}
}
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

import static datadog.environment.JavaVirtualMachine.isJavaVersion;
import static datadog.environment.JavaVirtualMachine.isJavaVersionAtLeast;
import static datadog.trace.api.config.ProfilingConfig.PROFILING_SCRUB_ENABLED;
import static datadog.trace.api.config.ProfilingConfig.PROFILING_SCRUB_ENABLED_DEFAULT;
import static datadog.trace.api.config.ProfilingConfig.PROFILING_SCRUB_FAIL_OPEN;
import static datadog.trace.api.config.ProfilingConfig.PROFILING_SCRUB_FAIL_OPEN_DEFAULT;
import static datadog.trace.api.config.ProfilingConfig.PROFILING_START_FORCE_FIRST;
import static datadog.trace.api.config.ProfilingConfig.PROFILING_START_FORCE_FIRST_DEFAULT;
import static datadog.trace.api.telemetry.LogCollector.SEND_TELEMETRY;
Expand All @@ -24,6 +28,7 @@
import datadog.trace.api.profiling.RecordingDataListener;
import datadog.trace.api.profiling.RecordingType;
import datadog.trace.bootstrap.config.provider.ConfigProvider;
import de.thetaphi.forbiddenapis.SuppressForbidden;
import java.io.IOException;
import java.lang.instrument.Instrumentation;
import java.lang.ref.WeakReference;
Expand All @@ -32,6 +37,7 @@
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Predicate;
import java.util.regex.Pattern;
Expand Down Expand Up @@ -137,6 +143,25 @@ public static synchronized boolean run(final boolean earlyStart, Instrumentation

uploader = new ProfileUploader(config, configProvider);

RecordingDataListener listener = uploader::upload;
if (dumper != null) {
RecordingDataListener upload = listener;
listener =
(type, data, sync) -> {
dumper.onNewData(type, data, sync);
upload.onNewData(type, data, sync);
};
}
// Scrubber wraps the combined dumper+uploader so debug dumps also contain scrubbed data
if (configProvider.getBoolean(PROFILING_SCRUB_ENABLED, PROFILING_SCRUB_ENABLED_DEFAULT)) {
List<String> excludeEventTypes =
configProvider.getList(ProfilingConfig.PROFILING_SCRUB_EXCLUDE_EVENTS);
boolean failOpen =
configProvider.getBoolean(
PROFILING_SCRUB_FAIL_OPEN, PROFILING_SCRUB_FAIL_OPEN_DEFAULT);
listener = wrapWithScrubber(listener, excludeEventTypes, failOpen);
}

final Duration startupDelay = Duration.ofSeconds(config.getProfilingStartDelay());
final Duration uploadPeriod = Duration.ofSeconds(config.getProfilingUploadPeriod());

Expand All @@ -149,12 +174,7 @@ public static synchronized boolean run(final boolean earlyStart, Instrumentation
configProvider,
controller,
context.snapshot(),
dumper == null
? uploader::upload
: (type, data, sync) -> {
dumper.onNewData(type, data, sync);
uploader.upload(type, data, sync);
},
listener,
startupDelay,
startupDelayRandomRange,
uploadPeriod,
Expand All @@ -181,6 +201,30 @@ public static synchronized boolean run(final boolean earlyStart, Instrumentation
return false;
}

/**
* Wraps a listener with the JFR scrubber using reflection to avoid a compile-time dependency on
* {@code ScrubRecordingDataListener} and its transitive jafar-parser classes. A direct reference
* would cause {@code NoClassDefFoundError} during GraalVM native-image builds when the
* VMRuntimeModule's helper injector walks transitive dependencies of this class.
*/
// Class.forName is safe here — the target class lives in the same classloader (agent-profiling
// shadow jar). We use reflection solely to avoid a compile-time type reference that would cause
// GraalVM native-image to walk jafar-parser's transitive dependencies.
@SuppressForbidden
private static RecordingDataListener wrapWithScrubber(
RecordingDataListener listener, List<String> excludeEventTypes, boolean failOpen) {
try {
Class<?> scrubClass = Class.forName("com.datadog.profiling.agent.ScrubRecordingDataListener");
return (RecordingDataListener)
scrubClass
.getDeclaredMethod("wrap", RecordingDataListener.class, List.class, boolean.class)
.invoke(null, listener, excludeEventTypes, failOpen);
} catch (Exception e) {
log.warn(SEND_TELEMETRY, "Failed to initialize JFR scrubber", e);
return listener;
}
}

private static boolean isStartForceFirstSafe() {
return isJavaVersionAtLeast(14)
|| (isJavaVersion(13) && isJavaVersionAtLeast(13, 0, 4))
Expand Down
Loading