Skip to content
Open
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
6 changes: 4 additions & 2 deletions .github/workflows/gradle-dependency-submit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:.*
12 changes: 11 additions & 1 deletion backend/examples/spring-boot-3-undertow/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.2</version>
<version>3.5.0</version>
</parent>

<dependencies>
Expand Down Expand Up @@ -45,6 +45,16 @@
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<!--
Spring Session is enabled so the demo exercises a real-world filter chain that wraps
requests in SessionRepositoryFilter$SessionRepositoryRequestWrapper. This is the
production scenario that previously triggered IllegalAccessException in the profiler's
HttpServletRequestAdapter (see sample-apps/it-spring-boot-3 integration test).
-->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-core</artifactId>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SessionRepositoryFilter<MapSession>> sessionFilterRegistration(
MapSessionRepository repository) {
SessionRepositoryFilter<MapSession> filter = new SessionRepositoryFilter<>(repository);
FilterRegistrationBean<SessionRepositoryFilter<MapSession>> registration =
new FilterRegistrationBean<>(filter);
registration.addUrlPatterns("/*");
registration.setOrder(Integer.MIN_VALUE + 50);
return registration;
}
}
18 changes: 18 additions & 0 deletions boot/src/main/java/com/netcracker/profiler/agent/Profiler.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
plugins {
id("build-logic.java-library")
}

java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}

tasks.withType<JavaCompile>().configureEach {
options.release = 17
javaCompiler = javaToolchains.compilerFor {
languageVersion = JavaLanguageVersion.of(17)
}
}
13 changes: 13 additions & 0 deletions renovate.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
61 changes: 61 additions & 0 deletions sample-apps/it-spring-boot-3/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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<KotlinJvmCompile>().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")
}
Original file line number Diff line number Diff line change
@@ -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))
},
)
}
}
Loading
Loading