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")