diff --git a/instrumentation-security/spray-client/build.gradle b/instrumentation-security/spray-client/build.gradle new file mode 100644 index 000000000..f71ab7b45 --- /dev/null +++ b/instrumentation-security/spray-client/build.gradle @@ -0,0 +1,27 @@ +apply plugin: 'scala' + +isScalaProjectEnabled(project, "scala-2.10") + +jar { + manifest { attributes 'Implementation-Title': 'com.newrelic.instrumentation.security.spray-client' } +} + +dependencies { + implementation(project(":newrelic-security-api")) + implementation("com.newrelic.agent.java:newrelic-api:${nrAPIVersion}") + implementation("com.newrelic.agent.java:newrelic-weaver-api:${nrAPIVersion}") + implementation("org.scala-lang:scala-library:2.10.7") + implementation("io.spray:spray-client_2.10:1.3.3") + testImplementation("com.typesafe.akka:akka-actor_2.10:2.3.14") + testImplementation("io.spray:spray-can_2.10:1.3.3") +} + +verifyInstrumentation { + passesOnly('io.spray:spray-client_2.11:[1.3.1,)') + passesOnly('io.spray:spray-client_2.10:[1.3.1,)') +} + +site { + title 'Spray-can client' + type 'Messaging' +} \ No newline at end of file diff --git a/instrumentation-security/spray-client/src/main/scala/com/newrelic/agent/security/instrumentation/spray/client/OutboundRequest.scala b/instrumentation-security/spray-client/src/main/scala/com/newrelic/agent/security/instrumentation/spray/client/OutboundRequest.scala new file mode 100644 index 000000000..fd0aaf790 --- /dev/null +++ b/instrumentation-security/spray-client/src/main/scala/com/newrelic/agent/security/instrumentation/spray/client/OutboundRequest.scala @@ -0,0 +1,25 @@ +/* + * + * * Copyright 2020 New Relic Corporation. All rights reserved. + * * SPDX-License-Identifier: Apache-2.0 + * + */ + +package com.newrelic.agent.security.instrumentation.spray.client + +import spray.http.{HttpHeaders, HttpRequest} + +/** + * Spray's HttpRequest is immutable so we have to create a copy with the new headers. + */ + +class OutboundRequest(request: HttpRequest) { + private var req: HttpRequest = request + + def setHeader(key: String, value: String): Unit = { + req = request.withHeaders(req.headers ++ List(HttpHeaders.RawHeader(key, value))) + } + def getRequest: HttpRequest = { + req + } +} \ No newline at end of file diff --git a/instrumentation-security/spray-client/src/main/scala/com/newrelic/agent/security/instrumentation/spray/client/SprayUtils.java b/instrumentation-security/spray-client/src/main/scala/com/newrelic/agent/security/instrumentation/spray/client/SprayUtils.java new file mode 100644 index 000000000..81344b72a --- /dev/null +++ b/instrumentation-security/spray-client/src/main/scala/com/newrelic/agent/security/instrumentation/spray/client/SprayUtils.java @@ -0,0 +1,10 @@ +package com.newrelic.agent.security.instrumentation.spray.client; + +public class SprayUtils { + private static final String NR_SEC_OPERATION_LOCK = "OPERATION_LOCK_SPRAY_CAN_CLIENT-"; + public static final String METHOD_SEND_RECEIVE = "sendReceive"; + public static final String SPRAY_CLIENT = "SPRAY-CLIENT"; + public static String getNrSecCustomAttribName() { + return NR_SEC_OPERATION_LOCK + Thread.currentThread().getId(); + } +} diff --git a/instrumentation-security/spray-client/src/main/scala/spray/client/SendReceive_Instrumentation.java b/instrumentation-security/spray-client/src/main/scala/spray/client/SendReceive_Instrumentation.java new file mode 100644 index 000000000..96f6ac28f --- /dev/null +++ b/instrumentation-security/spray-client/src/main/scala/spray/client/SendReceive_Instrumentation.java @@ -0,0 +1,134 @@ +package spray.client; + +import com.newrelic.agent.security.instrumentation.spray.client.OutboundRequest; +import com.newrelic.agent.security.instrumentation.spray.client.SprayUtils; +import com.newrelic.api.agent.security.NewRelicSecurity; +import com.newrelic.api.agent.security.instrumentation.helpers.GenericHelper; +import com.newrelic.api.agent.security.instrumentation.helpers.ServletHelper; +import com.newrelic.api.agent.security.schema.AbstractOperation; +import com.newrelic.api.agent.security.schema.SecurityMetaData; +import com.newrelic.api.agent.security.schema.StringUtils; +import com.newrelic.api.agent.security.schema.exceptions.NewRelicSecurityException; +import com.newrelic.api.agent.security.schema.operation.SSRFOperation; +import com.newrelic.api.agent.security.utils.SSRFUtils; +import com.newrelic.api.agent.security.utils.logging.LogLevel; +import com.newrelic.api.agent.weaver.MatchType; +import com.newrelic.api.agent.weaver.Weave; +import com.newrelic.api.agent.weaver.Weaver; +import scala.concurrent.Future; +import spray.http.HttpRequest; +import spray.http.HttpResponse; + +import java.net.URI; + +@Weave(type = MatchType.Interface, originalName = "spray.client.pipelining$$anonfun$sendReceive$1") +public class SendReceive_Instrumentation { + + public final Future apply(HttpRequest request) { + boolean isLockAcquired = acquireLockIfPossible(); + AbstractOperation operation = null; + // Preprocess Phase + if (isLockAcquired) { + operation = preprocessSecurityHook(request); + request = addSecurityHeaders(request, operation); + } + + Future returnCode; + try { + returnCode = Weaver.callOriginal(); + } finally { + if (isLockAcquired) { + releaseLock(); + } + } + registerExitOperation(isLockAcquired, operation); + return returnCode; + } + + private HttpRequest addSecurityHeaders(HttpRequest request, AbstractOperation operation) { + OutboundRequest outboundRequest = new OutboundRequest(request); + if (operation!=null) { + SecurityMetaData securityMetaData = NewRelicSecurity.getAgent().getSecurityMetaData(); + String iastHeader = NewRelicSecurity.getAgent().getSecurityMetaData().getFuzzRequestIdentifier().getRaw(); + if (iastHeader != null && !iastHeader.trim().isEmpty()) { + outboundRequest.setHeader(ServletHelper.CSEC_IAST_FUZZ_REQUEST_ID, iastHeader); + } + String csecParentId = securityMetaData.getCustomAttribute(GenericHelper.CSEC_PARENT_ID, String.class); + if(StringUtils.isNotBlank(csecParentId)){ + outboundRequest.setHeader(GenericHelper.CSEC_PARENT_ID, csecParentId); + } + + try { + NewRelicSecurity.getAgent().registerOperation(operation); + } finally { + if (operation.getApiID() != null && !operation.getApiID().trim().isEmpty() && + operation.getExecutionId() != null && !operation.getExecutionId().trim().isEmpty()) { + // Add Security distributed tracing header + outboundRequest.setHeader(ServletHelper.CSEC_DISTRIBUTED_TRACING_HEADER, + SSRFUtils.generateTracingHeaderValue(securityMetaData.getTracingHeaderValue(), + operation.getApiID(), operation.getExecutionId(), + NewRelicSecurity.getAgent().getAgentUUID())); + } + } + } + return outboundRequest.getRequest(); + } + private void releaseLock() { + try { + GenericHelper.releaseLock(SprayUtils.getNrSecCustomAttribName()); + } catch (Throwable ignored) { + } + } + + private boolean acquireLockIfPossible() { + try { + return GenericHelper.acquireLockIfPossible(SprayUtils.getNrSecCustomAttribName()); + } catch (Throwable ignored) { + } + return false; + } + + private AbstractOperation preprocessSecurityHook(HttpRequest httpRequest) { + try { + SecurityMetaData securityMetaData = NewRelicSecurity.getAgent().getSecurityMetaData(); + if (!NewRelicSecurity.isHookProcessingActive() || securityMetaData.getRequest().isEmpty()) { + return null; + } + + // Generate required URL + URI methodURI = null; + String uri = null; + try { + methodURI = new URI(httpRequest.uri().toString()); + uri = methodURI.toString(); + if (methodURI == null) { + return null; + } + } catch (Exception ignored){ + NewRelicSecurity.getAgent().log(LogLevel.WARNING, String.format(GenericHelper.URI_EXCEPTION_MESSAGE, SprayUtils.SPRAY_CLIENT, ignored.getMessage()), ignored, this.getClass().getName()); + return null; + } + return new SSRFOperation(uri, this.getClass().getName(), SprayUtils.METHOD_SEND_RECEIVE); + } catch (Throwable e) { + if (e instanceof NewRelicSecurityException) { + NewRelicSecurity.getAgent().log(LogLevel.WARNING, String.format(GenericHelper.SECURITY_EXCEPTION_MESSAGE, SprayUtils.SPRAY_CLIENT, e.getMessage()), e, this.getClass().getName()); + throw e; + } + NewRelicSecurity.getAgent().log(LogLevel.SEVERE, String.format(GenericHelper.REGISTER_OPERATION_EXCEPTION_MESSAGE, SprayUtils.SPRAY_CLIENT, e.getMessage()), e, this.getClass().getName()); + NewRelicSecurity.getAgent().reportIncident(LogLevel.SEVERE, String.format(GenericHelper.REGISTER_OPERATION_EXCEPTION_MESSAGE, SprayUtils.SPRAY_CLIENT, e.getMessage()), e, this.getClass().getName()); + } + return null; + } + private void registerExitOperation(boolean isProcessingAllowed, AbstractOperation operation) { + try { + if (operation == null || !isProcessingAllowed || !NewRelicSecurity.isHookProcessingActive() || NewRelicSecurity.getAgent().getSecurityMetaData().getRequest().isEmpty() + ) { + return; + } + NewRelicSecurity.getAgent().registerExitEvent(operation); + } catch (Throwable e) { + NewRelicSecurity.getAgent().log(LogLevel.FINEST, String.format(GenericHelper.EXIT_OPERATION_EXCEPTION_MESSAGE, SprayUtils.SPRAY_CLIENT, e.getMessage()), e, this.getClass().getName()); + } + } +} + diff --git a/instrumentation-security/spray-client/src/test/scala/com/newrelic/security/agent/spray/client/SprayCanClientTest.scala b/instrumentation-security/spray-client/src/test/scala/com/newrelic/security/agent/spray/client/SprayCanClientTest.scala new file mode 100644 index 000000000..36a056279 --- /dev/null +++ b/instrumentation-security/spray-client/src/test/scala/com/newrelic/security/agent/spray/client/SprayCanClientTest.scala @@ -0,0 +1,118 @@ +/* + * + * * Copyright 2020 New Relic Corporation. All rights reserved. + * * SPDX-License-Identifier: Apache-2.0 + * + */ + +package com.newrelic.security.agent.spray.client + +import akka.actor._ +import com.newrelic.agent.security.instrumentation.spray.client.SprayUtils +import com.newrelic.agent.security.introspec.internal.HttpServerLocator +import com.newrelic.agent.security.introspec.{HttpTestServer, InstrumentationTestConfig, SecurityInstrumentationTestRunner, SecurityIntrospector} +import com.newrelic.api.agent.security.instrumentation.helpers.{GenericHelper, ServletHelper} +import com.newrelic.api.agent.security.schema.VulnerabilityCaseType +import com.newrelic.api.agent.security.schema.operation.SSRFOperation +import com.newrelic.security.test.marker.{Java11IncompatibleTest, Java17IncompatibleTest} +import org.junit.experimental.categories.Category +import org.junit.runner.RunWith +import org.junit._ +import org.junit.runners.MethodSorters +import spray.client.pipelining +import spray.client.pipelining.Get + +import java.util.UUID +import scala.util.{Failure, Success} + +//// Not compatible with Java 11+ and Scala 2.13+ https://github.com/scala/bug/issues/12340 +@Category( Array(classOf[Java11IncompatibleTest], classOf[Java17IncompatibleTest] )) +@RunWith(classOf[SecurityInstrumentationTestRunner]) +@InstrumentationTestConfig(includePrefixes = Array("spray", "scala", "com.newrelic.agent.security.instrumentation")) +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +class SprayCanClientTest { + var server :HttpTestServer = HttpServerLocator.createAndStart() + implicit var system: ActorSystem = ActorSystem("spray-client") + val endpoint :String = server.getEndPoint.toString; + + @After + def after(): Unit = { + server.shutdown() + } + + @Test + def testSendReceive(): Unit = { + server.getHeaders.clear() + val introspector: SecurityIntrospector = SecurityInstrumentationTestRunner.getIntrospector + + requestApi() + + Assert.assertTrue("No operations detected", introspector.getOperations.size() > 0) + val operations: SSRFOperation = introspector.getOperations.get(0).asInstanceOf[SSRFOperation] + Assert.assertEquals("Invalid event category.", VulnerabilityCaseType.HTTP_REQUEST, operations.getCaseType) + Assert.assertEquals("Invalid method-name.", SprayUtils.METHOD_SEND_RECEIVE, operations.getMethodName) + Assert.assertEquals("Invalid ssrf arg.", endpoint, operations.getArg) + + val header: java.util.Map[String, String] = server.getHeaders + Assert.assertFalse(String.format("Found CSEC header: %s", ServletHelper.CSEC_IAST_FUZZ_REQUEST_ID), header.containsKey(ServletHelper.CSEC_IAST_FUZZ_REQUEST_ID)) + Assert.assertFalse(String.format("Found CSEC header: %s", ServletHelper.CSEC_DISTRIBUTED_TRACING_HEADER), header.containsKey(ServletHelper.CSEC_DISTRIBUTED_TRACING_HEADER)) + Assert.assertFalse(String.format("Found CSEC header: %s", GenericHelper.CSEC_PARENT_ID), header.containsKey(GenericHelper.CSEC_PARENT_ID)) + } + + @Test + def testSendReceiveWithHeader(): Unit = { + val headerValue = String.valueOf(UUID.randomUUID) + val introspector: SecurityIntrospector = SecurityInstrumentationTestRunner.getIntrospector + introspector.setK2FuzzRequestId(headerValue) + introspector.setK2TracingData(headerValue) + introspector.setK2ParentId(headerValue) + + requestApi() + + + Assert.assertTrue("No operations detected", introspector.getOperations.size() > 0) + val operations: SSRFOperation = introspector.getOperations.get(0).asInstanceOf[SSRFOperation] + Assert.assertEquals("Invalid event category.", VulnerabilityCaseType.HTTP_REQUEST, operations.getCaseType) + Assert.assertEquals("Invalid method-name.", SprayUtils.METHOD_SEND_RECEIVE, operations.getMethodName) + Assert.assertEquals("Invalid ssrf arg.", endpoint, operations.getArg) + + val header: java.util.Map[String, String] = server.getHeaders + Assert.assertTrue(String.format("Missing CSEC header: %s", ServletHelper.CSEC_IAST_FUZZ_REQUEST_ID), header.containsKey(ServletHelper.CSEC_IAST_FUZZ_REQUEST_ID)) + Assert.assertEquals( + String.format("Invalid CSEC header value for: %s", ServletHelper.CSEC_IAST_FUZZ_REQUEST_ID), + headerValue, + header.get(ServletHelper.CSEC_IAST_FUZZ_REQUEST_ID) + ) + + Assert.assertTrue(String.format("Missing CSEC header: %s", ServletHelper.CSEC_DISTRIBUTED_TRACING_HEADER), header.containsKey(ServletHelper.CSEC_DISTRIBUTED_TRACING_HEADER.toLowerCase)) + Assert.assertEquals( + String.format("Invalid CSEC header value for: %s", ServletHelper.CSEC_DISTRIBUTED_TRACING_HEADER), + String.format("%s;DUMMY_UUID/dummy-api-id/dummy-exec-id;", headerValue), + header.get(ServletHelper.CSEC_DISTRIBUTED_TRACING_HEADER.toLowerCase) + ) + Assert.assertTrue(String.format("Missing CSEC header: %s", GenericHelper.CSEC_PARENT_ID), header.containsKey(GenericHelper.CSEC_PARENT_ID)) + Assert.assertEquals( + String.format("Invalid CSEC header value for: %s", GenericHelper.CSEC_PARENT_ID), + headerValue, + header.get(GenericHelper.CSEC_PARENT_ID) + ) + } + + + def requestApi()(implicit system: ActorSystem): Unit = { + import system.dispatcher + val pipeline = pipelining.sendReceive + val responseFuture = pipeline {Get(endpoint)} + + responseFuture onComplete { + case Success(result) => + println("The API call was successful...: " + result) + system.shutdown() + + case Failure(error) => + println(error, "Couldn't get elevation") + system.shutdown() + } + system.awaitTermination + } +} diff --git a/settings.gradle b/settings.gradle index 71d851ba8..f99d96629 100644 --- a/settings.gradle +++ b/settings.gradle @@ -179,4 +179,5 @@ include 'instrumentation:jetty-12' include 'instrumentation:mule-3.7' include 'instrumentation:mule-3.6' include 'instrumentation:spray-http-1.3.1' +include 'instrumentation:spray-client' include 'instrumentation:spray-can-1.3.1' \ No newline at end of file