diff --git a/.github/workflows/gradle-dependency-submit.yaml b/.github/workflows/gradle-dependency-submit.yaml
index b7d88cdb..e3d19ee8 100644
--- a/.github/workflows/gradle-dependency-submit.yaml
+++ b/.github/workflows/gradle-dependency-submit.yaml
@@ -32,6 +32,8 @@ jobs:
with:
# Profiler plugins do not expose the dependencies to the runtime, so the versions used for the build
# do not affect the execution. At the same time, we might need to build plugins for different thirdparty
- # versions, so we intentionally exclude :plugins:* projects from the submitted dependencies
+ # versions, so we intentionally exclude :plugins:* projects from the submitted dependencies.
+ # :sample-apps:* are pinned to specific framework majors (e.g. Spring Boot 3.x) on purpose and should not
+ # generate security alerts for the profiler agent project.
dependency-graph-exclude-projects: |
- :plugins:.*
+ :plugins:.*|:sample-apps:.*
diff --git a/backend/examples/spring-boot-3-undertow/pom.xml b/backend/examples/spring-boot-3-undertow/pom.xml
index 6dc38f2f..8f3d09cf 100644
--- a/backend/examples/spring-boot-3-undertow/pom.xml
+++ b/backend/examples/spring-boot-3-undertow/pom.xml
@@ -17,7 +17,7 @@
org.springframework.boot
spring-boot-starter-parent
- 3.1.2
+ 3.5.0
@@ -45,6 +45,16 @@
io.micrometer
micrometer-registry-prometheus
+
+
+ org.springframework.session
+ spring-session-core
+
diff --git a/backend/examples/spring-boot-3-undertow/src/main/java/com/netcracker/monitoring/metrics/SessionConfig.java b/backend/examples/spring-boot-3-undertow/src/main/java/com/netcracker/monitoring/metrics/SessionConfig.java
new file mode 100644
index 00000000..833c0441
--- /dev/null
+++ b/backend/examples/spring-boot-3-undertow/src/main/java/com/netcracker/monitoring/metrics/SessionConfig.java
@@ -0,0 +1,39 @@
+package com.netcracker.monitoring.metrics;
+
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.session.MapSession;
+import org.springframework.session.MapSessionRepository;
+import org.springframework.session.web.http.SessionRepositoryFilter;
+
+/**
+ * Wires Spring Session with an in-memory repository so that every incoming HTTP request is
+ * wrapped in {@code SessionRepositoryFilter$SessionRepositoryRequestWrapper}. This recreates
+ * the production scenario that previously surfaced IllegalAccessException in the profiler's
+ * HttpServletRequestAdapter when running against Undertow + Spring Session.
+ *
+ * The filter is constructed and registered explicitly so the behaviour is independent of which
+ * Spring Boot session auto-configuration is active for the current spring-session-core version.
+ */
+@Configuration
+public class SessionConfig {
+
+ @Bean
+ public MapSessionRepository sessionRepository() {
+ return new MapSessionRepository(new ConcurrentHashMap<>());
+ }
+
+ @Bean
+ public FilterRegistrationBean> sessionFilterRegistration(
+ MapSessionRepository repository) {
+ SessionRepositoryFilter filter = new SessionRepositoryFilter<>(repository);
+ FilterRegistrationBean> registration =
+ new FilterRegistrationBean<>(filter);
+ registration.addUrlPatterns("/*");
+ registration.setOrder(Integer.MIN_VALUE + 50);
+ return registration;
+ }
+}
diff --git a/boot/src/main/java/com/netcracker/profiler/agent/Profiler.java b/boot/src/main/java/com/netcracker/profiler/agent/Profiler.java
index dea66267..947dbc3a 100644
--- a/boot/src/main/java/com/netcracker/profiler/agent/Profiler.java
+++ b/boot/src/main/java/com/netcracker/profiler/agent/Profiler.java
@@ -52,7 +52,25 @@ public static void event(Object value, int tagId) {
getState().event(value, tagId);
}
+ /**
+ * Property name guarding {@link #pluginException(Throwable)} fail-loud diagnostic. When set
+ * to {@code true} the agent echoes every swallowed plugin exception to {@code System.err}
+ * in addition to the regular {@link ProfilerData#pluginLogger}. This is independent of the
+ * slf4j/Logback binding currently in effect (bootstrap-classloader vs. application logback
+ * are not guaranteed to share a {@code LoggerContext}), so it works as an unconditional
+ * channel for integration tests and field diagnostics.
+ */
+ public static final String ECHO_PLUGIN_EXCEPTION_TO_STDERR_PROPERTY =
+ "com.netcracker.profiler.agent.echoPluginExceptionToStderr";
+
+ private static final boolean ECHO_PLUGIN_EXCEPTION_TO_STDERR =
+ Boolean.getBoolean(ECHO_PLUGIN_EXCEPTION_TO_STDERR_PROPERTY);
+
public static void pluginException(Throwable t) {
+ if (ECHO_PLUGIN_EXCEPTION_TO_STDERR) {
+ System.err.println("Profiler plugin exception: " + t);
+ t.printStackTrace(System.err);
+ }
if(ProfilerData.pluginLogger != null) {
ProfilerData.pluginLogger.pluginError(t);
}
diff --git a/boot/src/main/java/com/netcracker/profiler/agent/http/CookieAdapter.java b/boot/src/main/java/com/netcracker/profiler/agent/http/CookieAdapter.java
index acf80a17..596aa2fd 100644
--- a/boot/src/main/java/com/netcracker/profiler/agent/http/CookieAdapter.java
+++ b/boot/src/main/java/com/netcracker/profiler/agent/http/CookieAdapter.java
@@ -10,8 +10,13 @@ public class CookieAdapter {
public CookieAdapter(Object cookie) throws NoSuchMethodException {
this.cookie = cookie;
- this.getName = cookie.getClass().getDeclaredMethod("getName");
- this.getValue = cookie.getClass().getDeclaredMethod("getValue");
+ // getMethod (not getDeclaredMethod) walks the inheritance chain — works even when the
+ // concrete cookie class doesn't override the accessors. setAccessible bypasses the language
+ // access check when the declaring class is non-public (see HttpServletRequestAdapter).
+ this.getName = cookie.getClass().getMethod("getName");
+ this.getName.setAccessible(true);
+ this.getValue = cookie.getClass().getMethod("getValue");
+ this.getValue.setAccessible(true);
}
public String getName() throws InvocationTargetException, IllegalAccessException {
diff --git a/boot/src/main/java/com/netcracker/profiler/agent/http/HttpServletRequestAdapter.java b/boot/src/main/java/com/netcracker/profiler/agent/http/HttpServletRequestAdapter.java
index efedf589..7f37b84f 100644
--- a/boot/src/main/java/com/netcracker/profiler/agent/http/HttpServletRequestAdapter.java
+++ b/boot/src/main/java/com/netcracker/profiler/agent/http/HttpServletRequestAdapter.java
@@ -16,14 +16,26 @@ public class HttpServletRequestAdapter {
public HttpServletRequestAdapter(Object httpServletRequest) throws NoSuchMethodException {
this.httpServletRequest = httpServletRequest;
+ // Spring Session wraps the request in a non-public inner class (SessionRepositoryRequestWrapper).
+ // Class.getMethod returns a Method whose declaringClass is that wrapper, and Method.invoke
+ // performs an access check against the declaring class modifiers — so we must setAccessible(true)
+ // even for methods declared public on a public ancestor interface.
getSession = this.httpServletRequest.getClass().getMethod("getSession", boolean.class);
+ getSession.setAccessible(true);
getRequestURL = this.httpServletRequest.getClass().getMethod("getRequestURL");
+ getRequestURL.setAccessible(true);
getQueryString = this.httpServletRequest.getClass().getMethod("getQueryString");
+ getQueryString.setAccessible(true);
getRequestedSessionId = this.httpServletRequest.getClass().getMethod("getRequestedSessionId");
+ getRequestedSessionId.setAccessible(true);
getMethod = this.httpServletRequest.getClass().getMethod("getMethod");
+ getMethod.setAccessible(true);
getHeader = this.httpServletRequest.getClass().getMethod("getHeader", String.class);
+ getHeader.setAccessible(true);
getCookies = this.httpServletRequest.getClass().getMethod("getCookies");
+ getCookies.setAccessible(true);
setAttribute = this.httpServletRequest.getClass().getMethod("setAttribute", String.class, Object.class);
+ setAttribute.setAccessible(true);
}
public HttpSessionAdapter getSession(boolean createSession) throws InvocationTargetException, IllegalAccessException, NoSuchMethodException {
diff --git a/boot/src/main/java/com/netcracker/profiler/agent/http/HttpSessionAdapter.java b/boot/src/main/java/com/netcracker/profiler/agent/http/HttpSessionAdapter.java
index a7717d63..52181954 100644
--- a/boot/src/main/java/com/netcracker/profiler/agent/http/HttpSessionAdapter.java
+++ b/boot/src/main/java/com/netcracker/profiler/agent/http/HttpSessionAdapter.java
@@ -10,6 +10,8 @@ public class HttpSessionAdapter {
public HttpSessionAdapter(Object httpSession) throws NoSuchMethodException {
this.httpSession = httpSession;
getAttribute = this.httpSession.getClass().getMethod("getAttribute", String.class);
+ // Some session implementations are non-public wrapper classes (see HttpServletRequestAdapter).
+ getAttribute.setAccessible(true);
}
public Object getAttribute(String name) throws InvocationTargetException, IllegalAccessException {
diff --git a/boot/src/main/java/com/netcracker/profiler/agent/http/ServletRequestAdapter.java b/boot/src/main/java/com/netcracker/profiler/agent/http/ServletRequestAdapter.java
index 49119eb2..2dfae975 100644
--- a/boot/src/main/java/com/netcracker/profiler/agent/http/ServletRequestAdapter.java
+++ b/boot/src/main/java/com/netcracker/profiler/agent/http/ServletRequestAdapter.java
@@ -16,20 +16,25 @@ public class ServletRequestAdapter {
public ServletRequestAdapter(Object servletRequest) throws ClassNotFoundException, NoSuchMethodException {
this.servletRequest = servletRequest;
+ // setAccessible(true) handles non-public wrapper classes (e.g. Spring Session's
+ // SessionRepositoryRequestWrapper) whose declaring class modifiers fail Method.invoke access checks.
try {
getRemoteAddr = servletRequest.getClass().getMethod("getRemoteAddr");
+ getRemoteAddr.setAccessible(true);
} catch (NoSuchMethodException e) {
logger.severe("ServletRequest should have method getRemoteAddr", e);
}
try {
getRemoteHost = servletRequest.getClass().getMethod("getRemoteHost");
+ getRemoteHost.setAccessible(true);
} catch (NoSuchMethodException e) {
logger.severe("ServletRequest should have method getRemoteHost", e);
}
try {
setAttribute = servletRequest.getClass().getMethod("setAttribute", String.class, Object.class);
+ setAttribute.setAccessible(true);
} catch (NoSuchMethodException e) {
logger.severe("ServletRequest should have method setAttribute", e);
}
diff --git a/build-logic/jvm/src/main/kotlin/build-logic.java-17-library.gradle.kts b/build-logic/jvm/src/main/kotlin/build-logic.java-17-library.gradle.kts
new file mode 100644
index 00000000..beba8450
--- /dev/null
+++ b/build-logic/jvm/src/main/kotlin/build-logic.java-17-library.gradle.kts
@@ -0,0 +1,16 @@
+plugins {
+ id("build-logic.java-library")
+}
+
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(17)
+ }
+}
+
+tasks.withType().configureEach {
+ options.release = 17
+ javaCompiler = javaToolchains.compilerFor {
+ languageVersion = JavaLanguageVersion.of(17)
+ }
+}
diff --git a/renovate.json b/renovate.json
index e8ad03d5..8606559a 100644
--- a/renovate.json
+++ b/renovate.json
@@ -114,6 +114,19 @@
"jquery"
]
},
+ {
+ "description": "Pin sample-apps/it-spring-boot-3 to Spring Boot 3.x; create it-spring-boot-4 module when bumping major",
+ "groupName": "Spring Boot 3 sample app",
+ "matchFileNames": [
+ "sample-apps/it-spring-boot-3/**"
+ ],
+ "allowedVersions": "< 4",
+ "matchPackageNames": [
+ "org.springframework.boot{/,}**",
+ "org.springframework{/,}**",
+ "org.springframework.session{/,}**"
+ ]
+ },
{
"description": "Group all GitHub Actions updates into a single PR",
"groupName": "GitHub Actions",
diff --git a/sample-apps/it-spring-boot-3/build.gradle.kts b/sample-apps/it-spring-boot-3/build.gradle.kts
new file mode 100644
index 00000000..f066aaed
--- /dev/null
+++ b/sample-apps/it-spring-boot-3/build.gradle.kts
@@ -0,0 +1,61 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
+
+plugins {
+ id("build-logic.java-17-library")
+ id("build-logic.kotlin")
+ id("build-logic.test-junit5")
+}
+
+tasks.withType().configureEach {
+ compilerOptions {
+ jvmTarget = JvmTarget.JVM_17
+ freeCompilerArgs.add("-Xjdk-release=17")
+ }
+}
+
+dependencies {
+ val testcontainersBom = enforcedPlatform("org.testcontainers:testcontainers-bom:1.21.0")
+ testImplementation(testcontainersBom)
+ testImplementation("org.testcontainers:testcontainers")
+ testImplementation("org.testcontainers:junit-jupiter")
+}
+
+val demoModuleDir = rootDir.resolve("backend/examples/spring-boot-3-undertow")
+val demoTargetDir = demoModuleDir.resolve("target")
+
+// Build the existing Maven demo (Spring Boot 3 + Undertow + Spring Session). The demo doubles
+// as the test fixture — same source code shipped as a deployable sample. The test packages this
+// jar on top of qubership/qubership-core-base-image:profiler-latest, which already has the
+// profiler agent baked into /app/diag and wires -javaagent via its entrypoint when
+// NC_DIAGNOSTIC_MODE=prod.
+val buildDemoApp by tasks.registering(Exec::class) {
+ workingDir = demoModuleDir
+ executable = "mvn"
+ args("package", "-DskipTests", "-q")
+
+ inputs.file(demoModuleDir.resolve("pom.xml"))
+ inputs.dir(demoModuleDir.resolve("src"))
+ outputs.dir(demoTargetDir)
+}
+
+val demoJarProvider = buildDemoApp.map {
+ fileTree(demoTargetDir) {
+ include("*.jar")
+ exclude("original-*", "*-sources.jar", "*-javadoc.jar")
+ }.singleFile
+}
+
+tasks.test {
+ dependsOn(buildDemoApp, ":installer:buildBaseImage")
+
+ inputs.files(demoTargetDir).withPropertyName("demoTarget").withPathSensitivity(PathSensitivity.RELATIVE)
+
+ systemProperty("test.demoAppJar", demoJarProvider.map { it.absolutePath }.get())
+ systemProperty("test.baseImageTag", "qubership/qubership-core-base-image:profiler-latest")
+
+ // docker-java 3.x bundled with Testcontainers 1.21 advertises Docker API 1.32 by default,
+ // which modern Docker daemons (OrbStack, Docker Desktop ≥ 25) reject with
+ // "client version 1.32 is too old". Force a newer negotiated API version.
+ systemProperty("api.version", "1.43")
+}
diff --git a/sample-apps/it-spring-boot-3/src/test/kotlin/com/netcracker/profiler/test/spring/HttpServletRequestAdapterSpringSessionTest.kt b/sample-apps/it-spring-boot-3/src/test/kotlin/com/netcracker/profiler/test/spring/HttpServletRequestAdapterSpringSessionTest.kt
new file mode 100644
index 00000000..586c7812
--- /dev/null
+++ b/sample-apps/it-spring-boot-3/src/test/kotlin/com/netcracker/profiler/test/spring/HttpServletRequestAdapterSpringSessionTest.kt
@@ -0,0 +1,113 @@
+package com.netcracker.profiler.test.spring
+
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+import org.testcontainers.containers.GenericContainer
+import org.testcontainers.containers.output.OutputFrame
+import org.testcontainers.containers.wait.strategy.Wait
+import org.testcontainers.images.builder.ImageFromDockerfile
+import java.net.URI
+import java.net.http.HttpClient
+import java.net.http.HttpRequest
+import java.net.http.HttpResponse
+import java.nio.file.Path
+import java.nio.file.Paths
+import java.time.Duration
+
+/**
+ * End-to-end reproduction of the production IllegalAccessException.
+ *
+ * Setup:
+ * - `backend/examples/spring-boot-3-undertow` is packaged by Maven into a Spring Boot fat jar.
+ * - The test layers that jar on top of `qubership/qubership-core-base-image:profiler-latest`
+ * (built by the `:installer:buildBaseImage` task). The base image already has the profiler
+ * agent installed under `/app/diag` and wires `-javaagent:/app/diag/lib/agent.jar` via its
+ * entrypoint when `NC_DIAGNOSTIC_MODE=prod`.
+ * - Testcontainers manages the container lifecycle, exposing a random host port.
+ *
+ * Why a real container:
+ * - The agent's bytecode instrumentation fires against the real Undertow
+ * `FilterHandler$FilterChainImpl.doFilter` — the exact call site from the production
+ * stacktrace. Same image, same agent layout, same JAVA_TOOL_OPTIONS as production runtime.
+ *
+ * Why an env-toggled stderr echo:
+ * - `Profiler.pluginException` forwards to `ProfilerPluginLoggerImpl` which logs via slf4j.
+ * Between the agent loading Logback early (bootstrap classloader) and Spring Boot
+ * reconfiguring Logback later (app classloader), the visible-stream routing for that logger
+ * is unreliable. The fail-loud property
+ * `com.netcracker.profiler.agent.echoPluginExceptionToStderr=true` (gated, default-off)
+ * makes the agent also write every swallowed plugin exception to {@code System.err},
+ * giving the test a deterministic, classloader-independent signal.
+ */
+class HttpServletRequestAdapterSpringSessionTest {
+
+ @Test
+ fun `agent does not raise IllegalAccessException for Spring Session wrapped request`() {
+ val demoJar: Path = Paths.get(System.getProperty("test.demoAppJar"))
+ val baseImageTag = System.getProperty("test.baseImageTag")
+
+ val image = ImageFromDockerfile("qubership-profiler-it-spring-boot-3", false)
+ .withFileFromPath("app.jar", demoJar)
+ .withDockerfileFromBuilder { b ->
+ b
+ .from(baseImageTag)
+ .copy("app.jar", "/app/app.jar")
+ .env("NC_DIAGNOSTIC_MODE", "prod")
+ .expose(8080)
+ .cmd(
+ "java",
+ "-Dcom.netcracker.profiler.agent.echoPluginExceptionToStderr=true",
+ "-jar",
+ "/app/app.jar",
+ )
+ .build()
+ }
+
+ val captured = StringBuilder()
+ GenericContainer(image).use { container ->
+ container.withExposedPorts(8080)
+ .waitingFor(
+ Wait.forHttp("/health")
+ .forStatusCode(200)
+ .withStartupTimeout(Duration.ofMinutes(2)),
+ )
+ .withLogConsumer { frame ->
+ if (frame.type == OutputFrame.OutputType.STDERR) {
+ captured.append(frame.utf8String)
+ }
+ }
+ container.start()
+
+ val baseUrl = "http://${container.host}:${container.getMappedPort(8080)}"
+ val response = HttpClient.newHttpClient().send(
+ HttpRequest.newBuilder(URI.create("$baseUrl/health")).GET().build(),
+ HttpResponse.BodyHandlers.ofString(),
+ )
+ assertEquals(200, response.statusCode())
+
+ // Give the agent a beat to flush any pending plugin-exception output before
+ // we tear the container down.
+ Thread.sleep(500)
+ }
+
+ val swallowed = captured.toString().lineSequence()
+ .filter {
+ it.contains("IllegalAccessException") &&
+ it.contains("SessionRepositoryRequestWrapper")
+ }
+ .toList()
+
+ assertTrue(
+ swallowed.isEmpty(),
+ buildString {
+ appendLine("Profiler swallowed IllegalAccessException(s) for Spring Session wrapper.")
+ appendLine("Matched lines (${swallowed.size}):")
+ swallowed.take(3).forEach { appendLine(" $it") }
+ appendLine()
+ appendLine("--- captured stderr (truncated to 4 KiB) ---")
+ appendLine(captured.toString().takeLast(4096))
+ },
+ )
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 8f8e1ada..d85c8952 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -9,6 +9,7 @@ pluginManagement {
id("com.google.osdetector") version "1.7.3"
kotlin("jvm") version "2.3.21"
kotlin("kapt") version "2.3.21"
+ kotlin("plugin.spring") version "2.3.21"
id("me.champeau.jmh") version "0.7.3"
}
}
@@ -28,7 +29,7 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
rootProject.name = "qubership-profiler"
gradle.allprojects {
- if (path != ":plugins") {
+ if (path != ":plugins" && path != ":sample-apps") {
buildscript {
dependencies {
classpath(platform("com.fasterxml.jackson:jackson-bom:2.21.1"))
@@ -65,6 +66,7 @@ include("profiler")
include("profiler-ui")
include("proto-definition")
include("runtime")
+include("sample-apps:it-spring-boot-3")
include("test-config")
include("test-app")
include("testkit")