From 5a8e00f391da9df9f56e5476c8eece6700fea9dc Mon Sep 17 00:00:00 2001 From: Mishenevd Date: Mon, 10 Aug 2026 15:33:46 +0200 Subject: [PATCH 1/5] Keep instrumenting classes decorated by another agent's synthetic types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the OpenTelemetry agent is loaded before Zen, it injects synthetic supertypes (its VirtualField markers) onto core types such as java.sql.*, jakarta.servlet.* and java.lang.Runnable. Those types have no .class resource, so resolving the hierarchy of any class implementing one threw NoSuchTypeException and the transform was skipped — silently turning off SQL injection, request-context/route and executor instrumentation. Add a lenient type pool that degrades an unresolvable type to an empty interface so resolution completes, and skip OpenTelemetry's own classes. --- .github/workflows/opentel.yml | 49 +++++++++++++ agent/build.gradle | 12 ++++ .../aikido/agent/ByteBuddyInitializer.java | 4 +- .../dev/aikido/agent/LenientPoolStrategy.java | 71 +++++++++++++++++++ .../aikido/agent/LenientPoolStrategyTest.java | 38 ++++++++++ sample-apps/SpringBootPostgres/Makefile | 17 +++++ 6 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 agent/src/main/java/dev/aikido/agent/LenientPoolStrategy.java create mode 100644 agent/src/test/java/dev/aikido/agent/LenientPoolStrategyTest.java diff --git a/.github/workflows/opentel.yml b/.github/workflows/opentel.yml index 78bd9d744..5b121dd38 100644 --- a/.github/workflows/opentel.yml +++ b/.github/workflows/opentel.yml @@ -80,3 +80,52 @@ jobs: - name: Run End-to-End tests working-directory: ./ run: tail -f ./sample-apps/JavalinPostgres/output.log & sleep 20 && python end2end/javalin_postgres.py + + opentel_test_spring: + runs-on: ubuntu-latest + needs: build + continue-on-error: true + strategy: + matrix: + java-version: [17, 18, 19, 20, 21] + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: pkg-build + + - name: Set up JDK + uses: actions/setup-java@v2 + with: + java-version: ${{ matrix.java-version }} + distribution: 'adopt' + + - name: Start mock server + working-directory: ./end2end/server + run: | + docker build -t mock_core . + docker run --name mock_core -d -p 5000:5000 mock_core + - name: Start databases + working-directory: ./sample-apps/databases + run: | + docker compose down --volumes + docker compose up --build -d postgres_database + - name: Install Python dependencies + run: python -m pip install -r end2end/requirements.txt + - name: Cleanup application + working-directory: ./sample-apps/SpringBootPostgres + run: chmod +x ./gradlew && make clean + + - name: Build application + working-directory: ./sample-apps/SpringBootPostgres + run: make build + + - name: Start Application (with and without Zen) + working-directory: ./sample-apps/SpringBootPostgres + run: | + nohup make runWithoutZen > output_without_zen.log & sleep 5 + nohup make runWithOpentel > output.log & sleep 5 + + - name: Run End-to-End tests + working-directory: ./ + run: tail -f ./sample-apps/SpringBootPostgres/output.log & sleep 20 && python end2end/spring_boot_postgres.py diff --git a/agent/build.gradle b/agent/build.gradle index 8e4b44536..995c1bca0 100644 --- a/agent/build.gradle +++ b/agent/build.gradle @@ -12,6 +12,18 @@ dependencies { compileOnly 'io.projectreactor.netty:reactor-netty-http:1.2.1' // For Spring Webflux compileOnly 'io.javalin:javalin:6.4.0' compileOnly 'org.springframework:spring-web:5.3.20' + + testImplementation 'org.junit.jupiter:junit-jupiter:5.9.2' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.9.2' +} + +test { + useJUnitPlatform() + // The root `test --tests ` smoke run targets a test in another module; don't fail here on no match. + filter { + setFailOnNoMatchingTests(false) + } } shadowJar { diff --git a/agent/src/main/java/dev/aikido/agent/ByteBuddyInitializer.java b/agent/src/main/java/dev/aikido/agent/ByteBuddyInitializer.java index e22f4caef..82a130132 100644 --- a/agent/src/main/java/dev/aikido/agent/ByteBuddyInitializer.java +++ b/agent/src/main/java/dev/aikido/agent/ByteBuddyInitializer.java @@ -28,6 +28,8 @@ public static AgentBuilder createAgentBuilder(boolean debugMode) { .with(InstrumentedType.Factory.Default.FROZEN) ); + agentBuilder = agentBuilder.with(LenientPoolStrategy.INSTANCE); + // Disables all implicit changes on a class file that Byte Buddy would apply for certain instrumentation's. agentBuilder = agentBuilder.disableClassFormatChanges(); @@ -39,10 +41,10 @@ public static AgentBuilder createAgentBuilder(boolean debugMode) { .with(AgentBuilder.InstallationListener.StreamWriting.toSystemError()); } - // Ignore Byte Buddy and Aikido's internal code: agentBuilder = agentBuilder.ignore( ElementMatchers.nameContains("bytebuddy") .or(ElementMatchers.nameContains("dev.aikido.agent")) + .or(ElementMatchers.nameStartsWith("io.opentelemetry.javaagent")) ); agentBuilder = agentBuilder.with(AgentBuilder.TypeStrategy.Default.DECORATE); diff --git a/agent/src/main/java/dev/aikido/agent/LenientPoolStrategy.java b/agent/src/main/java/dev/aikido/agent/LenientPoolStrategy.java new file mode 100644 index 000000000..35f126176 --- /dev/null +++ b/agent/src/main/java/dev/aikido/agent/LenientPoolStrategy.java @@ -0,0 +1,71 @@ +package dev.aikido.agent; + +import net.bytebuddy.agent.builder.AgentBuilder; +import net.bytebuddy.description.annotation.AnnotationList; +import net.bytebuddy.description.field.FieldDescription; +import net.bytebuddy.description.field.FieldList; +import net.bytebuddy.description.method.MethodDescription; +import net.bytebuddy.description.method.MethodList; +import net.bytebuddy.description.type.RecordComponentDescription; +import net.bytebuddy.description.type.RecordComponentList; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.dynamic.ClassFileLocator; +import net.bytebuddy.pool.TypePool; + +import java.lang.reflect.Modifier; +import java.util.Collections; + +// Another agent (e.g. OpenTelemetry) can add synthetic supertypes with no .class resource to core types; +// the default pool then throws while resolving the hierarchy. Unresolvable types degrade to an empty interface. +public enum LenientPoolStrategy implements AgentBuilder.PoolStrategy { + INSTANCE; + + @Override + public TypePool typePool(ClassFileLocator classFileLocator, ClassLoader classLoader) { + return new LenientPool(new TypePool.CacheProvider.Simple(), classFileLocator, TypePool.Default.ReaderMode.FAST); + } + + @Override + public TypePool typePool(ClassFileLocator classFileLocator, ClassLoader classLoader, String name) { + return typePool(classFileLocator, classLoader); + } + + private static final class LenientPool extends TypePool.Default { + LenientPool(CacheProvider cacheProvider, ClassFileLocator classFileLocator, ReaderMode readerMode) { + super(cacheProvider, classFileLocator, readerMode); + } + + @Override + protected Resolution doDescribe(String name) { + Resolution resolution = super.doDescribe(name); + return resolution.isResolved() ? resolution : new Resolution.Simple(new EmptyStubType(name)); + } + } + + private static final class EmptyStubType extends TypeDescription.Latent { + EmptyStubType(String name) { + super(name, Modifier.PUBLIC | Modifier.ABSTRACT | Modifier.INTERFACE, + TypeDescription.Generic.OBJECT, Collections.emptyList()); + } + + @Override + public MethodList getDeclaredMethods() { + return new MethodList.Empty(); + } + + @Override + public FieldList getDeclaredFields() { + return new FieldList.Empty(); + } + + @Override + public AnnotationList getDeclaredAnnotations() { + return new AnnotationList.Empty(); + } + + @Override + public RecordComponentList getRecordComponents() { + return new RecordComponentList.Empty(); + } + } +} diff --git a/agent/src/test/java/dev/aikido/agent/LenientPoolStrategyTest.java b/agent/src/test/java/dev/aikido/agent/LenientPoolStrategyTest.java new file mode 100644 index 000000000..b24305be1 --- /dev/null +++ b/agent/src/test/java/dev/aikido/agent/LenientPoolStrategyTest.java @@ -0,0 +1,38 @@ +package dev.aikido.agent; + +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.dynamic.ClassFileLocator; +import net.bytebuddy.pool.TypePool; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LenientPoolStrategyTest { + private TypePool pool() { + ClassLoader classLoader = getClass().getClassLoader(); + return LenientPoolStrategy.INSTANCE.typePool(ClassFileLocator.ForClassLoader.of(classLoader), classLoader); + } + + @Test + void resolvableTypeIsDescribedNormally() { + TypeDescription type = pool().describe("java.util.ArrayList").resolve(); + + assertEquals("java.util.ArrayList", type.getName()); + assertFalse(type.isInterface()); + assertFalse(type.getDeclaredMethods().isEmpty()); + } + + @Test + void unresolvableTypeDegradesToEmptyInterface() { + TypeDescription type = pool().describe("com.acme.Injected$VirtualField$Absent").resolve(); + + assertEquals("com.acme.Injected$VirtualField$Absent", type.getName()); + assertTrue(type.isInterface()); + assertTrue(type.getDeclaredMethods().isEmpty()); + assertTrue(type.getDeclaredFields().isEmpty()); + assertTrue(type.getInterfaces().isEmpty()); + assertEquals(Object.class.getName(), type.getSuperClass().asErasure().getName()); + } +} diff --git a/sample-apps/SpringBootPostgres/Makefile b/sample-apps/SpringBootPostgres/Makefile index bba15d607..66336fd00 100644 --- a/sample-apps/SpringBootPostgres/Makefile +++ b/sample-apps/SpringBootPostgres/Makefile @@ -37,6 +37,23 @@ runWithDdTrace: build -javaagent:dd-java-agent.jar -Ddd.profiling.enabled=true -Ddd.logs.injection=true -Ddd.service=my-app -Ddd.env=staging -Ddd.version=1.0 \ -javaagent:$(JAVA_AGENT) -jar $(JAR_FILE) --server.port=8080 +# Run with the OpenTelemetry agent loaded before Zen (the order that broke Zen instrumentation). +# The agent runs as a -javaagent on the runner, so pin the version and verify its checksum. +OTEL_VERSION = 2.30.0 +OTEL_SHA256 = 9d6bc2ad8dd8fb7f730984988e57b8ac0a82d81c7b3b8ae795378718733a509d +.PHONY: runWithOpentel +runWithOpentel: build + @echo "Running SpringBootPostgres with OpenTelemetry $(OTEL_VERSION) (first) & Zen (http://localhost:8080)" + wget -O opentelemetry-javaagent.jar 'https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v$(OTEL_VERSION)/opentelemetry-javaagent.jar' + echo "$(OTEL_SHA256) opentelemetry-javaagent.jar" | sha256sum -c - + AIKIDO_LOG_LEVEL="error" \ + AIKIDO_TOKEN="token" \ + AIKIDO_REALTIME_ENDPOINT="http://localhost:5000/realtime" \ + AIKIDO_ENDPOINT="http://localhost:5000" \ + AIKIDO_BLOCK=1 java \ + -javaagent:opentelemetry-javaagent.jar -Dotel.service.name=service -Dotel.traces.exporter=none -Dotel.metrics.exporter=none -Dotel.logs.exporter=none \ + -javaagent:$(JAVA_AGENT) -jar $(JAR_FILE) --server.port=8080 + # Run the application without Zen .PHONY: runWithoutZen runWithoutZen: build From 0dced034549024090e04f035d12adb8309c3777a Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Wed, 26 Aug 2026 18:37:05 +0200 Subject: [PATCH 2/5] Slight improvements --- .github/workflows/opentel.yml | 3 +- .../dev/aikido/agent/LenientPoolStrategy.java | 27 +++++- .../aikido/agent/LenientPoolStrategyTest.java | 88 ++++++++++++++++--- end2end/spring_boot_postgres.py | 3 +- sample-apps/SpringBootPostgres/Makefile | 19 ++-- 5 files changed, 117 insertions(+), 23 deletions(-) diff --git a/.github/workflows/opentel.yml b/.github/workflows/opentel.yml index 5b121dd38..b3896b814 100644 --- a/.github/workflows/opentel.yml +++ b/.github/workflows/opentel.yml @@ -88,6 +88,7 @@ jobs: strategy: matrix: java-version: [17, 18, 19, 20, 21] + agent-order: [runWithOpentel, runWithZenFirstOpentel] steps: - name: Download build artifacts uses: actions/download-artifact@v4 @@ -124,7 +125,7 @@ jobs: working-directory: ./sample-apps/SpringBootPostgres run: | nohup make runWithoutZen > output_without_zen.log & sleep 5 - nohup make runWithOpentel > output.log & sleep 5 + nohup make ${{ matrix.agent-order }} > output.log & sleep 5 - name: Run End-to-End tests working-directory: ./ diff --git a/agent/src/main/java/dev/aikido/agent/LenientPoolStrategy.java b/agent/src/main/java/dev/aikido/agent/LenientPoolStrategy.java index 35f126176..63afbf2b5 100644 --- a/agent/src/main/java/dev/aikido/agent/LenientPoolStrategy.java +++ b/agent/src/main/java/dev/aikido/agent/LenientPoolStrategy.java @@ -15,14 +15,30 @@ import java.lang.reflect.Modifier; import java.util.Collections; -// Another agent (e.g. OpenTelemetry) can add synthetic supertypes with no .class resource to core types; -// the default pool then throws while resolving the hierarchy. Unresolvable types degrade to an empty interface. +/* + * OTel adds generated VirtualFieldAccessor interfaces to types it instruments. For example: + * + * org.postgresql.jdbc.PgConnection + * └─ java.sql.Connection + * └─ io.opentelemetry.javaagent.bootstrap.field.VirtualFieldAccessor$... + * + * OTel provides the generated interface bytecode itself, so it has no .class resource for Zen's + * ClassFileLocator. Byte Buddy must resolve the hierarchy eagerly to replace this specific missing + * interface with an empty stub before Zen's matchers traverse it. Other missing types stay unresolved. + */ public enum LenientPoolStrategy implements AgentBuilder.PoolStrategy { INSTANCE; + private static final String OTEL_VIRTUAL_FIELD_ACCESSOR_PREFIX = + "io.opentelemetry.javaagent.bootstrap.field.VirtualFieldAccessor$"; + @Override public TypePool typePool(ClassFileLocator classFileLocator, ClassLoader classLoader) { - return new LenientPool(new TypePool.CacheProvider.Simple(), classFileLocator, TypePool.Default.ReaderMode.FAST); + return new TypePool.LazyFacade(new LenientPool( + TypePool.CacheProvider.Simple.withObjectType(), + classFileLocator, + TypePool.Default.ReaderMode.FAST + )); } @Override @@ -38,7 +54,10 @@ private static final class LenientPool extends TypePool.Default { @Override protected Resolution doDescribe(String name) { Resolution resolution = super.doDescribe(name); - return resolution.isResolved() ? resolution : new Resolution.Simple(new EmptyStubType(name)); + if (resolution.isResolved() || !name.startsWith(OTEL_VIRTUAL_FIELD_ACCESSOR_PREFIX)) { + return resolution; + } + return new Resolution.Simple(new EmptyStubType(name)); } } diff --git a/agent/src/test/java/dev/aikido/agent/LenientPoolStrategyTest.java b/agent/src/test/java/dev/aikido/agent/LenientPoolStrategyTest.java index b24305be1..6d8ab80ad 100644 --- a/agent/src/test/java/dev/aikido/agent/LenientPoolStrategyTest.java +++ b/agent/src/test/java/dev/aikido/agent/LenientPoolStrategyTest.java @@ -1,38 +1,102 @@ package dev.aikido.agent; +import net.bytebuddy.ByteBuddy; import net.bytebuddy.description.type.TypeDescription; import net.bytebuddy.dynamic.ClassFileLocator; +import net.bytebuddy.implementation.StubMethod; +import net.bytebuddy.jar.asm.ClassWriter; +import net.bytebuddy.jar.asm.MethodVisitor; +import net.bytebuddy.jar.asm.Opcodes; import net.bytebuddy.pool.TypePool; import org.junit.jupiter.api.Test; +import java.lang.reflect.Modifier; +import java.util.Map; + +import static net.bytebuddy.matcher.ElementMatchers.declaresMethod; +import static net.bytebuddy.matcher.ElementMatchers.hasSuperType; +import static net.bytebuddy.matcher.ElementMatchers.named; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class LenientPoolStrategyTest { + private static final String OTEL_VIRTUAL_FIELD_ACCESSOR = + "io.opentelemetry.javaagent.bootstrap.field.VirtualFieldAccessor$java$lang$Runnable$context"; + private static final String CONTROLLER_BASE = "com.acme.ControllerBase"; + private static final String CONTROLLER = "com.acme.Controller"; + private TypePool pool() { ClassLoader classLoader = getClass().getClassLoader(); return LenientPoolStrategy.INSTANCE.typePool(ClassFileLocator.ForClassLoader.of(classLoader), classLoader); } @Test - void resolvableTypeIsDescribedNormally() { - TypeDescription type = pool().describe("java.util.ArrayList").resolve(); + void virtualFieldAccessorDegradesToEmptyInterface() { + TypeDescription type = pool().describe(OTEL_VIRTUAL_FIELD_ACCESSOR).resolve(); - assertEquals("java.util.ArrayList", type.getName()); - assertFalse(type.isInterface()); - assertFalse(type.getDeclaredMethods().isEmpty()); - } - - @Test - void unresolvableTypeDegradesToEmptyInterface() { - TypeDescription type = pool().describe("com.acme.Injected$VirtualField$Absent").resolve(); - - assertEquals("com.acme.Injected$VirtualField$Absent", type.getName()); + assertEquals(OTEL_VIRTUAL_FIELD_ACCESSOR, type.getName()); assertTrue(type.isInterface()); assertTrue(type.getDeclaredMethods().isEmpty()); assertTrue(type.getDeclaredFields().isEmpty()); assertTrue(type.getInterfaces().isEmpty()); assertEquals(Object.class.getName(), type.getSuperClass().asErasure().getName()); } + + @Test + void nonOtelMissingTypeRemainsUnresolved() { + assertFalse(pool().describe("com.acme.MissingDependency").isResolved()); + } + + @Test + void matchesHierarchyContainingVirtualFieldAccessor() { + TypePool pool = LenientPoolStrategy.INSTANCE.typePool( + new ClassFileLocator.Simple(Map.of( + CONTROLLER_BASE, controllerBaseBytes(), + CONTROLLER, controllerBytes() + )), + null + ); + + TypeDescription controller = pool.describe(CONTROLLER).resolve(); + + assertTrue(hasSuperType(declaresMethod(named("handle"))).matches(controller)); + } + + private static byte[] controllerBaseBytes() { + return new ByteBuddy() + .subclass(Object.class) + .name(CONTROLLER_BASE) + .defineMethod("handle", void.class, Modifier.PUBLIC) + .intercept(StubMethod.INSTANCE) + .make() + .getBytes(); + } + + private static byte[] controllerBytes() { + ClassWriter writer = new ClassWriter(0); + writer.visit( + Opcodes.V17, + Opcodes.ACC_PUBLIC, + CONTROLLER.replace('.', '/'), + null, + CONTROLLER_BASE.replace('.', '/'), + new String[] {OTEL_VIRTUAL_FIELD_ACCESSOR.replace('.', '/')} + ); + MethodVisitor constructor = writer.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null); + constructor.visitCode(); + constructor.visitVarInsn(Opcodes.ALOAD, 0); + constructor.visitMethodInsn( + Opcodes.INVOKESPECIAL, + CONTROLLER_BASE.replace('.', '/'), + "", + "()V", + false + ); + constructor.visitInsn(Opcodes.RETURN); + constructor.visitMaxs(1, 1); + constructor.visitEnd(); + writer.visitEnd(); + return writer.toByteArray(); + } } diff --git a/end2end/spring_boot_postgres.py b/end2end/spring_boot_postgres.py index 8421b6c1b..a5ce1100a 100644 --- a/end2end/spring_boot_postgres.py +++ b/end2end/spring_boot_postgres.py @@ -4,7 +4,8 @@ spring_boot_postgres_app.add_payload("sql", safe_request=Request("/api/pets/create", body={"name": "Bobby"}), - unsafe_request=Request("/api/pets/create", body={"name": "Malicious Pet', 'Gru from the Minions') -- "}) + unsafe_request=Request("/api/pets/create", body={"name": "Malicious Pet', 'Gru from the Minions') -- "}), + test_request={"route": "/api/pets/create"} ) spring_boot_postgres_app.add_payload("command injection", safe_request=Request("/api/commands/execute/Johnny", method='GET'), diff --git a/sample-apps/SpringBootPostgres/Makefile b/sample-apps/SpringBootPostgres/Makefile index 66336fd00..5980930ce 100644 --- a/sample-apps/SpringBootPostgres/Makefile +++ b/sample-apps/SpringBootPostgres/Makefile @@ -39,11 +39,15 @@ runWithDdTrace: build # Run with the OpenTelemetry agent loaded before Zen (the order that broke Zen instrumentation). # The agent runs as a -javaagent on the runner, so pin the version and verify its checksum. -OTEL_VERSION = 2.30.0 -OTEL_SHA256 = 9d6bc2ad8dd8fb7f730984988e57b8ac0a82d81c7b3b8ae795378718733a509d +OTEL_VERSION = 2.28.0 +OTEL_SHA256 = 130606aed07f101458fe42b8f453ef3a2536f6bce8a7ae64f222f5688ada2500 +OTEL_JAVA_AGENT = -javaagent:opentelemetry-javaagent.jar +ZEN_JAVA_AGENT = -javaagent:$(JAVA_AGENT) +JAVA_AGENTS = $(OTEL_JAVA_AGENT) $(ZEN_JAVA_AGENT) +JAVA_AGENT_ORDER = OpenTelemetry $(OTEL_VERSION) (first) & Zen .PHONY: runWithOpentel runWithOpentel: build - @echo "Running SpringBootPostgres with OpenTelemetry $(OTEL_VERSION) (first) & Zen (http://localhost:8080)" + @echo "Running SpringBootPostgres with $(JAVA_AGENT_ORDER) (http://localhost:8080)" wget -O opentelemetry-javaagent.jar 'https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v$(OTEL_VERSION)/opentelemetry-javaagent.jar' echo "$(OTEL_SHA256) opentelemetry-javaagent.jar" | sha256sum -c - AIKIDO_LOG_LEVEL="error" \ @@ -51,8 +55,13 @@ runWithOpentel: build AIKIDO_REALTIME_ENDPOINT="http://localhost:5000/realtime" \ AIKIDO_ENDPOINT="http://localhost:5000" \ AIKIDO_BLOCK=1 java \ - -javaagent:opentelemetry-javaagent.jar -Dotel.service.name=service -Dotel.traces.exporter=none -Dotel.metrics.exporter=none -Dotel.logs.exporter=none \ - -javaagent:$(JAVA_AGENT) -jar $(JAR_FILE) --server.port=8080 + $(JAVA_AGENTS) -Dotel.service.name=service -Dotel.traces.exporter=none -Dotel.metrics.exporter=none -Dotel.logs.exporter=none \ + -jar $(JAR_FILE) --server.port=8080 + +.PHONY: runWithZenFirstOpentel +runWithZenFirstOpentel: JAVA_AGENTS = $(ZEN_JAVA_AGENT) $(OTEL_JAVA_AGENT) +runWithZenFirstOpentel: JAVA_AGENT_ORDER = Zen (first) & OpenTelemetry $(OTEL_VERSION) +runWithZenFirstOpentel: runWithOpentel # Run the application without Zen .PHONY: runWithoutZen From baddb07aee5c32a152deda575729a05ce88e7f58 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Thu, 27 Aug 2026 13:00:46 +0200 Subject: [PATCH 3/5] Get rid of `setFailOnNoMatchingTests(false)` --- .github/workflows/smoke-tests.yml | 4 ++-- agent/build.gradle | 4 ---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml index be12bb159..72df1ca11 100644 --- a/.github/workflows/smoke-tests.yml +++ b/.github/workflows/smoke-tests.yml @@ -34,7 +34,7 @@ jobs: - name: Run WasmSQLInterfaceTest working-directory: ./ run: | - ./gradlew test --tests "vulnerabilities.WasmSQLInterfaceTest" --info + ./gradlew :agent_api:test --tests "vulnerabilities.WasmSQLInterfaceTest" --info smoke-test-musl: name: Smoke Test (${{matrix.image}}, Java 21) @@ -63,5 +63,5 @@ jobs: run: | docker run --rm -v "$(pwd):/app" -w /app ${{matrix.image}} sh -c " chmod +x gradlew && \ - AIKIDO_DEBUG=true ./gradlew test --tests 'vulnerabilities.WasmSQLInterfaceTest' --info + AIKIDO_DEBUG=true ./gradlew :agent_api:test --tests 'vulnerabilities.WasmSQLInterfaceTest' --info " diff --git a/agent/build.gradle b/agent/build.gradle index 995c1bca0..2e4e217cd 100644 --- a/agent/build.gradle +++ b/agent/build.gradle @@ -20,10 +20,6 @@ dependencies { test { useJUnitPlatform() - // The root `test --tests ` smoke run targets a test in another module; don't fail here on no match. - filter { - setFailOnNoMatchingTests(false) - } } shadowJar { From c0bc5e9cac0d571562d6ae16f8d8fb118b0bb229 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Fri, 28 Aug 2026 16:32:26 +0200 Subject: [PATCH 4/5] Use lazy resolution --- .../java/dev/aikido/agent/LenientPoolStrategy.java | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/agent/src/main/java/dev/aikido/agent/LenientPoolStrategy.java b/agent/src/main/java/dev/aikido/agent/LenientPoolStrategy.java index 63afbf2b5..e24413ad1 100644 --- a/agent/src/main/java/dev/aikido/agent/LenientPoolStrategy.java +++ b/agent/src/main/java/dev/aikido/agent/LenientPoolStrategy.java @@ -46,18 +46,21 @@ public TypePool typePool(ClassFileLocator classFileLocator, ClassLoader classLoa return typePool(classFileLocator, classLoader); } - private static final class LenientPool extends TypePool.Default { + private static final class LenientPool extends TypePool.Default.WithLazyResolution { LenientPool(CacheProvider cacheProvider, ClassFileLocator classFileLocator, ReaderMode readerMode) { super(cacheProvider, classFileLocator, readerMode); } @Override protected Resolution doDescribe(String name) { - Resolution resolution = super.doDescribe(name); - if (resolution.isResolved() || !name.startsWith(OTEL_VIRTUAL_FIELD_ACCESSOR_PREFIX)) { - return resolution; + if (!name.startsWith(OTEL_VIRTUAL_FIELD_ACCESSOR_PREFIX)) { + return super.doDescribe(name); } - return new Resolution.Simple(new EmptyStubType(name)); + + Resolution resolution = super.doDescribe(name); + return resolution.isResolved() + ? resolution + : new Resolution.Simple(new EmptyStubType(name)); } } From 78f7c9c96450924ac5d49482e9358a61abe8ac75 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Fri, 28 Aug 2026 17:50:56 +0200 Subject: [PATCH 5/5] Handle OpenTel's `VirtualFieldInstalledMarker` --- .../dev/aikido/agent/LenientPoolStrategy.java | 35 ++++++++++++++----- .../aikido/agent/LenientPoolStrategyTest.java | 25 ++++++++----- 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/agent/src/main/java/dev/aikido/agent/LenientPoolStrategy.java b/agent/src/main/java/dev/aikido/agent/LenientPoolStrategy.java index e24413ad1..e0aec117b 100644 --- a/agent/src/main/java/dev/aikido/agent/LenientPoolStrategy.java +++ b/agent/src/main/java/dev/aikido/agent/LenientPoolStrategy.java @@ -12,25 +12,37 @@ import net.bytebuddy.dynamic.ClassFileLocator; import net.bytebuddy.pool.TypePool; +import java.io.IOException; import java.lang.reflect.Modifier; import java.util.Collections; /* - * OTel adds generated VirtualFieldAccessor interfaces to types it instruments. For example: + * OpenTelemetry adds helper interfaces to some classes it instruments. For example: * * org.postgresql.jdbc.PgConnection * └─ java.sql.Connection * └─ io.opentelemetry.javaagent.bootstrap.field.VirtualFieldAccessor$... * - * OTel provides the generated interface bytecode itself, so it has no .class resource for Zen's - * ClassFileLocator. Byte Buddy must resolve the hierarchy eagerly to replace this specific missing - * interface with an empty stub before Zen's matchers traverse it. Other missing types stay unresolved. + * OpenTelemetry loads these helpers itself, so Zen cannot read their class files through the + * application's class loader. Byte Buddy would remember the missing type and stop inspecting + * the hierarchy before Zen can apply its instrumentation. + * + * For these known OpenTelemetry helpers, check for the class file before Byte Buddy caches the + * miss. If it is unavailable, use an empty interface so Zen can keep inspecting the hierarchy. + * Leave all other missing types unresolved. */ public enum LenientPoolStrategy implements AgentBuilder.PoolStrategy { INSTANCE; private static final String OTEL_VIRTUAL_FIELD_ACCESSOR_PREFIX = "io.opentelemetry.javaagent.bootstrap.field.VirtualFieldAccessor$"; + private static final String OTEL_VIRTUAL_FIELD_INSTALLED_MARKER = + "io.opentelemetry.javaagent.bootstrap.field.VirtualFieldInstalledMarker"; + + private static boolean isOtelVirtualField(String name) { + return name.startsWith(OTEL_VIRTUAL_FIELD_ACCESSOR_PREFIX) + || name.equals(OTEL_VIRTUAL_FIELD_INSTALLED_MARKER); + } @Override public TypePool typePool(ClassFileLocator classFileLocator, ClassLoader classLoader) { @@ -53,14 +65,19 @@ private static final class LenientPool extends TypePool.Default.WithLazyResoluti @Override protected Resolution doDescribe(String name) { - if (!name.startsWith(OTEL_VIRTUAL_FIELD_ACCESSOR_PREFIX)) { + if (!isOtelVirtualField(name)) { return super.doDescribe(name); } - Resolution resolution = super.doDescribe(name); - return resolution.isResolved() - ? resolution - : new Resolution.Simple(new EmptyStubType(name)); + try { + if (!classFileLocator.locate(name).isResolved()) { + return new Resolution.Simple(new EmptyStubType(name)); + } + } catch (IOException exception) { + throw new IllegalStateException("Error while reading class file", exception); + } + + return super.doDescribe(name); } } diff --git a/agent/src/test/java/dev/aikido/agent/LenientPoolStrategyTest.java b/agent/src/test/java/dev/aikido/agent/LenientPoolStrategyTest.java index 6d8ab80ad..355d5803f 100644 --- a/agent/src/test/java/dev/aikido/agent/LenientPoolStrategyTest.java +++ b/agent/src/test/java/dev/aikido/agent/LenientPoolStrategyTest.java @@ -11,6 +11,7 @@ import org.junit.jupiter.api.Test; import java.lang.reflect.Modifier; +import java.util.List; import java.util.Map; import static net.bytebuddy.matcher.ElementMatchers.declaresMethod; @@ -23,6 +24,8 @@ class LenientPoolStrategyTest { private static final String OTEL_VIRTUAL_FIELD_ACCESSOR = "io.opentelemetry.javaagent.bootstrap.field.VirtualFieldAccessor$java$lang$Runnable$context"; + private static final String OTEL_VIRTUAL_FIELD_INSTALLED_MARKER = + "io.opentelemetry.javaagent.bootstrap.field.VirtualFieldInstalledMarker"; private static final String CONTROLLER_BASE = "com.acme.ControllerBase"; private static final String CONTROLLER = "com.acme.Controller"; @@ -32,15 +35,20 @@ private TypePool pool() { } @Test - void virtualFieldAccessorDegradesToEmptyInterface() { - TypeDescription type = pool().describe(OTEL_VIRTUAL_FIELD_ACCESSOR).resolve(); + void virtualFieldInterfacesDegradeToEmptyInterface() { + for (String virtualFieldInterface : List.of( + OTEL_VIRTUAL_FIELD_ACCESSOR, + OTEL_VIRTUAL_FIELD_INSTALLED_MARKER + )) { + TypeDescription type = pool().describe(virtualFieldInterface).resolve(); - assertEquals(OTEL_VIRTUAL_FIELD_ACCESSOR, type.getName()); - assertTrue(type.isInterface()); - assertTrue(type.getDeclaredMethods().isEmpty()); - assertTrue(type.getDeclaredFields().isEmpty()); - assertTrue(type.getInterfaces().isEmpty()); - assertEquals(Object.class.getName(), type.getSuperClass().asErasure().getName()); + assertEquals(virtualFieldInterface, type.getName()); + assertTrue(type.isInterface()); + assertTrue(type.getDeclaredMethods().isEmpty()); + assertTrue(type.getDeclaredFields().isEmpty()); + assertTrue(type.getInterfaces().isEmpty()); + assertEquals(Object.class.getName(), type.getSuperClass().asErasure().getName()); + } } @Test @@ -61,6 +69,7 @@ CONTROLLER, controllerBytes() TypeDescription controller = pool.describe(CONTROLLER).resolve(); assertTrue(hasSuperType(declaresMethod(named("handle"))).matches(controller)); + assertFalse(hasSuperType(declaresMethod(named("missing"))).matches(controller)); } private static byte[] controllerBaseBytes() {