diff --git a/dd-java-agent/agent-iast/src/main/java/com/datadog/iast/IastRequestContext.java b/dd-java-agent/agent-iast/src/main/java/com/datadog/iast/IastRequestContext.java index d237935ac01..c2960eb82a7 100644 --- a/dd-java-agent/agent-iast/src/main/java/com/datadog/iast/IastRequestContext.java +++ b/dd-java-agent/agent-iast/src/main/java/com/datadog/iast/IastRequestContext.java @@ -48,8 +48,27 @@ public IastRequestContext() { } public IastRequestContext(final TaintedObjects taintedObjects) { + this(taintedObjects, false); + } + + public IastRequestContext(final TaintedObjects taintedObjects, boolean isGlobal) { + this.vulnerabilityBatch = new VulnerabilityBatch(); + this.overheadContext = + new OverheadContext(Config.get().getIastVulnerabilitiesPerRequest(), isGlobal); + this.taintedObjects = taintedObjects; + } + + /** + * Use this constructor only when you want to create a new context with a fresh overhead context + * (e.g. for testing purposes). + * + * @param taintedObjects the tainted objects to use + * @param overheadContext the overhead context to use + */ + public IastRequestContext( + final TaintedObjects taintedObjects, final OverheadContext overheadContext) { this.vulnerabilityBatch = new VulnerabilityBatch(); - this.overheadContext = new OverheadContext(Config.get().getIastVulnerabilitiesPerRequest()); + this.overheadContext = overheadContext; this.taintedObjects = taintedObjects; } @@ -188,6 +207,7 @@ public void releaseRequestContext(@Nonnull final IastContext context) { pool.offer(unwrapped); iastCtx.setTaintedObjects(TaintedObjects.NoOp.INSTANCE); } + iastCtx.overheadContext.resetMaps(); } } } diff --git a/dd-java-agent/agent-iast/src/main/java/com/datadog/iast/Reporter.java b/dd-java-agent/agent-iast/src/main/java/com/datadog/iast/Reporter.java index 20222ba551c..f5b9a16ff00 100644 --- a/dd-java-agent/agent-iast/src/main/java/com/datadog/iast/Reporter.java +++ b/dd-java-agent/agent-iast/src/main/java/com/datadog/iast/Reporter.java @@ -140,7 +140,7 @@ private VulnerabilityBatch getOrCreateVulnerabilityBatch(final AgentSpan span) { private AgentSpan startNewSpan() { final AgentSpanContext tagContext = new TagContext() - .withRequestContextDataIast(new IastRequestContext(TaintedObjects.NoOp.INSTANCE)); + .withRequestContextDataIast(new IastRequestContext(TaintedObjects.NoOp.INSTANCE, true)); final AgentSpan span = tracer() .startSpan("iast", VULNERABILITY_SPAN_NAME, tagContext) diff --git a/dd-java-agent/agent-iast/src/main/java/com/datadog/iast/overhead/OverheadContext.java b/dd-java-agent/agent-iast/src/main/java/com/datadog/iast/overhead/OverheadContext.java index b34b533f43a..9dac3394455 100644 --- a/dd-java-agent/agent-iast/src/main/java/com/datadog/iast/overhead/OverheadContext.java +++ b/dd-java-agent/agent-iast/src/main/java/com/datadog/iast/overhead/OverheadContext.java @@ -3,16 +3,60 @@ import static datadog.trace.api.iast.IastDetectionMode.UNLIMITED; import com.datadog.iast.util.NonBlockingSemaphore; +import datadog.trace.api.iast.VulnerabilityTypes; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicIntegerArray; +import java.util.function.Function; +import javax.annotation.Nullable; +import org.jetbrains.annotations.NotNull; public class OverheadContext { + /** Maximum number of distinct endpoints to remember in the global cache. */ + private static final int GLOBAL_MAP_MAX_SIZE = 4096; + + /** + * Global concurrent cache mapping each “method + path” key to its historical vulnerabilityCounts + * map. As soon as size() > GLOBAL_MAP_MAX_SIZE, we clear() the whole map. + */ + static final ConcurrentMap globalMap = + new ConcurrentHashMap() { + + @Override + public AtomicIntegerArray computeIfAbsent( + String key, + @NotNull Function mappingFunction) { + if (this.size() >= GLOBAL_MAP_MAX_SIZE) { + super.clear(); + } + return super.computeIfAbsent(key, mappingFunction); + } + }; + + // Snapshot of the globalMap for the current request + private @Nullable final Map copyMap; + // Map of vulnerabilities per endpoint for the current request, needs to use AtomicIntegerArray + // because it's possible to have concurrent updates in the same request + private @Nullable final Map requestMap; + private final NonBlockingSemaphore availableVulnerabilities; + private final boolean isGlobal; public OverheadContext(final int vulnerabilitiesPerRequest) { + this(vulnerabilitiesPerRequest, false); + } + + public OverheadContext(final int vulnerabilitiesPerRequest, final boolean isGlobal) { availableVulnerabilities = vulnerabilitiesPerRequest == UNLIMITED ? NonBlockingSemaphore.unlimited() : NonBlockingSemaphore.withPermitCount(vulnerabilitiesPerRequest); + this.isGlobal = isGlobal; + this.requestMap = isGlobal ? null : new ConcurrentHashMap<>(); + this.copyMap = isGlobal ? null : new ConcurrentHashMap<>(); } public int getAvailableQuota() { @@ -26,4 +70,52 @@ public boolean consumeQuota(final int delta) { public void reset() { availableVulnerabilities.reset(); } + + public void resetMaps() { + // If this is a global context, we do not reset the maps + if (isGlobal || requestMap == null || copyMap == null) { + return; + } + Set endpoints = requestMap.keySet(); + // If the budget is not consumed, we can reset the maps + if (getAvailableQuota() > 0) { + // clean endpoints from globalMap + endpoints.forEach(globalMap::remove); + return; + } + // If the budget is consumed, we need to merge the requestMap into the globalMap + endpoints.forEach( + endpoint -> { + AtomicIntegerArray countMap = requestMap.get(endpoint); + // should not happen, but just in case + if (countMap == null) { + globalMap.remove(endpoint); + return; + } + // Iterate over the vulnerabilities and update the globalMap + int numberOfVulnerabilities = VulnerabilityTypes.STRINGS.length; + for (int i = 0; i < numberOfVulnerabilities; i++) { + int counter = countMap.get(i); + if (counter > 0) { + AtomicIntegerArray globalCountMap = + globalMap.computeIfAbsent( + endpoint, value -> new AtomicIntegerArray(numberOfVulnerabilities)); + + globalCountMap.accumulateAndGet(i, counter, Math::max); + } + } + }); + } + + public boolean isGlobal() { + return isGlobal; + } + + public @Nullable Map getCopyMap() { + return copyMap; + } + + public @Nullable Map getRequestMap() { + return requestMap; + } } diff --git a/dd-java-agent/agent-iast/src/main/java/com/datadog/iast/overhead/OverheadController.java b/dd-java-agent/agent-iast/src/main/java/com/datadog/iast/overhead/OverheadController.java index 0cd4575056a..0a9e9fa56ea 100644 --- a/dd-java-agent/agent-iast/src/main/java/com/datadog/iast/overhead/OverheadController.java +++ b/dd-java-agent/agent-iast/src/main/java/com/datadog/iast/overhead/OverheadController.java @@ -1,19 +1,24 @@ package com.datadog.iast.overhead; +import static com.datadog.iast.overhead.OverheadContext.globalMap; import static datadog.trace.api.iast.IastDetectionMode.UNLIMITED; import com.datadog.iast.IastRequestContext; import com.datadog.iast.IastSystem; +import com.datadog.iast.model.VulnerabilityType; import com.datadog.iast.util.NonBlockingSemaphore; import datadog.trace.api.Config; import datadog.trace.api.gateway.RequestContext; import datadog.trace.api.gateway.RequestContextSlot; import datadog.trace.api.iast.IastContext; +import datadog.trace.api.iast.VulnerabilityTypes; import datadog.trace.api.telemetry.LogCollector; import datadog.trace.bootstrap.instrumentation.api.AgentSpan; import datadog.trace.bootstrap.instrumentation.api.AgentTracer; +import datadog.trace.bootstrap.instrumentation.api.Tags; import datadog.trace.util.AgentTaskScheduler; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicIntegerArray; import java.util.concurrent.atomic.AtomicLong; import javax.annotation.Nullable; import org.slf4j.Logger; @@ -27,9 +32,12 @@ public interface OverheadController { int releaseRequest(); - boolean hasQuota(final Operation operation, @Nullable final AgentSpan span); + boolean hasQuota(Operation operation, @Nullable AgentSpan span); - boolean consumeQuota(final Operation operation, @Nullable final AgentSpan span); + boolean consumeQuota(Operation operation, @Nullable AgentSpan span); + + boolean consumeQuota( + Operation operation, @Nullable AgentSpan span, @Nullable VulnerabilityType type); static OverheadController build(final Config config, final AgentTaskScheduler scheduler) { return build( @@ -100,14 +108,23 @@ public boolean hasQuota(final Operation operation, @Nullable final AgentSpan spa @Override public boolean consumeQuota(final Operation operation, @Nullable final AgentSpan span) { - final boolean result = delegate.consumeQuota(operation, span); + return consumeQuota(operation, span, null); + } + + @Override + public boolean consumeQuota( + final Operation operation, + @Nullable final AgentSpan span, + @Nullable final VulnerabilityType type) { + final boolean result = delegate.consumeQuota(operation, span, type); if (LOGGER.isDebugEnabled()) { LOGGER.debug( - "consumeQuota: operation={}, result={}, availableQuota={}, span={}", + "consumeQuota: operation={}, result={}, availableQuota={}, span={}, type={}", operation, result, getAvailableQuote(span), - span); + span, + type); } return result; } @@ -147,7 +164,7 @@ class OverheadControllerImpl implements OverheadController { private volatile long lastAcquiredTimestamp = Long.MAX_VALUE; final OverheadContext globalContext = - new OverheadContext(Config.get().getIastVulnerabilitiesPerRequest()); + new OverheadContext(Config.get().getIastVulnerabilitiesPerRequest(), true); public OverheadControllerImpl( final float requestSampling, @@ -192,7 +209,96 @@ public boolean hasQuota(final Operation operation, @Nullable final AgentSpan spa @Override public boolean consumeQuota(final Operation operation, @Nullable final AgentSpan span) { - return operation.consumeQuota(getContext(span)); + return consumeQuota(operation, span, null); + } + + @Override + public boolean consumeQuota( + final Operation operation, + @Nullable final AgentSpan span, + @Nullable final VulnerabilityType type) { + + OverheadContext ctx = getContext(span); + if (ctx == null) { + return false; + } + if (ctx.isGlobal()) { + return operation.consumeQuota(ctx); + } + if (operation.hasQuota(ctx)) { + String method = null; + String path = null; + if (span != null) { + AgentSpan rootSpan = span.getLocalRootSpan(); + Object methodTag = rootSpan.getTag(Tags.HTTP_METHOD); + method = (methodTag == null) ? "" : methodTag.toString(); + Object routeTag = rootSpan.getTag(Tags.HTTP_ROUTE); + path = (routeTag == null) ? "" : routeTag.toString(); + } + if (!maybeSkipVulnerability(ctx, type, method, path)) { + return operation.consumeQuota(ctx); + } + } + return false; + } + + /** + * Method to be called when a vulnerability of a certain type is detected. Implements the + * RFC-1029 algorithm. + * + * @param ctx the overhead context for the current request + * @param type the type of vulnerability detected + * @param httpMethod the HTTP method of the request (e.g., GET, POST) + * @param httpPath the HTTP path of the request + * @return true if the vulnerability should be skipped, false otherwise + */ + private boolean maybeSkipVulnerability( + @Nullable final OverheadContext ctx, + @Nullable final VulnerabilityType type, + @Nullable final String httpMethod, + @Nullable final String httpPath) { + + if (ctx == null || type == null || ctx.getRequestMap() == null || ctx.getCopyMap() == null) { + return false; + } + + int numberOfVulnerabilities = VulnerabilityTypes.STRINGS.length; + + String currentEndpoint = httpMethod + " " + httpPath; + + AtomicIntegerArray requestArray = ctx.getRequestMap().get(currentEndpoint); + int[] copyArray; + + if (requestArray == null) { + AtomicIntegerArray globalArray = + globalMap.computeIfAbsent( + currentEndpoint, k -> new AtomicIntegerArray(numberOfVulnerabilities)); + copyArray = toIntArray(globalArray); + ctx.getCopyMap().put(currentEndpoint, copyArray); + requestArray = + ctx.getRequestMap() + .computeIfAbsent( + currentEndpoint, k -> new AtomicIntegerArray(numberOfVulnerabilities)); + } else { + copyArray = ctx.getCopyMap().get(currentEndpoint); + } + + int counter = requestArray.getAndIncrement(type.type()); + int storedCounter = 0; + if (copyArray != null) { + storedCounter = copyArray[type.type()]; + } + + return counter < storedCounter; + } + + private static int[] toIntArray(AtomicIntegerArray atomic) { + int length = atomic.length(); + int[] result = new int[length]; + for (int i = 0; i < length; i++) { + result[i] = atomic.get(i); + } + return result; } @Nullable diff --git a/dd-java-agent/agent-iast/src/main/java/com/datadog/iast/sink/HttpResponseHeaderModuleImpl.java b/dd-java-agent/agent-iast/src/main/java/com/datadog/iast/sink/HttpResponseHeaderModuleImpl.java index 21d372c7cab..70ec869951d 100644 --- a/dd-java-agent/agent-iast/src/main/java/com/datadog/iast/sink/HttpResponseHeaderModuleImpl.java +++ b/dd-java-agent/agent-iast/src/main/java/com/datadog/iast/sink/HttpResponseHeaderModuleImpl.java @@ -1,5 +1,6 @@ package com.datadog.iast.sink; +import static com.datadog.iast.model.VulnerabilityType.INSECURE_COOKIE; import static com.datadog.iast.util.HttpHeader.SET_COOKIE; import static com.datadog.iast.util.HttpHeader.SET_COOKIE2; import static java.util.Collections.singletonList; @@ -65,7 +66,9 @@ private void onCookies(final List cookies) { return; } final AgentSpan span = AgentTracer.activeSpan(); - if (!overheadController.consumeQuota(Operations.REPORT_VULNERABILITY, span)) { + if (!overheadController.consumeQuota( + Operations.REPORT_VULNERABILITY, span, INSECURE_COOKIE // we need a type to check quota + )) { return; } final Location location = Location.forSpanAndStack(span, getCurrentStackTrace()); diff --git a/dd-java-agent/agent-iast/src/main/java/com/datadog/iast/sink/SinkModuleBase.java b/dd-java-agent/agent-iast/src/main/java/com/datadog/iast/sink/SinkModuleBase.java index fb694a96d77..38c66cb4c53 100644 --- a/dd-java-agent/agent-iast/src/main/java/com/datadog/iast/sink/SinkModuleBase.java +++ b/dd-java-agent/agent-iast/src/main/java/com/datadog/iast/sink/SinkModuleBase.java @@ -58,7 +58,8 @@ protected void report(final Vulnerability vulnerability) { } protected void report(@Nullable final AgentSpan span, final Vulnerability vulnerability) { - if (!overheadController.consumeQuota(Operations.REPORT_VULNERABILITY, span)) { + if (!overheadController.consumeQuota( + Operations.REPORT_VULNERABILITY, span, vulnerability.getType())) { return; } reporter.report(span, vulnerability); @@ -70,7 +71,7 @@ protected void report(final VulnerabilityType type, final Evidence evidence) { protected void report( @Nullable final AgentSpan span, final VulnerabilityType type, final Evidence evidence) { - if (!overheadController.consumeQuota(Operations.REPORT_VULNERABILITY, span)) { + if (!overheadController.consumeQuota(Operations.REPORT_VULNERABILITY, span, type)) { return; } final Vulnerability vulnerability = @@ -170,7 +171,7 @@ protected final Evidence checkInjection( } final AgentSpan span = AgentTracer.activeSpan(); - if (!overheadController.consumeQuota(Operations.REPORT_VULNERABILITY, span)) { + if (!overheadController.consumeQuota(Operations.REPORT_VULNERABILITY, span, type)) { return null; } @@ -251,7 +252,7 @@ protected final Evidence checkInjection( if (!spanFetched && valueRanges != null && valueRanges.length > 0) { span = AgentTracer.activeSpan(); spanFetched = true; - if (!overheadController.consumeQuota(Operations.REPORT_VULNERABILITY, span)) { + if (!overheadController.consumeQuota(Operations.REPORT_VULNERABILITY, span, type)) { return null; } } diff --git a/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/IastModuleImplTestBase.groovy b/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/IastModuleImplTestBase.groovy index 0d031c7d566..7d7d77e7dd6 100644 --- a/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/IastModuleImplTestBase.groovy +++ b/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/IastModuleImplTestBase.groovy @@ -114,6 +114,7 @@ class IastModuleImplTestBase extends DDSpecification { return Stub(OverheadController) { acquireRequest() >> true consumeQuota(_ as Operation, _) >> true + consumeQuota(_ as Operation, _, _) >> true } } } diff --git a/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/IastRequestContextTest.groovy b/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/IastRequestContextTest.groovy index 5d7fe0dde7c..a9621171fb6 100644 --- a/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/IastRequestContextTest.groovy +++ b/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/IastRequestContextTest.groovy @@ -1,6 +1,8 @@ package com.datadog.iast import com.datadog.iast.model.Range +import com.datadog.iast.overhead.OverheadContext +import com.datadog.iast.taint.TaintedMap import com.datadog.iast.taint.TaintedObjects import datadog.trace.api.Config import datadog.trace.api.gateway.RequestContext @@ -120,4 +122,16 @@ class IastRequestContextTest extends DDSpecification { then: ctx.taintedObjects.count() == 0 } + + void 'on release context overheadContext reset is called'() { + setup: + final overheadCtx = Mock(OverheadContext) + final ctx = new IastRequestContext(TaintedObjects.build(TaintedMap.build(TaintedMap.DEFAULT_CAPACITY)), overheadCtx) + + when: + provider.releaseRequestContext(ctx) + + then: + 1 * overheadCtx.resetMaps() + } } diff --git a/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/overhead/OverheadContextTest.groovy b/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/overhead/OverheadContextTest.groovy index c606e30c125..a0771b10831 100644 --- a/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/overhead/OverheadContextTest.groovy +++ b/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/overhead/OverheadContextTest.groovy @@ -1,7 +1,9 @@ package com.datadog.iast.overhead +import com.datadog.iast.model.VulnerabilityType import datadog.trace.api.Config import datadog.trace.api.iast.IastContext +import datadog.trace.api.iast.VulnerabilityTypes import datadog.trace.test.util.DDSpecification import datadog.trace.util.AgentTaskScheduler import com.datadog.iast.overhead.OverheadController.OverheadControllerImpl @@ -9,9 +11,16 @@ import groovy.transform.CompileDynamic import static datadog.trace.api.iast.IastDetectionMode.UNLIMITED +import java.util.concurrent.atomic.AtomicIntegerArray + @CompileDynamic class OverheadContextTest extends DDSpecification { + @Override + void cleanup() { + OverheadContext.globalMap.clear() + } + void 'Can reset global overhead context'() { given: def taskSchedler = Stub(AgentTaskScheduler) @@ -69,4 +78,156 @@ class OverheadContextTest extends DDSpecification { !consumed.any { !it } overheadContext.availableQuota == Integer.MAX_VALUE } + + void 'if it is global sampling maps are null'() { + given: + OverheadContext ctx = new OverheadContext(1, true) + + expect: + ctx.requestMap == null + ctx.copyMap == null + } + + void "resetMaps is no-op when context is global"() { + given: + def ctx = new OverheadContext(5, true) + def array = new AtomicIntegerArray(VulnerabilityTypes.STRINGS.length) + array.incrementAndGet(VulnerabilityType.WEAK_HASH.type()) + OverheadContext.globalMap.put("endpoint", array) + + + when: + ctx.resetMaps() + + then: + // globalMap remains unchanged + OverheadContext.globalMap.get('endpoint').get(VulnerabilityType.WEAK_HASH.type()) == 1 + ctx.copyMap == null + ctx.requestMap == null + } + + void "resetMaps clears request and copy maps when quota remains"() { + given: + def ctx = new OverheadContext(3, false) + // Prepare global entry for "endpoint" + def globalArray = new AtomicIntegerArray(VulnerabilityTypes.STRINGS.length) + globalArray.getAndSet(VulnerabilityType.SQL_INJECTION.type(), 2) + OverheadContext.globalMap.put("endpoint", globalArray) + def requestArray = new AtomicIntegerArray(VulnerabilityTypes.STRINGS.length) + requestArray.set(VulnerabilityType.WEAK_HASH.type(), 1) + ctx.requestMap.put("endpoint", requestArray) + def copyArray = new int[VulnerabilityTypes.STRINGS.length] + copyArray[VulnerabilityType.WEAK_HASH.type()] = 1 + ctx.copyMap.put("endpoint", copyArray) + assert ctx.getAvailableQuota() > 0 + + when: + ctx.resetMaps() + + then: + // Since quota > 0, we remove any global entry for "endpoint" (none here) + OverheadContext.globalMap.isEmpty() + } + + void "resetMaps merges and updates global entry when quota consumed "() { + given: + def ctx = new OverheadContext(1, false) + def globalArray = new AtomicIntegerArray(VulnerabilityTypes.STRINGS.length) + globalArray.getAndSet(VulnerabilityType.WEAK_HASH.type(), 1) + OverheadContext.globalMap.put("endpoint", globalArray) + // Simulate we saw 3 in this request + def requestArray = new AtomicIntegerArray(VulnerabilityTypes.STRINGS.length) + requestArray.set(VulnerabilityType.WEAK_HASH.type(), 3) + ctx.requestMap.put("endpoint", requestArray) + def copyArray = new int[VulnerabilityTypes.STRINGS.length] + copyArray[VulnerabilityType.WEAK_HASH.type()] = 1 + ctx.copyMap.put("endpoint", copyArray) + ctx.consumeQuota(1) + assert ctx.getAvailableQuota() == 0 + + when: + ctx.resetMaps() + + then: + // The max of (global=1, request=3) is 3, so globalMap is updated + OverheadContext.globalMap.get("endpoint").get(VulnerabilityType.WEAK_HASH.type()) == 3 + } + + void "resetMaps merges and updates global entry when quota consumed and counter <= globalCounter"() { + given: + def ctx = new OverheadContext(1, false) + def globalArray = new AtomicIntegerArray(VulnerabilityTypes.STRINGS.length) + globalArray.getAndSet(VulnerabilityType.WEAK_HASH.type(), 2) + OverheadContext.globalMap.put("endpoint", globalArray) + def requestArray = new AtomicIntegerArray(VulnerabilityTypes.STRINGS.length) + requestArray.set(VulnerabilityType.WEAK_HASH.type(), 1) + ctx.requestMap.put("endpoint", requestArray) + def copyArray = new int[VulnerabilityTypes.STRINGS.length] + copyArray[VulnerabilityType.WEAK_HASH.type()] = 2 + ctx.copyMap.put("endpoint", copyArray) + ctx.consumeQuota(1) + assert ctx.getAvailableQuota() == 0 + + when: + ctx.resetMaps() + + then: + // The max of (global=1, request=3) is 3, so globalMap is updated + OverheadContext.globalMap.get("endpoint").get(VulnerabilityType.WEAK_HASH.type()) == 2 + } + + void "resetMaps merges and updates global entry when quota consumed and a vuln is detected in a new endpoint"() { + given: + def ctx = new OverheadContext(1, false) + def globalArray = new AtomicIntegerArray(VulnerabilityTypes.STRINGS.length) + globalArray.getAndSet(VulnerabilityType.WEAK_HASH.type(), 1) + OverheadContext.globalMap.put("endpoint", globalArray) + def requestArray = new AtomicIntegerArray(VulnerabilityTypes.STRINGS.length) + requestArray.set(VulnerabilityType.WEAK_CIPHER.type(), 1) + ctx.requestMap.put("endpoint2", requestArray) + def copyArray = new int[VulnerabilityTypes.STRINGS.length] + copyArray[VulnerabilityType.WEAK_HASH.type()] = 1 + ctx.copyMap.put("endpoint", copyArray) + ctx.consumeQuota(1) + assert ctx.getAvailableQuota() == 0 + + when: + ctx.resetMaps() + + then: + OverheadContext.globalMap.get("endpoint").get(VulnerabilityType.WEAK_HASH.type()) == 1 + OverheadContext.globalMap.get("endpoint2").get(VulnerabilityType.WEAK_CIPHER.type()) == 1 + } + + + void "computeIfAbsent should not clear until size exceeds GLOBAL_MAP_MAX_SIZE"() { + given: "We know the maximum size" + int maxSize = OverheadContext.GLOBAL_MAP_MAX_SIZE + + when: "We insert exactly maxSize distinct keys via computeIfAbsent" + (1..maxSize).each { i -> + AtomicIntegerArray arr = OverheadContext.globalMap.computeIfAbsent("key" + i) { + new AtomicIntegerArray([i] as int[]) + } + // verify returned array holds the correct value + assert arr.get(0) == i + } + + then: "The map size is exactly maxSize and none of those keys was evicted" + OverheadContext.globalMap.size() == maxSize + (1..maxSize).each { i -> + assert OverheadContext.globalMap.containsKey("key"+i) + assert OverheadContext.globalMap.get("key"+i).get(0) == i + } + + when: "We invoke computeIfAbsent on one more distinct key, which should trigger clear()" + AtomicIntegerArray extra = OverheadContext.globalMap.computeIfAbsent("keyExtra") { + new AtomicIntegerArray([999] as int[]) + } + + then: + OverheadContext.globalMap.size() == 1 + // And the returned array is still the one newly created + extra.get(0) == 999 + } } diff --git a/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/overhead/OverheadControllerTest.groovy b/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/overhead/OverheadControllerTest.groovy index 875789e7466..bc448a66bcf 100644 --- a/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/overhead/OverheadControllerTest.groovy +++ b/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/overhead/OverheadControllerTest.groovy @@ -2,14 +2,19 @@ package com.datadog.iast.overhead import com.datadog.iast.IastRequestContext import com.datadog.iast.overhead.OverheadController.OverheadControllerImpl +import com.datadog.iast.taint.TaintedMap +import com.datadog.iast.taint.TaintedObjects import datadog.trace.api.Config import datadog.trace.api.gateway.RequestContext import datadog.trace.api.gateway.RequestContextSlot +import datadog.trace.api.iast.VulnerabilityTypes import datadog.trace.bootstrap.instrumentation.api.AgentSpan +import datadog.trace.bootstrap.instrumentation.api.Tags import datadog.trace.test.util.DDSpecification import datadog.trace.util.AgentTaskScheduler import groovy.transform.CompileDynamic import spock.lang.Shared +import com.datadog.iast.model.VulnerabilityType import java.util.concurrent.Callable import java.util.concurrent.CountDownLatch @@ -19,6 +24,9 @@ import java.util.concurrent.Semaphore import static datadog.trace.api.iast.IastDetectionMode.UNLIMITED +import java.util.concurrent.atomic.AtomicIntegerArray + + @CompileDynamic class OverheadControllerTest extends DDSpecification { @@ -283,6 +291,171 @@ class OverheadControllerTest extends DDSpecification { !lastAcquired } + void "maybeSkipVulnerability returns false if ctx or type is null"() { + given: + final controller = new OverheadControllerImpl(100f, 10, true, null) + final ctx = Mock(OverheadContext) + ctx.requestMap >>> [null, [:]] + ctx.copyMap >> null + + when: "maybeSkipVulnerability returns false if ctx or type is null" + def skip1 = controller.maybeSkipVulnerability(null, VulnerabilityType.WEAK_HASH, "GET", "/path") + + then: + !skip1 + + when: "maybeSkipVulnerability returns false if type is null" + def skip2 = controller.maybeSkipVulnerability(ctx, null, "GET", "/path") + + then: + !skip2 + + when: "maybeSkipVulnerability returns false if ctx.requestMap is null" + def skip3 = controller.maybeSkipVulnerability(ctx, null, "GET", "/path") + + then: + !skip3 + + when: "maybeSkipVulnerability returns false if ctx.requestMap is empty" + def skip4 = controller.maybeSkipVulnerability(ctx, VulnerabilityType.WEAK_HASH, "GET", "/path") + + then: + !skip4 + } + + void "maybeSkipVulnerability returns true when global count is higher"() { + given: + final ctx = new OverheadContext(Config.get().getIastVulnerabilitiesPerRequest()) + final controller = new OverheadControllerImpl(100f, 10, true, null) + // Simulate that in a previous request, GLOBAL has counted 3 SQL_INJECTION for "GET /bar" + def array = new AtomicIntegerArray(VulnerabilityTypes.STRINGS.length) + array.set(VulnerabilityType.SQL_INJECTION.type(), 3) + OverheadContext.globalMap.put("GET /bar", array) + + when: + // First occurrence in this request: counter=1, storedCounter=3 ⇒ skip + boolean skipFirst = controller.maybeSkipVulnerability(ctx, VulnerabilityType.SQL_INJECTION, "GET", "/bar") + // Second occurrence: requestMap now equals 1 internally, storedCounter=3 still ⇒ skip + boolean skipSecond = controller.maybeSkipVulnerability(ctx, VulnerabilityType.SQL_INJECTION, "GET", "/bar") + // Simulate calling a third time: counter in requestMap incremented to 2 ⇒ still 2 < 3 ⇒ skip + boolean skipThird = controller.maybeSkipVulnerability(ctx, VulnerabilityType.SQL_INJECTION, "GET", "/bar") + // Fourth time: counter=3, storedCounter=3 ⇒ not skip + boolean skipFourth = controller.maybeSkipVulnerability(ctx, VulnerabilityType.SQL_INJECTION, "GET", "/bar") + + then: + skipFirst + skipSecond + skipThird + !skipFourth + + when: + boolean skipPost = controller.maybeSkipVulnerability(ctx, VulnerabilityType.SQL_INJECTION, "POST", "/bar") + + then: + !skipPost + + when: + boolean skipAnotherEndpoint = controller.maybeSkipVulnerability(ctx, VulnerabilityType.SQL_INJECTION, "GET", "/bar2") + + then: + !skipAnotherEndpoint + } + + void "consumeQuota: globalContext path always calls operation.consumeQuota"() { + given: "An Operation stub that always returns true for both hasQuota and consumeQuota" + final controller = new OverheadControllerImpl(100f, 10, true, null) + def dummyOp = Stub(Operation) { + hasQuota(_ as OverheadContext) >> false // hasQuota won’t matter for global path + consumeQuota(_ as OverheadContext) >> true + } + + expect: + // Because controller was built with useGlobalAsFallback=true, passing null span yields globalContext + controller.consumeQuota(dummyOp, null, VulnerabilityType.WEAK_HASH) + } + + void "consumeQuota: when IAST context present and hasQuota false returns false"() { + given: + // Build a fake span + requestContext + IAST context setup + OverheadContext localCtx = new OverheadContext(10, false) + def iastCtx = new IastRequestContext(TaintedObjects.build(TaintedMap.build(TaintedMap.DEFAULT_CAPACITY)), localCtx) + final controller = new OverheadControllerImpl(100f, 10, true, null) + + RequestContext rc = Stub(RequestContext) { + getData(RequestContextSlot.IAST) >> iastCtx + } + AgentSpan span = Stub(AgentSpan) + span.getRequestContext() >> rc + span.getLocalRootSpan() >> span // return itself for tag lookups + span.getTag(Tags.HTTP_METHOD) >> "POST" + span.getTag(Tags.HTTP_ROUTE) >> "/do" + + def op = Stub(Operation) { + hasQuota(localCtx) >> false // even though context exists, hasQuota = false + consumeQuota(localCtx) >> true + } + + expect: + !controller.consumeQuota(op, span, VulnerabilityType.WEAK_CIPHER) + } + + void "consumeQuota: when hasQuota true but maybeSkipVulnerability returns true => false"() { + given: + // Prepare local context and global count so that skip logic triggers immediately + OverheadContext localCtx = new OverheadContext(10, false) + def array = new AtomicIntegerArray(VulnerabilityTypes.STRINGS.length) + array.set(VulnerabilityType.WEAK_CIPHER.type(), 2) + OverheadContext.globalMap.put("PUT /skipme", array) + def iastCtx = new IastRequestContext(TaintedObjects.build(TaintedMap.build(TaintedMap.DEFAULT_CAPACITY)), localCtx) + final controller = new OverheadControllerImpl(100f, 10, true, null) + + RequestContext rc = Stub(RequestContext) { + getData(RequestContextSlot.IAST) >> iastCtx + } + AgentSpan span = Stub(AgentSpan) + span.getRequestContext() >> rc + span.getLocalRootSpan() >> span // return the stub itself + span.getTag(Tags.HTTP_METHOD) >> "PUT" + span.getTag(Tags.HTTP_ROUTE) >> "/skipme" + + def op = Stub(Operation) { + hasQuota(localCtx) >> true + consumeQuota(localCtx) >> true + } + + expect: + // First call: maybeSkip sees global=2, request counter=1 ⇒ skip → consumeQuota not called + !controller.consumeQuota(op, span, VulnerabilityType.WEAK_CIPHER) + } + + void "consumeQuota: when hasQuota true and skip=false, calls consume and returns true"() { + given: + OverheadContext localCtx = new OverheadContext(10, false) + def iastCtx = new IastRequestContext(TaintedObjects.build(TaintedMap.build(TaintedMap.DEFAULT_CAPACITY)), localCtx) + final controller = new OverheadControllerImpl(100f, 10, true, null) + + RequestContext rc = Stub(RequestContext) { + getData(RequestContextSlot.IAST) >> iastCtx + } + AgentSpan span = Stub(AgentSpan) + span.getRequestContext() >> rc + span.getLocalRootSpan() >> span // return the stub itself + span.getTag(Tags.HTTP_METHOD) >> "PATCH" + span.getTag(Tags.HTTP_ROUTE) >> "/allow" + + // No globalMap entry for "PATCH /allow", so skip=false on first invocation + def op = Stub(Operation) { + hasQuota(localCtx) >> true + consumeQuota(localCtx) >> { OverheadContext ctx -> + // As soon as consumeQuota is called, record it by incrementing a counter + return true + } + } + + expect: + controller.consumeQuota(op, span, VulnerabilityType.WEAK_CIPHER) + } + private AgentSpan getAgentSpanWithOverheadContext() { def iastRequestContext = Stub(IastRequestContext) iastRequestContext.getOverheadContext() >> new OverheadContext(Config.get().getIastVulnerabilitiesPerRequest()) diff --git a/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/sink/HstsMissingHeaderModuleTest.groovy b/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/sink/HstsMissingHeaderModuleTest.groovy index bc5f5ef305a..e86040d9cc2 100644 --- a/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/sink/HstsMissingHeaderModuleTest.groovy +++ b/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/sink/HstsMissingHeaderModuleTest.groovy @@ -36,7 +36,7 @@ class HstsMissingHeaderModuleTest extends IastModuleImplTestBase { protected OverheadController buildOverheadController() { return Mock(OverheadController) { acquireRequest() >> true - consumeQuota(_ as Operation, _ as AgentSpan) >> true + consumeQuota(_ as Operation, _ as AgentSpan, _ as VulnerabilityType) >> true } } diff --git a/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/sink/HttpResponseHeaderModuleTest.groovy b/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/sink/HttpResponseHeaderModuleTest.groovy index c1a35d10c65..d530fefa6d3 100644 --- a/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/sink/HttpResponseHeaderModuleTest.groovy +++ b/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/sink/HttpResponseHeaderModuleTest.groovy @@ -85,7 +85,7 @@ class HttpResponseHeaderModuleTest extends IastModuleImplTestBase { module.onHeader(header, value) then: - overheadController.consumeQuota(Operations.REPORT_VULNERABILITY, span) >> false // do not report in this test + overheadController.consumeQuota(Operations.REPORT_VULNERABILITY, span, _ as VulnerabilityType) >> false // do not report in this test activeSpanCount * tracer.activeSpan() >> { return span } diff --git a/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/sink/WeakRandomnessModuleTest.groovy b/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/sink/WeakRandomnessModuleTest.groovy index 03125f3f996..e1f9806a046 100644 --- a/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/sink/WeakRandomnessModuleTest.groovy +++ b/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/sink/WeakRandomnessModuleTest.groovy @@ -2,6 +2,7 @@ package com.datadog.iast.sink import com.datadog.iast.IastModuleImplTestBase import com.datadog.iast.Reporter +import com.datadog.iast.model.VulnerabilityType import com.datadog.iast.overhead.Operations import datadog.trace.api.iast.sink.WeakRandomnessModule @@ -54,7 +55,7 @@ class WeakRandomnessModuleTest extends IastModuleImplTestBase { module.onWeakRandom(Random) then: - overheadController.consumeQuota(Operations.REPORT_VULNERABILITY, span) >> false + overheadController.consumeQuota(Operations.REPORT_VULNERABILITY, span, _ as VulnerabilityType) >> false 0 * _ } } diff --git a/dd-java-agent/agent-iast/src/testFixtures/groovy/com/datadog/iast/test/NoopOverheadController.groovy b/dd-java-agent/agent-iast/src/testFixtures/groovy/com/datadog/iast/test/NoopOverheadController.groovy index 32152663dc1..7535c112003 100644 --- a/dd-java-agent/agent-iast/src/testFixtures/groovy/com/datadog/iast/test/NoopOverheadController.groovy +++ b/dd-java-agent/agent-iast/src/testFixtures/groovy/com/datadog/iast/test/NoopOverheadController.groovy @@ -1,5 +1,6 @@ package com.datadog.iast.test +import com.datadog.iast.model.VulnerabilityType import com.datadog.iast.overhead.Operation import com.datadog.iast.overhead.OverheadController import com.github.javaparser.quality.Nullable @@ -28,6 +29,11 @@ class NoopOverheadController implements OverheadController { true } + @Override + boolean consumeQuota(Operation operation, @Nullable AgentSpan span, @Nullable VulnerabilityType type) { + true + } + @Override void reset() { } diff --git a/dd-smoke-tests/iast-util/src/main/java/datadog/smoketest/springboot/controller/IastSamplingController.java b/dd-smoke-tests/iast-util/src/main/java/datadog/smoketest/springboot/controller/IastSamplingController.java new file mode 100644 index 00000000000..dcbb3f0070a --- /dev/null +++ b/dd-smoke-tests/iast-util/src/main/java/datadog/smoketest/springboot/controller/IastSamplingController.java @@ -0,0 +1,127 @@ +package datadog.smoketest.springboot.controller; + +import java.io.IOException; +import java.io.ObjectInputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class IastSamplingController { + + @GetMapping("/multiple_vulns/{i}") + public String multipleVulns( + @PathVariable("i") int i, + @RequestParam(name = "param", required = false) String paramValue, + HttpServletRequest request, + HttpServletResponse response) + throws NoSuchAlgorithmException { + // weak hash + MessageDigest.getInstance("SHA1").digest("hash1".getBytes(StandardCharsets.UTF_8)); + // Insecure cookie + Cookie cookie = new Cookie("user-id", "7"); + response.addCookie(cookie); + // weak hash + MessageDigest.getInstance("SHA-1").digest("hash2".getBytes(StandardCharsets.UTF_8)); + // untrusted deserialization + try { + final ObjectInputStream ois = new ObjectInputStream(request.getInputStream()); + ois.close(); + } catch (IOException e) { + // Ignore IOException + } + // weak hash + MessageDigest.getInstance("MD2").digest("hash3".getBytes(StandardCharsets.UTF_8)); + return "OK"; + } + + @GetMapping("/multiple_vulns-2/{i}") + public String multipleVulns2( + @PathVariable("i") int i, + @RequestParam(name = "param", required = false) String paramValue, + HttpServletRequest request, + HttpServletResponse response) + throws NoSuchAlgorithmException { + // weak hash + MessageDigest.getInstance("SHA1").digest("hash1".getBytes(StandardCharsets.UTF_8)); + // Insecure cookie + Cookie cookie = new Cookie("user-id", "7"); + response.addCookie(cookie); + // weak hash + MessageDigest.getInstance("SHA-1").digest("hash2".getBytes(StandardCharsets.UTF_8)); + // untrusted deserialization + try { + final ObjectInputStream ois = new ObjectInputStream(request.getInputStream()); + ois.close(); + } catch (IOException e) { + // Ignore IOException + } + // weak hash + MessageDigest.getInstance("MD2").digest("hash3".getBytes(StandardCharsets.UTF_8)); + return "OK"; + } + + @PostMapping("/multiple_vulns/{i}") + public String multipleVulnsPost( + @PathVariable("i") int i, + @RequestParam(name = "param", required = false) String paramValue, + HttpServletRequest request, + HttpServletResponse response) + throws NoSuchAlgorithmException { + // weak hash + MessageDigest.getInstance("SHA1").digest("hash1".getBytes(StandardCharsets.UTF_8)); + // Insecure cookie + Cookie cookie = new Cookie("user-id", "7"); + response.addCookie(cookie); + // weak hash + MessageDigest.getInstance("SHA-1").digest("hash2".getBytes(StandardCharsets.UTF_8)); + // untrusted deserialization + try { + final ObjectInputStream ois = new ObjectInputStream(request.getInputStream()); + ois.close(); + } catch (IOException e) { + // Ignore IOException + } + // weak hash + MessageDigest.getInstance("MD2").digest("hash3".getBytes(StandardCharsets.UTF_8)); + return "OK"; + } + + @GetMapping("/different_vulns/{i}") + public String differentVulns( + @PathVariable("i") int i, HttpServletRequest request, HttpServletResponse response) + throws NoSuchAlgorithmException { + if (i == 1) { + // weak hash + MessageDigest.getInstance("SHA1").digest("hash1".getBytes(StandardCharsets.UTF_8)); + // Insecure cookie + Cookie cookie = new Cookie("user-id", "7"); + response.addCookie(cookie); + // weak hash + MessageDigest.getInstance("SHA-1").digest("hash2".getBytes(StandardCharsets.UTF_8)); + // untrusted deserialization + try { + final ObjectInputStream ois = new ObjectInputStream(request.getInputStream()); + ois.close(); + } catch (IOException e) { + // Ignore IOException + } + } else if (i == 2) { + // weak hash + MessageDigest.getInstance("MD2").digest("hash3".getBytes(StandardCharsets.UTF_8)); + // weak hash + MessageDigest.getInstance("MD5").digest("hash3".getBytes(StandardCharsets.UTF_8)); + // weak hash + MessageDigest.getInstance("RIPEMD128").digest("hash3".getBytes(StandardCharsets.UTF_8)); + } + return "OK"; + } +} diff --git a/dd-smoke-tests/springboot/src/test/groovy/datadog/smoketest/IastOverheadControlSpringBootSmokeTest.groovy b/dd-smoke-tests/springboot/src/test/groovy/datadog/smoketest/IastOverheadControlSpringBootSmokeTest.groovy new file mode 100644 index 00000000000..d33f2712b05 --- /dev/null +++ b/dd-smoke-tests/springboot/src/test/groovy/datadog/smoketest/IastOverheadControlSpringBootSmokeTest.groovy @@ -0,0 +1,135 @@ +package datadog.smoketest + +import static datadog.trace.api.config.IastConfig.IAST_DEBUG_ENABLED +import static datadog.trace.api.config.IastConfig.IAST_DETECTION_MODE +import static datadog.trace.api.config.IastConfig.IAST_ENABLED +import static datadog.trace.api.config.IastConfig.IAST_REQUEST_SAMPLING +import groovy.transform.CompileDynamic +import okhttp3.FormBody +import okhttp3.Request + +@CompileDynamic +class IastOverheadControlSpringBootSmokeTest extends AbstractIastServerSmokeTest { + + @Override + ProcessBuilder createProcessBuilder() { + String springBootShadowJar = System.getProperty('datadog.smoketest.springboot.shadowJar.path') + + List command = [] + command.add(javaPath()) + command.addAll(defaultJavaProperties) + command.addAll(iastJvmOpts()) + command.addAll((String[]) ['-jar', springBootShadowJar, "--server.port=${httpPort}"]) + ProcessBuilder processBuilder = new ProcessBuilder(command) + processBuilder.directory(new File(buildDirectory)) + // Spring will print all environment variables to the log, which may pollute it and affect log assertions. + processBuilder.environment().clear() + return processBuilder + } + + protected List iastJvmOpts() { + return [ + withSystemProperty(IAST_ENABLED, true), + withSystemProperty(IAST_DETECTION_MODE, 'DEFAULT'), + withSystemProperty(IAST_DEBUG_ENABLED, true), + withSystemProperty(IAST_REQUEST_SAMPLING, 100), + ] + } + + void 'Test that all the vulnerabilities are detected'() { + given: + // prepare a list of exactly three GET requests with path and query param + def requests = [] + for (int i = 1; i <= 3; i++) { + requests.add(new Request.Builder() + .url("http://localhost:${httpPort}/multiple_vulns/${i}/?param=value${i}") + .get() + .build()) + requests.add(new Request.Builder() + .url("http://localhost:${httpPort}/multiple_vulns-2/${i}/?param=value${i}") + .get() + .build()) + requests.add(new Request.Builder() + .url("http://localhost:${httpPort}/multiple_vulns/${i}") + .post(new FormBody.Builder().add('param', "value${i}").build()) + .build()) + } + + + when: + requests.each { req -> + client.newCall(req as Request).execute() + } + + then: 'check first get mapping' + hasVulnerability { vul -> vul.type == 'WEAK_HASH' && vul.location.method == 'multipleVulns' && vul.evidence.value == 'SHA1' } + hasVulnerability { vul -> vul.type == 'NO_SAMESITE_COOKIE' && vul.location.method == 'multipleVulns'} + hasVulnerability { vul -> vul.type == 'NO_HTTPONLY_COOKIE' && vul.location.method == 'multipleVulns' } + hasVulnerability { vul -> vul.type == 'INSECURE_COOKIE' && vul.location.method == 'multipleVulns'} + hasVulnerability { vul -> vul.type == 'WEAK_HASH' && vul.location.method == 'multipleVulns' && vul.evidence.value == 'SHA-1' } + hasVulnerability { vul -> vul.type == 'UNTRUSTED_DESERIALIZATION' && vul.location.method == 'multipleVulns'} + hasVulnerability { vul -> vul.type == 'WEAK_HASH' && vul.location.method == 'multipleVulns' && vul.evidence.value == 'MD2'} + + and: 'check second get mapping' + hasVulnerability { vul -> vul.type == 'WEAK_HASH' && vul.location.method == 'multipleVulns2' && vul.evidence.value == 'SHA1' } + hasVulnerability { vul -> vul.type == 'NO_SAMESITE_COOKIE' && vul.location.method == 'multipleVulns2'} + hasVulnerability { vul -> vul.type == 'NO_HTTPONLY_COOKIE' && vul.location.method == 'multipleVulns2' } + hasVulnerability { vul -> vul.type == 'INSECURE_COOKIE' && vul.location.method == 'multipleVulns2'} + hasVulnerability { vul -> vul.type == 'WEAK_HASH' && vul.location.method == 'multipleVulns2' && vul.evidence.value == 'SHA-1' } + hasVulnerability { vul -> vul.type == 'UNTRUSTED_DESERIALIZATION' && vul.location.method == 'multipleVulns2'} + hasVulnerability { vul -> vul.type == 'WEAK_HASH' && vul.location.method == 'multipleVulns2' && vul.evidence.value == 'MD2'} + + and: 'check post mapping' + hasVulnerability { vul -> vul.type == 'WEAK_HASH' && vul.location.method == 'multipleVulnsPost' && vul.evidence.value == 'SHA1' } + hasVulnerability { vul -> vul.type == 'NO_SAMESITE_COOKIE' && vul.location.method == 'multipleVulnsPost'} + hasVulnerability { vul -> vul.type == 'NO_HTTPONLY_COOKIE' && vul.location.method == 'multipleVulnsPost'} + hasVulnerability { vul -> vul.type == 'INSECURE_COOKIE' && vul.location.method == 'multipleVulnsPost'} + hasVulnerability { vul -> vul.type == 'WEAK_HASH' && vul.location.method == 'multipleVulnsPost' && vul.evidence.value == 'SHA-1' } + hasVulnerability { vul -> vul.type == 'UNTRUSTED_DESERIALIZATION' && vul.location.method == 'multipleVulnsPost'} + hasVulnerability { vul -> vul.type == 'WEAK_HASH' && vul.location.method == 'multipleVulnsPost'&& vul.evidence.value == 'MD2'} + } + + /** This test validates whether the algorithm can detect all vulnerabilities in an endpoint when different requests trigger different vulns due to input variation. + * There’s a known issue: the current reset logic for the global map is insufficient — not consuming the quota isn’t always a valid condition to clear it. + * While with enough traffic (and varied request order), most vulns will eventually be explored, in the worst case the algorithm degrades to the original behavior, where vulns beyond the quota remain undetected. + */ + void 'test different vulns in the same endpoint'() { + given: + // prepare a list of exactly three GET requests with path and query param + def requests = [] + for (int i = 1; i <= 3; i++) { + requests.add(new Request.Builder() + .url("http://localhost:${httpPort}/different_vulns/1") + .get() + .build()) + requests.add(new Request.Builder() + .url("http://localhost:${httpPort}/different_vulns/2") + .get() + .build()) + //Request without vulns + requests.add(new Request.Builder() + .url("http://localhost:${httpPort}/different_vulns/3") + .get() + .build()) + } + + when: + requests.each { req -> + client.newCall(req as Request).execute() + } + + then: + hasVulnerability { vul -> vul.type == 'WEAK_HASH' && vul.location.method == 'differentVulns' && vul.evidence.value == 'SHA1' } + hasVulnerability { vul -> vul.type == 'NO_SAMESITE_COOKIE' && vul.location.method == 'differentVulns'} + hasVulnerability { vul -> vul.type == 'NO_HTTPONLY_COOKIE' && vul.location.method == 'differentVulns' } + hasVulnerability { vul -> vul.type == 'INSECURE_COOKIE' && vul.location.method == 'differentVulns'} + hasVulnerability { vul -> vul.type == 'UNTRUSTED_DESERIALIZATION' && vul.location.method == 'differentVulns'} + hasVulnerability { vul -> vul.type == 'WEAK_HASH' && vul.location.method == 'differentVulns' && vul.evidence.value == 'MD5'} + hasVulnerability { vul -> vul.type == 'WEAK_HASH' && vul.location.method == 'differentVulns' && vul.evidence.value == 'RIPEMD128'} + + //TODO the current algorithm is not able to detect all the vulnerabilities in the same endpoint if those vulnerabilities are not present in all requests. We need to improve it. + //hasVulnerability { vul -> vul.type == 'WEAK_HASH' && vul.location.method == 'differentVulns' && vul.evidence.value == 'MD2'} + //hasVulnerability { vul -> vul.type == 'WEAK_HASH' && vul.location.method == 'differentVulns' && vul.evidence.value == 'SHA-1' } + } + +}