diff --git a/.github/workflows/end2end.yml b/.github/workflows/end2end.yml index 66c9dac2d..071e56608 100644 --- a/.github/workflows/end2end.yml +++ b/.github/workflows/end2end.yml @@ -49,6 +49,7 @@ jobs: - { name: JavalinMySQLKotlin, test_file: end2end/javalin_mysql_kotlin.py, db: mysql_database } - { name: SpringBoot2.7Postgres, test_file: end2end/spring_boot_2.7_postgres.py, db: postgres_database } - { name: SpringBootHyperSQL, test_file: end2end/spring_boot_hypersql.py, db: "" } + - { name: RingClojurePostgres, test_file: end2end/ring_clojure_postgres.py, db: postgres_database } java-version: [17, 18, 19, 20, 21, 24, 25] distribution: ['adopt', 'corretto', 'oracle'] exclude: @@ -82,6 +83,12 @@ jobs: - name: Install Python dependencies run: python -m pip install -r end2end/requirements.txt + - name: Install Leiningen + if: matrix.app.name == 'RingClojurePostgres' + run: | + curl -sSL -o /usr/local/bin/lein https://raw.githubusercontent.com/technomancy/leiningen/stable/bin/lein + chmod +x /usr/local/bin/lein + - name: Build Application (ensures cache) working-directory: ./sample-apps/${{ matrix.app.name }} run: | diff --git a/agent/build.gradle b/agent/build.gradle index 8e4b44536..84eeaf0c6 100644 --- a/agent/build.gradle +++ b/agent/build.gradle @@ -12,6 +12,7 @@ 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' + compileOnly 'com.google.code.gson:gson:2.11.0' } shadowJar { diff --git a/agent/src/main/java/dev/aikido/agent/Wrappers.java b/agent/src/main/java/dev/aikido/agent/Wrappers.java index a71c13b26..745186aae 100644 --- a/agent/src/main/java/dev/aikido/agent/Wrappers.java +++ b/agent/src/main/java/dev/aikido/agent/Wrappers.java @@ -5,6 +5,8 @@ import dev.aikido.agent.wrappers.file.FileConstructorSingleArgumentWrapper; import dev.aikido.agent.wrappers.javalin.*; import dev.aikido.agent.wrappers.jdbc.*; +import dev.aikido.agent.wrappers.ring.RingJettyServletWrapper; +import dev.aikido.agent.wrappers.ring.RingRequestBodyWrapper; import dev.aikido.agent.wrappers.spring.SpringMVCJavaxWrapper; import dev.aikido.agent.wrappers.spring.SpringWebfluxWrapper; import dev.aikido.agent.wrappers.spring.SpringControllerWrapper; @@ -42,6 +44,8 @@ private Wrappers() {} new JavalinDataWrapper(), new JavalinContextClearWrapper(), new SQLiteWrapper(), - new HyperSQLWrapper() + new HyperSQLWrapper(), + new RingJettyServletWrapper(), + new RingRequestBodyWrapper() ); } diff --git a/agent/src/main/java/dev/aikido/agent/wrappers/ring/RingJettyServletWrapper.java b/agent/src/main/java/dev/aikido/agent/wrappers/ring/RingJettyServletWrapper.java new file mode 100644 index 000000000..33a452450 --- /dev/null +++ b/agent/src/main/java/dev/aikido/agent/wrappers/ring/RingJettyServletWrapper.java @@ -0,0 +1,98 @@ +package dev.aikido.agent.wrappers.ring; + +import dev.aikido.agent.wrappers.Wrapper; +import dev.aikido.agent_api.collectors.WebRequestCollector; +import dev.aikido.agent_api.collectors.WebResponseCollector; +import dev.aikido.agent_api.context.ContextObject; +import dev.aikido.agent_api.context.RingContextObject; +import dev.aikido.agent_api.helpers.logging.LogManager; +import dev.aikido.agent_api.helpers.logging.Logger; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.description.method.MethodDescription; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.matcher.ElementMatcher; + +import java.lang.reflect.Executable; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.List; + +import static net.bytebuddy.implementation.bytecode.assign.Assigner.Typing.DYNAMIC; +import static net.bytebuddy.matcher.ElementMatchers.*; + +public class RingJettyServletWrapper implements Wrapper { + public static final Logger logger = LogManager.getLogger(RingJettyServletWrapper.class); + + @Override + public String getName() { + return RingJettyAdvice.class.getName(); + } + + @Override + public ElementMatcher getMatcher() { + return named("doHandle").and(takesArguments(4)); + } + + @Override + public ElementMatcher getTypeMatcher() { + return hasSuperType(named("org.eclipse.jetty.ee9.servlet.ServletHandler")); + } + + public static class RingJettyAdvice { + public record SkipOnWrapper(HttpServletResponse response) {} + + @Advice.OnMethodEnter(skipOn = SkipOnWrapper.class, suppress = Throwable.class) + public static Object interceptOnEnter( + @Advice.Origin Executable method, + @Advice.Argument(value = 2, typing = DYNAMIC, optional = true) HttpServletRequest request, + @Advice.Argument(value = 3, typing = DYNAMIC, optional = true) HttpServletResponse response) throws Throwable { + if (request == null) { + return response; + } + + HashMap> headersMap = new HashMap<>(); + Enumeration headerNames = request.getHeaderNames(); + while (headerNames != null && headerNames.hasMoreElements()) { + String headerName = headerNames.nextElement(); + headersMap.put(headerName, request.getHeaders(headerName)); + } + + HashMap> cookiesMap = new HashMap<>(); + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if (!cookiesMap.containsKey(cookie.getName())) { + cookiesMap.put(cookie.getName(), new ArrayList<>()); + } + cookiesMap.get(cookie.getName()).add(cookie.getValue()); + } + } + + ContextObject contextObject = new RingContextObject( + request.getMethod(), request.getRequestURL(), request.getRemoteAddr(), + request.getParameterMap(), cookiesMap, headersMap, request.getQueryString() + ); + + WebRequestCollector.Res res = WebRequestCollector.report(contextObject); + if (res != null) { + logger.trace("Writing a new response"); + response.setStatus(res.status()); + response.setContentType("text/plain"); + response.getWriter().write(res.msg()); + return new SkipOnWrapper(response); + } + return response; + } + + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class) + public static void interceptOnExit(@Advice.Enter Object response) { + if (response instanceof HttpServletResponse httpServletResponse) { + WebResponseCollector.report(httpServletResponse.getStatus()); + } + } + } +} diff --git a/agent/src/main/java/dev/aikido/agent/wrappers/ring/RingRequestBodyWrapper.java b/agent/src/main/java/dev/aikido/agent/wrappers/ring/RingRequestBodyWrapper.java new file mode 100644 index 000000000..dbddf1c81 --- /dev/null +++ b/agent/src/main/java/dev/aikido/agent/wrappers/ring/RingRequestBodyWrapper.java @@ -0,0 +1,90 @@ +package dev.aikido.agent.wrappers.ring; + +import com.google.gson.Gson; +import dev.aikido.agent.wrappers.Wrapper; +import dev.aikido.agent_api.context.Context; +import dev.aikido.agent_api.context.ContextObject; +import dev.aikido.agent_api.helpers.logging.LogManager; +import dev.aikido.agent_api.helpers.logging.Logger; +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.description.method.MethodDescription; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.matcher.ElementMatcher; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; + +import static net.bytebuddy.implementation.bytecode.assign.Assigner.Typing.DYNAMIC; +import static net.bytebuddy.matcher.ElementMatchers.*; + +public class RingRequestBodyWrapper implements Wrapper { + public static final Logger logger = LogManager.getLogger(RingRequestBodyWrapper.class); + + @Override + public String getName() { + return RingRequestBodyAdvice.class.getName(); + } + + @Override + public ElementMatcher getMatcher() { + return named("getInputStream").and(takesArguments(0)); + } + + @Override + public ElementMatcher getTypeMatcher() { + return nameContains("org.eclipse.jetty.ee9.nested") + .and(hasSuperType(named("jakarta.servlet.http.HttpServletRequest"))); + } + + public static class BufferedServletInputStream extends ServletInputStream { + private final ByteArrayInputStream backing; + + public BufferedServletInputStream(byte[] body) { + this.backing = new ByteArrayInputStream(body); + } + + @Override + public boolean isFinished() { + return backing.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener readListener) { + } + + @Override + public int read() { + return backing.read(); + } + } + + public static class RingRequestBodyAdvice { + @Advice.OnMethodExit(suppress = Throwable.class) + public static void interceptOnExit( + @Advice.Return(readOnly = false, typing = DYNAMIC) ServletInputStream returnValue) throws Throwable { + if (returnValue == null) { + return; + } + byte[] bodyBytes = returnValue.readAllBytes(); + + ContextObject ctx = Context.get(); + if (ctx != null && bodyBytes.length > 0) { + try { + Object parsedBody = new Gson().fromJson(new String(bodyBytes, StandardCharsets.UTF_8), Object.class); + ctx.setBody(parsedBody); + } catch (Throwable t) { + logger.debug("RingRequestBodyWrapper failed to parse JSON body: %s", t.getMessage()); + } + } + + returnValue = new BufferedServletInputStream(bodyBytes); + } + } +} diff --git a/agent_api/src/main/java/dev/aikido/agent_api/context/RingContextObject.java b/agent_api/src/main/java/dev/aikido/agent_api/context/RingContextObject.java new file mode 100644 index 000000000..3c6d8d2e0 --- /dev/null +++ b/agent_api/src/main/java/dev/aikido/agent_api/context/RingContextObject.java @@ -0,0 +1,49 @@ +package dev.aikido.agent_api.context; + +import java.util.*; + +import static dev.aikido.agent_api.helpers.net.ProxyForwardedParser.getIpFromRequest; +import static dev.aikido.agent_api.helpers.url.BuildRouteFromUrl.buildRouteFromUrl; + +public class RingContextObject extends ContextObject { + public RingContextObject( + String method, StringBuffer url, String rawIp, Map queryParams, + HashMap> cookies, HashMap> headers, String queryString + ) { + this.method = method; + if (url != null) { + this.url = url.toString(); + if (queryString != null && !queryString.isEmpty()) { + this.url = this.url + "?" + queryString; + } + } + this.query = extractQueryParameters(queryParams); + this.cookies = cookies; + this.headers = extractHeaders(headers); + this.route = buildRouteFromUrl(this.url); + this.remoteAddress = getIpFromRequest(rawIp, this.headers); + this.source = "Ring"; + this.redirectStartNodes = new ArrayList<>(); + } + + private static HashMap> extractHeaders(HashMap> headers) { + HashMap> extractedHeaders = new HashMap<>(); + for (Map.Entry> entry : headers.entrySet()) { + List values = new ArrayList<>(); + Enumeration valuesEnum = entry.getValue(); + while (valuesEnum.hasMoreElements()) { + values.add(valuesEnum.nextElement()); + } + extractedHeaders.put(entry.getKey().toLowerCase(), values); + } + return extractedHeaders; + } + + private static HashMap> extractQueryParameters(Map parameterMap) { + HashMap> query = new HashMap<>(); + for (Map.Entry entry : parameterMap.entrySet()) { + query.put(entry.getKey(), Arrays.asList(entry.getValue())); + } + return query; + } +} diff --git a/agent_api/src/test/java/context/RingContextObjectTest.java b/agent_api/src/test/java/context/RingContextObjectTest.java new file mode 100644 index 000000000..93dba4b9a --- /dev/null +++ b/agent_api/src/test/java/context/RingContextObjectTest.java @@ -0,0 +1,88 @@ +package context; + +import dev.aikido.agent_api.context.RingContextObject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +class RingContextObjectTest { + + private RingContextObject contextObject; + + @BeforeEach + void setUp() { + contextObject = new RingContextObject( + "GET", new StringBuffer("http://localhost/test"), "192.168.1.1", Map.of(), new HashMap<>(), new HashMap<>(), null + ); + } + + @Test + void testConstructor() { + assertEquals("GET", contextObject.getMethod()); + assertEquals("http://localhost/test", contextObject.getUrl()); + assertEquals("192.168.1.1", contextObject.getRemoteAddress()); + assertEquals("Ring", contextObject.getSource()); + } + + @Test + void testGetRouteWithSlash() { + contextObject = new RingContextObject( + "GET", new StringBuffer("http://localhost/test"), "192.168.1.1", Map.of(), new HashMap<>(), new HashMap<>(), "a=b" + ); + + assertEquals("http://localhost/test?a=b", contextObject.getUrl()); + assertEquals("/test", contextObject.getRoute()); + } + + @Test + void testGetRouteWithNumbers() { + contextObject = new RingContextObject( + "GET", new StringBuffer("http://localhost/api/dog/28632"), "192.168.1.1", Map.of(), new HashMap<>(), new HashMap<>(), "" + ); + + assertEquals("http://localhost/api/dog/28632", contextObject.getUrl()); + assertEquals("/api/dog/:number", contextObject.getRoute()); + } + + @Test + void testQueryParametersExtraction() { + Map queryParams = new HashMap<>(); + queryParams.put("param1", new String[]{"value1"}); + + contextObject = new RingContextObject( + "GET", new StringBuffer("http://localhost/test"), "192.168.1.1", queryParams, new HashMap<>(), new HashMap<>(), null + ); + + assertEquals(1, contextObject.getQuery().size()); + assertEquals("value1", contextObject.getQuery().get("param1").get(0)); + } + + @Test + void testCookiesExtraction() { + HashMap> cookies = new HashMap<>(); + cookies.put("sessionId", List.of("abc123")); + + contextObject = new RingContextObject( + "GET", new StringBuffer("http://localhost/test"), "192.168.1.1", Map.of(), cookies, new HashMap<>(), null + ); + + assertEquals(1, contextObject.getCookies().size()); + assertEquals("abc123", contextObject.getCookies().get("sessionId").get(0)); + } + + @Test + void testHeadersExtraction() { + Vector contentTypeValues = new Vector<>(List.of("application/json")); + HashMap> headers = new HashMap<>(); + headers.put("Content-Type", contentTypeValues.elements()); + + contextObject = new RingContextObject( + "GET", new StringBuffer("http://localhost/test"), "192.168.1.1", Map.of(), new HashMap<>(), headers, null + ); + + assertEquals("application/json", contextObject.getHeader("content-type")); + } +} diff --git a/end2end/ring_clojure_postgres.py b/end2end/ring_clojure_postgres.py new file mode 100644 index 000000000..73aba7b67 --- /dev/null +++ b/end2end/ring_clojure_postgres.py @@ -0,0 +1,10 @@ +from utils import App, Request + +ring_clojure_postgres_app = App(8102) + +ring_clojure_postgres_app.add_payload("sql", + safe_request=Request("/api/create", body={"name": "Bobby"}), + unsafe_request=Request("/api/create", body={"name": "Malicious Pet', 'Gru from the Minions') -- "}) +) + +ring_clojure_postgres_app.test_all_payloads() diff --git a/sample-apps/RingClojurePostgres/Makefile b/sample-apps/RingClojurePostgres/Makefile index 79f315441..02ee1ef6b 100644 --- a/sample-apps/RingClojurePostgres/Makefile +++ b/sample-apps/RingClojurePostgres/Makefile @@ -1,12 +1,34 @@ # Define variables JAVA_AGENT = ../../dist/agent.jar +.PHONY: all +all: build + +.PHONY: build +build: + @echo "Resolving dependencies..." + lein deps + # Run the application with the Java agent .PHONY: run -run: +run: build @echo "Running RingClojurePostgres with Zen (http://localhost:8102)" AIKIDO_TOKEN="token" \ AIKIDO_REALTIME_ENDPOINT="http://localhost:5000/realtime" \ AIKIDO_ENDPOINT="http://localhost:5000" \ AIKIDO_BLOCK=1 \ + AIKIDO_AGENT_JAR="$(JAVA_AGENT)" \ + PORT=8102 \ lein run + +# Run the application without Zen +.PHONY: runWithoutZen +runWithoutZen: build + @echo "Running RingClojurePostgres without Zen (http://localhost:8103)" + AIKIDO_TOKEN="random-invalid-token" \ + PORT=8103 \ + lein run + +.PHONY: clean +clean: + lein clean diff --git a/sample-apps/RingClojurePostgres/project.clj b/sample-apps/RingClojurePostgres/project.clj index caaba8ad1..4a7e52cc5 100644 --- a/sample-apps/RingClojurePostgres/project.clj +++ b/sample-apps/RingClojurePostgres/project.clj @@ -1,12 +1,14 @@ (defproject RingClojurePostgres "0.1.0-SNAPSHOT" :dependencies [[org.clojure/clojure "1.11.1"] - [ring/ring-core "1.7.1"] - [ring/ring-jetty-adapter "1.7.1"] + [ring/ring-core "1.15.3"] + [ring/ring-jetty-adapter "1.15.3"] + [org.eclipse.jetty.toolchain/jetty-jakarta-servlet-api "5.0.2"] [clj-http "3.12.3"] [org.clojure/java.jdbc "0.7.12"] [org.postgresql/postgresql "42.2.20"] [ring/ring-json "0.5.1"]] :main ^:skip-aot RingClojurePostgres.core :target-path "target/%s" + :jvm-opts ~(if-let [agent (System/getenv "AIKIDO_AGENT_JAR")] [(str "-javaagent:" agent)] []) :profiles {:uberjar {:aot :all :jvm-opts ["-Dclojure.compiler.direct-linking=true"]}}) diff --git a/sample-apps/RingClojurePostgres/src/RingClojurePostgres/core.clj b/sample-apps/RingClojurePostgres/src/RingClojurePostgres/core.clj index 9b16e7552..371e610a1 100644 --- a/sample-apps/RingClojurePostgres/src/RingClojurePostgres/core.clj +++ b/sample-apps/RingClojurePostgres/src/RingClojurePostgres/core.clj @@ -91,4 +91,5 @@ (defn -main [& args] - (run-jetty app {:port 8102 :join? false})) + (let [port (if-let [p (System/getenv "PORT")] (Integer/parseInt p) 8102)] + (run-jetty app {:port port :join? false})))