diff --git a/dd-java-agent/appsec/build.gradle b/dd-java-agent/appsec/build.gradle index aca7f3f309c..19d0eb2b479 100644 --- a/dd-java-agent/appsec/build.gradle +++ b/dd-java-agent/appsec/build.gradle @@ -15,7 +15,7 @@ dependencies { implementation project(':internal-api') implementation project(':communication') implementation project(':telemetry') - implementation group: 'io.sqreen', name: 'libsqreen', version: '15.0.1' + implementation group: 'io.sqreen', name: 'libsqreen', version: '16.0.0' implementation libs.moshi testImplementation libs.bytebuddy diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/config/AppSecConfigServiceImpl.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/config/AppSecConfigServiceImpl.java index 49d67e37211..d76096deeca 100644 --- a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/config/AppSecConfigServiceImpl.java +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/config/AppSecConfigServiceImpl.java @@ -5,6 +5,7 @@ import static datadog.remoteconfig.Capabilities.CAPABILITY_ASM_AUTO_USER_INSTRUM_MODE; import static datadog.remoteconfig.Capabilities.CAPABILITY_ASM_CUSTOM_BLOCKING_RESPONSE; import static datadog.remoteconfig.Capabilities.CAPABILITY_ASM_CUSTOM_RULES; +import static datadog.remoteconfig.Capabilities.CAPABILITY_ASM_DD_MULTICONFIG; import static datadog.remoteconfig.Capabilities.CAPABILITY_ASM_DD_RULES; import static datadog.remoteconfig.Capabilities.CAPABILITY_ASM_EXCLUSIONS; import static datadog.remoteconfig.Capabilities.CAPABILITY_ASM_EXCLUSION_DATA; @@ -18,6 +19,7 @@ import static datadog.remoteconfig.Capabilities.CAPABILITY_ASM_RASP_SSRF; import static datadog.remoteconfig.Capabilities.CAPABILITY_ASM_REQUEST_BLOCKING; import static datadog.remoteconfig.Capabilities.CAPABILITY_ASM_SESSION_FINGERPRINT; +import static datadog.remoteconfig.Capabilities.CAPABILITY_ASM_TRACE_TAGGING_RULES; import static datadog.remoteconfig.Capabilities.CAPABILITY_ASM_TRUSTED_IPS; import static datadog.remoteconfig.Capabilities.CAPABILITY_ASM_USER_BLOCKING; import static datadog.remoteconfig.Capabilities.CAPABILITY_ENDPOINT_FINGERPRINT; @@ -37,8 +39,9 @@ import com.datadog.ddwaf.exception.InvalidRuleSetException; import com.datadog.ddwaf.exception.UnclassifiedWafException; import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.JsonReader; +import com.squareup.moshi.JsonWriter; import com.squareup.moshi.Moshi; -import com.squareup.moshi.Types; import datadog.remoteconfig.ConfigurationEndListener; import datadog.remoteconfig.ConfigurationPoller; import datadog.remoteconfig.PollingRateHinter; @@ -65,6 +68,7 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; +import javax.annotation.Nullable; import okio.Okio; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -92,10 +96,25 @@ public class AppSecConfigServiceImpl implements AppSecConfigService { new WAFInitializationResultReporter(); private final WAFStatsReporter statsReporter = new WAFStatsReporter(); - private static final JsonAdapter> ADAPTER = + private static final JsonAdapter ADAPTER = new Moshi.Builder() + .add( + Double.class, + new JsonAdapter() { + @Override + public Number fromJson(JsonReader reader) throws IOException { + double value = reader.nextDouble(); + long longValue = (long) value; + return value % 1 == 0 ? longValue : value; + } + + @Override + public void toJson(JsonWriter writer, @Nullable Number value) throws IOException { + throw new UnsupportedOperationException(); + } + }) .build() - .adapter(Types.newParameterizedType(Map.class, String.class, Object.class)); + .adapter(Object.class); private boolean hasUserWafConfig; private boolean defaultConfigActivated; @@ -104,7 +123,7 @@ public class AppSecConfigServiceImpl implements AppSecConfigService { Collections.newSetFromMap(new ConcurrentHashMap<>()); private final Set ignoredConfigKeys = Collections.newSetFromMap(new ConcurrentHashMap<>()); - private final String DEFAULT_WAF_CONFIG_RULE = "DEFAULT_WAF_CONFIG"; + private final String DEFAULT_WAF_CONFIG_RULE = "ASM_DD/default"; private String currentRuleVersion; private List modulesToUpdateVersionIn; @@ -137,6 +156,7 @@ private void subscribeConfigurationPoller() { private long getRulesAndDataCapabilities() { long capabilities = CAPABILITY_ASM_DD_RULES + | CAPABILITY_ASM_DD_MULTICONFIG | CAPABILITY_ASM_IP_BLOCKING | CAPABILITY_ASM_EXCLUSIONS | CAPABILITY_ASM_EXCLUSION_DATA @@ -148,7 +168,8 @@ private long getRulesAndDataCapabilities() { | CAPABILITY_ENDPOINT_FINGERPRINT | CAPABILITY_ASM_SESSION_FINGERPRINT | CAPABILITY_ASM_NETWORK_FINGERPRINT - | CAPABILITY_ASM_HEADER_FINGERPRINT; + | CAPABILITY_ASM_HEADER_FINGERPRINT + | CAPABILITY_ASM_TRACE_TAGGING_RULES; if (tracerConfig.isAppSecRaspEnabled()) { capabilities |= CAPABILITY_ASM_RASP_SQLI; capabilities |= CAPABILITY_ASM_RASP_SSRF; @@ -210,7 +231,8 @@ public void accept(ConfigKey configKey, byte[] content, PollingRateHinter pollin } final String key = configKey.toString(); Map contentMap = - ADAPTER.fromJson(Okio.buffer(Okio.source(new ByteArrayInputStream(content)))); + (Map) + ADAPTER.fromJson(Okio.buffer(Okio.source(new ByteArrayInputStream(content)))); if (contentMap == null || contentMap.isEmpty()) { ignoredConfigKeys.add(key); } else { @@ -255,7 +277,7 @@ private class AppSecConfigChangesDDListener extends AppSecConfigChangesListener @Override protected void beforeApply(final String key, final Map config) { if (defaultConfigActivated) { // if we get any config, remove the default one - log.debug("Removing default config"); + log.debug("Removing default config ASM_DD/default"); try { wafBuilder.removeConfig(DEFAULT_WAF_CONFIG_RULE); } catch (UnclassifiedWafException e) { @@ -466,7 +488,8 @@ private static Map loadDefaultWafConfig() throws IOException { throw new IOException("Resource " + DEFAULT_CONFIG_LOCATION + " not found"); } - Map ret = ADAPTER.fromJson(Okio.buffer(Okio.source(is))); + Map ret = + (Map) ADAPTER.fromJson(Okio.buffer(Okio.source(is))); StandardizedLogging._initialConfigSourceAndLibddwafVersion(log, ""); if (log.isInfoEnabled()) { @@ -483,7 +506,8 @@ private static Map loadUserWafConfig(Config tracerConfig) throws return null; } try (InputStream is = new FileInputStream(filename)) { - Map ret = ADAPTER.fromJson(Okio.buffer(Okio.source(is))); + Map ret = + (Map) ADAPTER.fromJson(Okio.buffer(Okio.source(is))); StandardizedLogging._initialConfigSourceAndLibddwafVersion(log, filename); if (log.isInfoEnabled()) { @@ -512,6 +536,7 @@ public void close() { this.configurationPoller.removeCapabilities( CAPABILITY_ASM_ACTIVATION | CAPABILITY_ASM_DD_RULES + | CAPABILITY_ASM_DD_MULTICONFIG | CAPABILITY_ASM_IP_BLOCKING | CAPABILITY_ASM_EXCLUSIONS | CAPABILITY_ASM_EXCLUSION_DATA @@ -529,7 +554,8 @@ public void close() { | CAPABILITY_ENDPOINT_FINGERPRINT | CAPABILITY_ASM_SESSION_FINGERPRINT | CAPABILITY_ASM_NETWORK_FINGERPRINT - | CAPABILITY_ASM_HEADER_FINGERPRINT); + | CAPABILITY_ASM_HEADER_FINGERPRINT + | CAPABILITY_ASM_TRACE_TAGGING_RULES); this.configurationPoller.removeListeners(Product.ASM_DD); this.configurationPoller.removeListeners(Product.ASM_DATA); this.configurationPoller.removeListeners(Product.ASM); diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/ddwaf/WAFModule.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/ddwaf/WAFModule.java index 1aef1c5f964..e1301248e67 100644 --- a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/ddwaf/WAFModule.java +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/ddwaf/WAFModule.java @@ -34,6 +34,7 @@ import datadog.trace.api.ProductActivation; import datadog.trace.api.ProductTraceSource; import datadog.trace.api.gateway.Flow; +import datadog.trace.api.sampling.PrioritySampling; import datadog.trace.api.telemetry.LogCollector; import datadog.trace.api.telemetry.WafMetricCollector; import datadog.trace.api.time.SystemTimeSource; @@ -402,12 +403,13 @@ public void onDataAvailable( } } Collection events = buildEvents(resultWithData); + boolean isThrottled = reqCtx.isThrottled(rateLimiter); - if (!events.isEmpty()) { - if (!reqCtx.isThrottled(rateLimiter)) { + if (resultWithData.keep) { + if (!isThrottled) { AgentSpan activeSpan = AgentTracer.get().activeSpan(); if (activeSpan != null) { - log.debug("Setting force-keep tag on the current span"); + log.debug("Setting force-keep tag and manual keep tag on the current span"); // Keep event related span, because it could be ignored in case of // reduced datadog sampling rate. activeSpan.getLocalRootSpan().setTag(Tags.ASM_KEEP, true); @@ -418,11 +420,9 @@ public void onDataAvailable( .getLocalRootSpan() .setTag(Tags.PROPAGATED_TRACE_SOURCE, ProductTraceSource.ASM); } else { - // If active span is not available the ASM_KEEP tag will be set in the GatewayBridge - // when the request ends + // If active span is not available then we need to set manual keep in GatewayBridge log.debug("There is no active span available"); } - reqCtx.reportEvents(events); } else { log.debug("Rate limited WAF events"); if (!gwCtx.isRasp) { @@ -430,6 +430,9 @@ public void onDataAvailable( } } } + if (resultWithData.events && !events.isEmpty() && !isThrottled) { + reqCtx.reportEvents(events); + } if (flow.isBlocking()) { if (!gwCtx.isRasp) { @@ -438,8 +441,11 @@ public void onDataAvailable( } } - if (resultWithData.derivatives != null) { - reqCtx.reportDerivatives(resultWithData.derivatives); + reqCtx.setKeepType( + resultWithData.keep ? PrioritySampling.USER_KEEP : PrioritySampling.USER_DROP); + + if (resultWithData.attributes != null && !resultWithData.attributes.isEmpty()) { + reqCtx.reportDerivatives(resultWithData.attributes); } } @@ -564,6 +570,10 @@ private Collection buildEvents(Waf.ResultWithData actionWithData) { } Collection listResults; try { + if (actionWithData.data == null || actionWithData.data.isEmpty()) { + log.debug("WAF returned no data"); + return emptyList(); + } listResults = RES_JSON_ADAPTER.fromJson(actionWithData.data); } catch (IOException e) { throw new UndeclaredThrowableException(e); diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/event/data/ObjectIntrospection.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/event/data/ObjectIntrospection.java index e097e313898..c415399e97a 100644 --- a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/event/data/ObjectIntrospection.java +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/event/data/ObjectIntrospection.java @@ -16,6 +16,7 @@ import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.Collection; +import java.util.Date; import java.util.HashMap; import java.util.Iterator; import java.util.List; @@ -184,6 +185,11 @@ private static Object doConversion(Object obj, int depth, State state) { return obj.toString(); } + // Date objects - avoid accessing private fastTime field + if (obj instanceof Date) { + return ((Date) obj).getTime(); + } + // Jackson databind nodes (via reflection) Class clazz = obj.getClass(); if (clazz.getName().startsWith("com.fasterxml.jackson.databind.node.")) { diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/AppSecRequestContext.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/AppSecRequestContext.java index 376b0448591..904d60318ff 100644 --- a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/AppSecRequestContext.java +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/AppSecRequestContext.java @@ -13,9 +13,11 @@ import datadog.trace.api.Config; import datadog.trace.api.http.StoredBodySupplier; import datadog.trace.api.internal.TraceSegment; +import datadog.trace.api.sampling.PrioritySampling; import datadog.trace.util.stacktrace.StackTraceEvent; import java.io.Closeable; import java.util.*; +import java.util.Locale; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.atomic.AtomicBoolean; @@ -108,7 +110,7 @@ public class AppSecRequestContext implements DataBundle, Closeable { private boolean responseBodyPublished; private boolean respDataPublished; private boolean pathParamsPublished; - private volatile Map derivatives; + private volatile Map derivatives; private final AtomicBoolean rateLimited = new AtomicBoolean(false); private volatile boolean throttled; @@ -145,6 +147,7 @@ public class AppSecRequestContext implements DataBundle, Closeable { private volatile boolean keepOpenForApiSecurityPostProcessing; private volatile Long apiSecurityEndpointHash; + private volatile byte keepType = PrioritySampling.SAMPLER_KEEP; private static final AtomicIntegerFieldUpdater WAF_TIMEOUTS_UPDATER = AtomicIntegerFieldUpdater.newUpdater(AppSecRequestContext.class, "wafTimeouts"); @@ -360,6 +363,14 @@ public Long getApiSecurityEndpointHash() { return this.apiSecurityEndpointHash; } + public void setKeepType(byte keepType) { + this.keepType = keepType; + } + + public byte getKeepType() { + return this.keepType; + } + void addRequestHeader(String name, String value) { if (finishedRequestHeaders) { throw new IllegalStateException("Request headers were said to be finished before"); @@ -645,24 +656,258 @@ List getStackTraces() { return stackTraces; } - public void reportDerivatives(Map data) { + /** + * Attempts to parse a string value as a number. Returns the parsed number if successful, null + * otherwise. Tries to parse as integer first, then as double if it contains a decimal point. + */ + private static Number convertToNumericAttribute(String value) { + if (value == null || value.isEmpty()) { + return null; + } + try { + // Check if it contains a decimal point to determine if it's a double + if (value.contains(".")) { + return Double.parseDouble(value); + } else { + // Try to parse as integer first + return Long.parseLong(value); + } + } catch (NumberFormatException e) { + return null; + } + } + + public void reportDerivatives(Map data) { log.debug("Reporting derivatives: {}", data); if (data == null || data.isEmpty()) return; + // Store raw derivatives if (derivatives == null) { - derivatives = data; - } else { - derivatives.putAll(data); + derivatives = new HashMap<>(); + } + + // Process each attribute according to the specification + for (Map.Entry entry : data.entrySet()) { + String attributeKey = entry.getKey(); + Object attributeConfig = entry.getValue(); + + if (attributeConfig instanceof Map) { + @SuppressWarnings("unchecked") + Map config = (Map) attributeConfig; + + // Check if it's a literal value schema + if (config.containsKey("value")) { + Object literalValue = config.get("value"); + if (literalValue != null) { + // Preserve the original type - don't convert to string + derivatives.put(attributeKey, literalValue); + log.debug( + "Added literal attribute: {} = {} (type: {})", + attributeKey, + literalValue, + literalValue.getClass().getSimpleName()); + } + } + // Check if it's a request data schema + else if (config.containsKey("address")) { + String address = (String) config.get("address"); + @SuppressWarnings("unchecked") + List keyPath = (List) config.get("key_path"); + @SuppressWarnings("unchecked") + List transformers = (List) config.get("transformers"); + + Object extractedValue = extractValueFromRequestData(address, keyPath, transformers); + if (extractedValue != null) { + // For extracted values, convert to string as they come from request data + derivatives.put(attributeKey, extractedValue.toString()); + log.debug("Added extracted attribute: {} = {}", attributeKey, extractedValue); + } + } + } else { + // Handle plain string/numeric values + derivatives.put(attributeKey, attributeConfig); + log.debug("Added direct attribute: {} = {}", attributeKey, attributeConfig); + } + } + } + + /** + * Extracts a value from request data based on address, key path, and transformers. + * + * @param address The address to extract from (e.g., "server.request.headers") + * @param keyPath Optional key path to navigate the data structure + * @param transformers Optional list of transformers to apply + * @return The extracted value, or null if not found + */ + private Object extractValueFromRequestData( + String address, List keyPath, List transformers) { + // Get the data from the address + Object data = getDataForAddress(address); + if (data == null) { + log.debug("No data found for address: {}", address); + return null; + } + + // Navigate through the key path + Object currentValue = data; + if (keyPath != null && !keyPath.isEmpty()) { + currentValue = navigateKeyPath(currentValue, keyPath); + if (currentValue == null) { + log.debug("Could not navigate key path {} for address {}", keyPath, address); + return null; + } + } + + // Apply transformers if specified + if (transformers != null && !transformers.isEmpty()) { + currentValue = applyTransformers(currentValue, transformers); + } + + return currentValue; + } + + /** Gets data for a specific address from the request context. */ + private Object getDataForAddress(String address) { + // Map common addresses to our data structures + switch (address) { + case "server.request.headers": + return requestHeaders; + case "server.response.headers": + return responseHeaders; + case "server.request.cookies": + return collectedCookies; + case "server.request.uri.raw": + return savedRawURI; + case "server.request.method": + return method; + case "server.request.scheme": + return scheme; + case "server.request.route": + return route; + case "server.response.status": + return responseStatus; + case "server.request.body": + return getStoredRequestBody(); + case "usr.id": + return userId; + case "usr.login": + return userLogin; + case "usr.session_id": + return sessionId; + default: + log.debug("Unknown address: {}", address); + return null; + } + } + + /** Navigates through a data structure using a key path. */ + private Object navigateKeyPath(Object data, List keyPath) { + Object current = data; + + for (String key : keyPath) { + if (current instanceof Map) { + @SuppressWarnings("unchecked") + Map map = (Map) current; + current = map.get(key); + } else if (current instanceof List) { + try { + int index = Integer.parseInt(key); + @SuppressWarnings("unchecked") + List list = (List) current; + if (index >= 0 && index < list.size()) { + current = list.get(index); + } else { + return null; + } + } catch (NumberFormatException e) { + log.debug("Invalid list index: {}", key); + return null; + } + } else { + log.debug("Cannot navigate key {} in data type: {}", key, current.getClass()); + return null; + } + + if (current == null) { + return null; + } + } + + return current; + } + + /** Applies transformers to a value. */ + private Object applyTransformers(Object value, List transformers) { + Object current = value; + + for (String transformer : transformers) { + switch (transformer) { + case "lowercase": + if (current instanceof String) { + current = ((String) current).toLowerCase(Locale.ROOT); + } + break; + case "uppercase": + if (current instanceof String) { + current = ((String) current).toUpperCase(Locale.ROOT); + } + break; + case "trim": + if (current instanceof String) { + current = ((String) current).trim(); + } + break; + case "length": + if (current instanceof String) { + current = ((String) current).length(); + } else if (current instanceof Collection) { + current = ((Collection) current).size(); + } else if (current instanceof Map) { + current = ((Map) current).size(); + } + break; + default: + log.debug("Unknown transformer: {}", transformer); + break; + } } + + return current; } public boolean commitDerivatives(TraceSegment traceSegment) { log.debug("Committing derivatives: {} for {}", derivatives, traceSegment); - if (traceSegment == null || derivatives == null) { + if (traceSegment == null) { return false; } - derivatives.forEach(traceSegment::setTagTop); - log.debug("Committed derivatives: {} for {}", derivatives, traceSegment); + + // Process and commit derivatives directly + if (derivatives != null && !derivatives.isEmpty()) { + for (Map.Entry entry : derivatives.entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + + // Handle different value types + if (value instanceof Number) { + traceSegment.setTagTop(key, (Number) value); + } else if (value instanceof String) { + // Try to parse as numeric, otherwise use as string + Number parsedNumber = convertToNumericAttribute((String) value); + if (parsedNumber != null) { + traceSegment.setTagTop(key, parsedNumber); + } else { + traceSegment.setTagTop(key, value); + } + } else if (value instanceof Boolean) { + traceSegment.setTagTop(key, value); + } else { + // Convert other types to string + traceSegment.setTagTop(key, value.toString()); + } + } + } + + // Clear all attribute maps derivatives = null; return true; } diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/GatewayBridge.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/GatewayBridge.java index ee88a09eed1..76565c0e4cb 100644 --- a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/GatewayBridge.java +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/GatewayBridge.java @@ -5,6 +5,7 @@ import static com.datadog.appsec.gateway.AppSecRequestContext.DEFAULT_REQUEST_HEADERS_ALLOW_LIST; import static com.datadog.appsec.gateway.AppSecRequestContext.REQUEST_HEADERS_ALLOW_LIST; import static com.datadog.appsec.gateway.AppSecRequestContext.RESPONSE_HEADERS_ALLOW_LIST; +import static datadog.trace.bootstrap.instrumentation.api.Tags.SAMPLING_PRIORITY; import com.datadog.appsec.AppSecSystem; import com.datadog.appsec.api.security.ApiSecuritySampler; @@ -751,6 +752,7 @@ private NoopFlow onRequestEnded(RequestContext ctx_, IGSpanInfo spanInfo) { if (!collectedEvents.isEmpty()) { // Set asm keep in case that root span was not available when events are detected traceSeg.setTagTop(Tags.ASM_KEEP, true); + traceSeg.setTagTop(SAMPLING_PRIORITY, ctx.getKeepType()); traceSeg.setTagTop(Tags.PROPAGATED_TRACE_SOURCE, ProductTraceSource.ASM); traceSeg.setTagTop("appsec.event", true); traceSeg.setTagTop("network.client.ip", ctx.getPeerAddress()); diff --git a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/config/AppSecConfigServiceImplJsonAdapterTest.groovy b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/config/AppSecConfigServiceImplJsonAdapterTest.groovy new file mode 100644 index 00000000000..8d4556378a8 --- /dev/null +++ b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/config/AppSecConfigServiceImplJsonAdapterTest.groovy @@ -0,0 +1,86 @@ +package com.datadog.appsec.config + +import datadog.trace.test.util.DDSpecification +import okio.Buffer + +class AppSecConfigServiceImplJsonAdapterTest extends DDSpecification { + + void 'test JSON number conversion - whole numbers'() { + given: + def json = '42.0' + def buffer = new Buffer().writeUtf8(json) + + when: + def result = AppSecConfigServiceImpl.ADAPTER.fromJson(buffer) + + then: + result != null + // This should trigger the true branch: value % 1 == 0 ? longValue : value + } + + void 'test JSON number conversion - fractional numbers'() { + given: + def json = '42.5' + def buffer = new Buffer().writeUtf8(json) + + when: + def result = AppSecConfigServiceImpl.ADAPTER.fromJson(buffer) + + then: + result != null + // This should trigger the false branch: value % 1 == 0 ? longValue : value + } + + void 'test JSON number conversion - mixed object'() { + given: + def json = '{"whole": 100.0, "fractional": 3.14}' + def buffer = new Buffer().writeUtf8(json) + + when: + def result = AppSecConfigServiceImpl.ADAPTER.fromJson(buffer) + + then: + result != null + result instanceof Map + // This exercises both branches through the nested numbers + } + + void 'test JSON number conversion - zero'() { + given: + def json = '0.0' + def buffer = new Buffer().writeUtf8(json) + + when: + def result = AppSecConfigServiceImpl.ADAPTER.fromJson(buffer) + + then: + result != null + // This should trigger the true branch: 0.0 % 1 == 0 + } + + void 'test JSON number conversion - negative whole'() { + given: + def json = '-10.0' + def buffer = new Buffer().writeUtf8(json) + + when: + def result = AppSecConfigServiceImpl.ADAPTER.fromJson(buffer) + + then: + result != null + // This should trigger the true branch: -10.0 % 1 == 0 + } + + void 'test JSON number conversion - negative fractional'() { + given: + def json = '-3.5' + def buffer = new Buffer().writeUtf8(json) + + when: + def result = AppSecConfigServiceImpl.ADAPTER.fromJson(buffer) + + then: + result != null + // This should trigger the false branch: -3.5 % 1 != 0 + } +} diff --git a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/config/AppSecConfigServiceImplSpecification.groovy b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/config/AppSecConfigServiceImplSpecification.groovy index 2daea19b89b..3dbfbaef23c 100644 --- a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/config/AppSecConfigServiceImplSpecification.groovy +++ b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/config/AppSecConfigServiceImplSpecification.groovy @@ -40,6 +40,8 @@ import static datadog.remoteconfig.Capabilities.CAPABILITY_ASM_SESSION_FINGERPRI import static datadog.remoteconfig.Capabilities.CAPABILITY_ASM_TRUSTED_IPS import static datadog.remoteconfig.Capabilities.CAPABILITY_ASM_USER_BLOCKING import static datadog.remoteconfig.Capabilities.CAPABILITY_ENDPOINT_FINGERPRINT +import static datadog.remoteconfig.Capabilities.CAPABILITY_ASM_DD_MULTICONFIG +import static datadog.remoteconfig.Capabilities.CAPABILITY_ASM_TRACE_TAGGING_RULES import static datadog.remoteconfig.PollingHinterNoop.NOOP import static datadog.trace.api.UserIdCollectionMode.ANONYMIZATION import static datadog.trace.api.UserIdCollectionMode.DISABLED @@ -270,6 +272,7 @@ class AppSecConfigServiceImplSpecification extends DDSpecification { 1 * poller.addListener(Product.ASM_DATA, _) 1 * poller.addListener(Product.ASM, _) 1 * poller.addCapabilities(CAPABILITY_ASM_DD_RULES + | CAPABILITY_ASM_DD_MULTICONFIG | CAPABILITY_ASM_IP_BLOCKING | CAPABILITY_ASM_EXCLUSIONS | CAPABILITY_ASM_EXCLUSION_DATA @@ -285,9 +288,21 @@ class AppSecConfigServiceImplSpecification extends DDSpecification { | CAPABILITY_ENDPOINT_FINGERPRINT | CAPABILITY_ASM_SESSION_FINGERPRINT | CAPABILITY_ASM_NETWORK_FINGERPRINT - | CAPABILITY_ASM_HEADER_FINGERPRINT) + | CAPABILITY_ASM_HEADER_FINGERPRINT + | CAPABILITY_ASM_TRACE_TAGGING_RULES) 0 * poller._ + when: + listeners.savedFeaturesListener.accept( + 'asm_features_activation', + listeners.savedFeaturesDeserializer.deserialize( + '{"asm":{"enabled": true}}'.bytes), null) + listeners.savedConfEndListener.onConfigurationEnd() + + then: + 1 * subconfigListener.onNewSubconfig(_ as String, _) + AppSecSystem.active + when: // AppSec is ACTIVE - rules trigger subscriptions listeners.savedWafDataChangesListener.accept( @@ -416,6 +431,7 @@ class AppSecConfigServiceImplSpecification extends DDSpecification { 1 * poller.addListener(Product.ASM_DATA, _) 1 * poller.addListener(Product.ASM, _) 1 * poller.addCapabilities(CAPABILITY_ASM_DD_RULES + | CAPABILITY_ASM_DD_MULTICONFIG | CAPABILITY_ASM_IP_BLOCKING | CAPABILITY_ASM_EXCLUSIONS | CAPABILITY_ASM_EXCLUSION_DATA @@ -427,7 +443,8 @@ class AppSecConfigServiceImplSpecification extends DDSpecification { | CAPABILITY_ENDPOINT_FINGERPRINT | CAPABILITY_ASM_SESSION_FINGERPRINT | CAPABILITY_ASM_NETWORK_FINGERPRINT - | CAPABILITY_ASM_HEADER_FINGERPRINT) + | CAPABILITY_ASM_HEADER_FINGERPRINT + | CAPABILITY_ASM_TRACE_TAGGING_RULES) 0 * poller._ when: @@ -504,6 +521,7 @@ class AppSecConfigServiceImplSpecification extends DDSpecification { then: 1 * poller.removeCapabilities(CAPABILITY_ASM_ACTIVATION | CAPABILITY_ASM_DD_RULES + | CAPABILITY_ASM_DD_MULTICONFIG | CAPABILITY_ASM_IP_BLOCKING | CAPABILITY_ASM_EXCLUSIONS | CAPABILITY_ASM_EXCLUSION_DATA @@ -521,7 +539,8 @@ class AppSecConfigServiceImplSpecification extends DDSpecification { | CAPABILITY_ENDPOINT_FINGERPRINT | CAPABILITY_ASM_SESSION_FINGERPRINT | CAPABILITY_ASM_NETWORK_FINGERPRINT - | CAPABILITY_ASM_HEADER_FINGERPRINT) + | CAPABILITY_ASM_HEADER_FINGERPRINT + | CAPABILITY_ASM_TRACE_TAGGING_RULES) 4 * poller.removeListeners(_) 1 * poller.removeConfigurationEndListener(_) 1 * poller.stop() diff --git a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/ddwaf/WAFModuleSpecification.groovy b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/ddwaf/WAFModuleSpecification.groovy index 37046e8f720..ab53eab959c 100644 --- a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/ddwaf/WAFModuleSpecification.groovy +++ b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/ddwaf/WAFModuleSpecification.groovy @@ -207,6 +207,7 @@ class WAFModuleSpecification extends DDSpecification { rba.statusCode == 501 && rba.blockingContentType == BlockingContentType.JSON }) + 1 * ctx.setKeepType(_) 1 * ctx.getOrCreateWafContext(_ as WafHandle, true, false) 2 * tracer.activeSpan() @@ -240,6 +241,7 @@ class WAFModuleSpecification extends DDSpecification { rba.statusCode == 403 && rba.blockingContentType == BlockingContentType.AUTO }) + 1 * ctx.setKeepType(_) 1 * ctx.getOrCreateWafContext(_ as WafHandle, true, false) >> { wafContext = new WafContext(it[0]) } @@ -284,6 +286,7 @@ class WAFModuleSpecification extends DDSpecification { 1 * ctx.isWafContextClosed() >> false 1 * ctx.closeWafContext() 1 * flow.isBlocking() + 1 * ctx.setKeepType(_) 1 * ctx.isThrottled(null) 0 * _ @@ -308,6 +311,7 @@ class WAFModuleSpecification extends DDSpecification { 1 * ctx.closeWafContext() 1 * flow.isBlocking() 1 * ctx.isThrottled(null) + 1 * ctx.setKeepType(_) 0 * _ when: 'changes the rules config' @@ -359,6 +363,7 @@ class WAFModuleSpecification extends DDSpecification { 1 * ctx.closeWafContext() 1 * flow.isBlocking() 1 * ctx.isThrottled(null) + 1 * ctx.setKeepType(_) 0 * _ when: @@ -374,6 +379,7 @@ class WAFModuleSpecification extends DDSpecification { 2 * ctx.getWafMetrics() 1 * ctx.isWafContextClosed() >> false 1 * ctx.closeWafContext() + 1 * ctx.setKeepType(_) 0 * _ } @@ -430,6 +436,7 @@ class WAFModuleSpecification extends DDSpecification { 1 * ctx.closeWafContext() 1 * ctx.setWafBlocked() 1 * ctx.isThrottled(null) + 1 * ctx.setKeepType(_) 0 * _ when: @@ -447,6 +454,7 @@ class WAFModuleSpecification extends DDSpecification { 2 * ctx.getWafMetrics() 1 * ctx.isWafContextClosed() >> false 1 * ctx.closeWafContext() + 1 * ctx.setKeepType(_) 0 * _ } @@ -505,6 +513,7 @@ class WAFModuleSpecification extends DDSpecification { 1 * ctx.isWafContextClosed() >> false 1 * ctx.closeWafContext() 1 * ctx.setWafBlocked() + 1 * ctx.setKeepType(_) 1 * ctx.isThrottled(null) 0 * _ } @@ -570,6 +579,7 @@ class WAFModuleSpecification extends DDSpecification { 2 * ctx.getWafMetrics() 1 * ctx.isWafContextClosed() >> false 1 * ctx.closeWafContext() + 1 * ctx.setKeepType(_) 1 * flow.isBlocking() 1 * ctx.isThrottled(null) 0 * _ @@ -593,6 +603,7 @@ class WAFModuleSpecification extends DDSpecification { 1 * ctx.reportEvents(_) 1 * ctx.setWafBlocked() 1 * ctx.isThrottled(null) + 1 * ctx.setKeepType(_) 0 * ctx._(*_) flow.blocking flow.action instanceof Flow.Action.RequestBlockingAction @@ -656,6 +667,7 @@ class WAFModuleSpecification extends DDSpecification { 1 * ctx.reportEvents(_) 1 * ctx.setWafBlocked() 1 * ctx.isThrottled(null) + 1 * ctx.setKeepType(_) 0 * ctx._(*_) flow.blocking flow.action.statusCode == 418 @@ -683,6 +695,7 @@ class WAFModuleSpecification extends DDSpecification { 1 * ctx.reportEvents(_) 1 * ctx.setWafBlocked() 1 * ctx.isThrottled(null) + 1 * ctx.setKeepType(_) 0 * ctx._(*_) metrics == null } @@ -734,6 +747,7 @@ class WAFModuleSpecification extends DDSpecification { 1 * ctx.setWafBlocked() 1 * ctx.isThrottled(null) 1 * ctx.isWafContextClosed() >> false + 1 * ctx.setKeepType(_) 0 * ctx._(*_) flow.blocking } @@ -995,6 +1009,7 @@ class WAFModuleSpecification extends DDSpecification { 1 * ctx.closeWafContext() 2 * tracer.activeSpan() 1 * wafMetricCollector.wafInit(Waf.LIB_VERSION, _, true) + 1 * ctx.setKeepType(_) 0 * _ } @@ -1032,6 +1047,7 @@ class WAFModuleSpecification extends DDSpecification { 1 * ctx.isWafContextClosed() >> false 1 * ctx.closeWafContext() 1 * flow.isBlocking() + 1 * ctx.setKeepType(_) 1 * ctx.isThrottled(null) 0 * _ } @@ -1098,6 +1114,7 @@ class WAFModuleSpecification extends DDSpecification { 2 * ctx.getWafMetrics() 1 * ctx.isWafContextClosed() >> false 1 * ctx.closeWafContext() + 1 * ctx.setKeepType(_) _ * ctx.increaseWafTimeouts() _ * ctx.increaseRaspTimeouts() 0 * _ @@ -1116,6 +1133,7 @@ class WAFModuleSpecification extends DDSpecification { 1 * reconf.reloadSubscriptions() 1 * ctx.closeWafContext() 2 * ctx.getWafMetrics() + 1 * ctx.setKeepType(_) _ * ctx.increaseWafTimeouts() _ * ctx.increaseRaspTimeouts() 0 * _ @@ -1137,6 +1155,7 @@ class WAFModuleSpecification extends DDSpecification { 1 * ctx.isWafContextClosed() >> false 1 * ctx.closeWafContext() 1 * flow.isBlocking() + 1 * ctx.setKeepType(_) 1 * ctx.isThrottled(null) _ * ctx.increaseWafTimeouts() _ * ctx.increaseRaspTimeouts() @@ -1155,6 +1174,7 @@ class WAFModuleSpecification extends DDSpecification { 2 * ctx.getWafMetrics() 1 * ctx.isWafContextClosed() >> false 1 * ctx.closeWafContext() + 1 * ctx.setKeepType(_) _ * ctx.increaseWafTimeouts() _ * ctx.increaseRaspTimeouts() 0 * _ @@ -1183,6 +1203,7 @@ class WAFModuleSpecification extends DDSpecification { 1 * ctx.closeWafContext() >> { wafContext.close() } + 1 * ctx.setKeepType(_) _ * ctx.increaseWafTimeouts() _ * ctx.increaseRaspTimeouts() 0 * _ @@ -1205,6 +1226,7 @@ class WAFModuleSpecification extends DDSpecification { 1 * ctx.closeWafContext() >> { wafContext.close() } + 1 * ctx.setKeepType(_) _ * ctx.increaseWafTimeouts() _ * ctx.increaseRaspTimeouts() 0 * _ @@ -1232,6 +1254,7 @@ class WAFModuleSpecification extends DDSpecification { 1 * ctx.closeWafContext() >> { wafContext.close() } + 1 * ctx.setKeepType(_) _ * ctx.increaseWafTimeouts() _ * ctx.increaseRaspTimeouts() 1 * ctx.isThrottled(null) @@ -1257,6 +1280,7 @@ class WAFModuleSpecification extends DDSpecification { 1 * ctx.closeWafContext() >> { wafContext.close() } + 1 * ctx.setKeepType(_) _ * ctx.increaseWafTimeouts() _ * ctx.increaseRaspTimeouts() 0 * _ @@ -1328,7 +1352,7 @@ class WAFModuleSpecification extends DDSpecification { void 'bad ResultWithData - empty list'() { def waf = new WAFModule() - Waf.ResultWithData rwd = new Waf.ResultWithData(null, "[]", null, null) + Waf.ResultWithData rwd = new Waf.ResultWithData(null, "[]", null, null, false, 0, false) Collection ret when: @@ -1340,7 +1364,7 @@ class WAFModuleSpecification extends DDSpecification { void 'bad ResultWithData - empty object'() { def waf = new WAFModule() - Waf.ResultWithData rwd = new Waf.ResultWithData(null, "[{}]", null, null) + Waf.ResultWithData rwd = new Waf.ResultWithData(null, "[{}]", null, null, false, 0, false) Collection ret when: @@ -1372,6 +1396,7 @@ class WAFModuleSpecification extends DDSpecification { 1 * flow.isBlocking() 1 * ctx.isThrottled(null) 1 * ctx.isWafContextClosed() >> false + 1 * ctx.setKeepType(_) 0 * _ when: @@ -1385,6 +1410,7 @@ class WAFModuleSpecification extends DDSpecification { 1 * ctx.reportEvents(_ as Collection) >> { it[0].iterator().next().ruleMatches[0].parameters[0].value == 'user-to-block-1' } + 1 * ctx.setKeepType(_) 2 * ctx.getWafMetrics() 1 * ctx.isWafContextClosed() >> false 1 * ctx.closeWafContext() @@ -1463,6 +1489,7 @@ class WAFModuleSpecification extends DDSpecification { 1 * ctx.isThrottled(null) 1 * ctx.reportEvents(_ as Collection) 1 * ctx.closeWafContext() + 1 * ctx.setKeepType(_) 1 * ctx.isWafContextClosed() 2 * tracer.activeSpan() 1 * flow.isBlocking() @@ -1496,6 +1523,7 @@ class WAFModuleSpecification extends DDSpecification { 1 * ctx.reportEvents(_ as Collection) 1 * ctx.closeWafContext() 2 * tracer.activeSpan() + 1 * ctx.setKeepType(_) 0 * _ } @@ -1682,7 +1710,7 @@ class WAFModuleSpecification extends DDSpecification { void 'ResultWithData - null data'() { def waf = new WAFModule() - Waf.ResultWithData rwd = new Waf.ResultWithData(null, null, null, null) + Waf.ResultWithData rwd = new Waf.ResultWithData(null, null, null, null, false, 0, false) Collection ret when: @@ -1711,6 +1739,214 @@ class WAFModuleSpecification extends DDSpecification { } } + void 'test rules_compat with output attributes'() { + setup: + def rulesConfig = [ + version: '2.1', + metadata: [ + rules_version: '1.2.7' + ], + rules: [ + [ + id: 'arachni_rule', + name: 'Arachni', + tags: [ + type: 'security_scanner', + category: 'attack_attempt' + ], + conditions: [ + [ + parameters: [ + inputs: [ + [ + address: 'server.request.headers.no_cookies', + key_path: ['user-agent'] + ] + ], + regex: '^Arachni\\/v' + ], + operator: 'match_regex' + ] + ], + transformers: [], + on_match: ['block'] + ] + ], + rules_compat: [ + [ + id: 'rc-000-001', + name: 'Rules Compat Test: Attributes, No Keep, No Event', + tags: [ + type: 'security_scanner', + category: 'attack_attempt' + ], + conditions: [ + [ + parameters: [ + inputs: [ + [ + address: 'server.request.headers.no_cookies', + key_path: ['user-agent'] + ] + ], + regex: '^RulesCompat\\/v1' + ], + operator: 'match_regex' + ] + ], + output: [ + event: false, + keep: false, + attributes: [ + '_dd.appsec.trace.integer': [ + value: 123456789 + ], + '_dd.appsec.trace.agent': [ + value: 'RulesCompat/v1' + ] + ] + ], + on_match: [] + ], + [ + id: 'rc-000-002', + name: 'Rules Compat Test: Attributes, Keep, No Event', + tags: [ + type: 'security_scanner', + category: 'attack_attempt' + ], + conditions: [ + [ + parameters: [ + inputs: [ + [ + address: 'server.request.headers.no_cookies', + key_path: ['user-agent'] + ] + ], + regex: '^RulesCompat\\/v2' + ], + operator: 'match_regex' + ] + ], + output: [ + event: false, + keep: true, + attributes: [ + '_dd.appsec.trace.integer': [ + value: 987654321 + ], + '_dd.appsec.trace.agent': [ + value: 'RulesCompat/v2' + ] + ] + ], + on_match: [] + ], + [ + id: 'rc-000-003', + name: 'Rules Compat Test: Attributes, Keep, Event', + tags: [ + type: 'security_scanner', + category: 'attack_attempt' + ], + conditions: [ + [ + parameters: [ + inputs: [ + [ + address: 'server.request.headers.no_cookies', + key_path: ['user-agent'] + ] + ], + regex: '^RulesCompat\\/v3' + ], + operator: 'match_regex' + ] + ], + output: [ + event: true, + keep: true, + attributes: [ + '_dd.appsec.trace.integer': [ + value: 555666777 + ], + '_dd.appsec.trace.agent': [ + value: 'RulesCompat/v3' + ] + ] + ], + on_match: [] + ] + ] + ] + + when: + initialRuleAddWithMap(rulesConfig) + wafModule.applyConfig(reconf) + + then: + 1 * wafMetricCollector.wafInit(Waf.LIB_VERSION, _, true) + 1 * wafMetricCollector.wafUpdates(_, true) + 1 * reconf.reloadSubscriptions() + 0 * _ + + when: 'test rules_compat rule with attributes, no keep and no event' + def bundle1 = MapDataBundle.of(KnownAddresses.HEADERS_NO_COOKIES, + new CaseInsensitiveMap>(['user-agent': 'RulesCompat/v1'])) + def flow1 = new ChangeableFlow() + dataListener.onDataAvailable(flow1, ctx, bundle1, gwCtx) + ctx.closeWafContext() + + then: + 1 * ctx.getOrCreateWafContext(_, true, false) + 2 * ctx.getWafMetrics() >> metrics + 1 * ctx.isWafContextClosed() >> false + 1 * ctx.closeWafContext() + 1 * ctx.reportDerivatives(['_dd.appsec.trace.agent':'RulesCompat/v1', '_dd.appsec.trace.integer': 123456789]) + 1 * ctx.isThrottled(null) + 1 * ctx.setKeepType(_) + 0 * ctx._(*_) + !flow1.blocking + + when: 'test rules_compat rule with attributes, keep and no event' + def bundle2 = MapDataBundle.of(KnownAddresses.HEADERS_NO_COOKIES, + new CaseInsensitiveMap>(['user-agent': 'RulesCompat/v2'])) + def flow2 = new ChangeableFlow() + dataListener.onDataAvailable(flow2, ctx, bundle2, gwCtx) + ctx.closeWafContext() + + then: + 1 * ctx.getOrCreateWafContext(_, true, false) + 2 * ctx.getWafMetrics() >> metrics + 1 * ctx.isWafContextClosed() >> false + 1 * ctx.closeWafContext() + 1 * ctx.reportDerivatives(['_dd.appsec.trace.agent':'RulesCompat/v2', '_dd.appsec.trace.integer': 987654321]) + 1 * ctx.isThrottled(null) + 1 * ctx.setKeepType(_) + 0 * ctx._(*_) + !flow2.blocking + + when: 'test rules_compat rule with attributes, keep and event' + def bundle3 = MapDataBundle.of(KnownAddresses.HEADERS_NO_COOKIES, + new CaseInsensitiveMap>(['user-agent': 'RulesCompat/v3'])) + def flow3 = new ChangeableFlow() + dataListener.onDataAvailable(flow3, ctx, bundle3, gwCtx) + ctx.closeWafContext() + + then: + 1 * ctx.getOrCreateWafContext(_, true, false) + 2 * ctx.getWafMetrics() >> metrics + 1 * ctx.isWafContextClosed() >> false + 1 * ctx.closeWafContext() + 1 * ctx.reportDerivatives(['_dd.appsec.trace.agent':'RulesCompat/v3', '_dd.appsec.trace.integer': 555666777]) + 1 * ctx.reportEvents(_ as Collection) + 1 * ctx.isThrottled(null) + 1 * ctx.setKeepType(_) + 0 * ctx._(*_) + !flow3.blocking + } + private static class BadConfig implements Map { @Delegate private Map delegate diff --git a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/AppSecRequestContextSpecification.groovy b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/AppSecRequestContextSpecification.groovy index 0b3b2c91bd7..00074d3776b 100644 --- a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/AppSecRequestContextSpecification.groovy +++ b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/AppSecRequestContextSpecification.groovy @@ -312,4 +312,125 @@ class AppSecRequestContextSpecification extends DDSpecification { cleanup: TestLogCollector.disable() } + void 'test that processed attributes are cleared on close'() { + setup: + def derivatives = [ + 'numeric': '42', + 'string': 'value' + ] + + when: + ctx.reportDerivatives(derivatives) + ctx.close() + + then: + ctx.getDerivativeKeys().isEmpty() + } + + def "test attribute handling with literal values and request data extraction"() { + given: + def context = new AppSecRequestContext() + context.setMethod("POST") + context.setScheme("https") + context.setRawURI("/api/test") + context.setRoute("/api/{param}") + context.setResponseStatus(200) + context.addRequestHeader("user-agent", "TestAgent/1.0") + context.addRequestHeader("content-type", "application/json") + + // Test data for attributes + def attributes = [ + "_dd.appsec.s.res.headers": [ + "value": "literal-header-value" + ], + "_dd.appsec.s.res.method": [ + "address": "server.request.method" + ], + "_dd.appsec.s.res.scheme": [ + "address": "server.request.scheme" + ], + "_dd.appsec.s.res.uri": [ + "address": "server.request.uri.raw" + ], + "_dd.appsec.s.res.route": [ + "address": "server.request.route" + ], + "_dd.appsec.s.res.status": [ + "address": "server.response.status" + ], + "_dd.appsec.s.res.user_agent": [ + "address": "server.request.headers", + "key_path": ["user-agent"] + ], + "_dd.appsec.s.res.content_type": [ + "address": "server.request.headers", + "key_path": ["content-type"] + ], + "_dd.appsec.s.res.user_agent_lower": [ + "address": "server.request.headers", + "key_path": ["user-agent"], + "transformers": ["lowercase"] + ], + "_dd.appsec.s.res.content_type_upper": [ + "address": "server.request.headers", + "key_path": ["content-type"], + "transformers": ["uppercase"] + ] + ] + + when: + context.reportDerivatives(attributes) + def keys = context.getDerivativeKeys() + + then: + keys.size() == 10 + keys.contains("_dd.appsec.s.res.headers") + keys.contains("_dd.appsec.s.res.method") + keys.contains("_dd.appsec.s.res.scheme") + keys.contains("_dd.appsec.s.res.uri") + keys.contains("_dd.appsec.s.res.route") + keys.contains("_dd.appsec.s.res.status") + keys.contains("_dd.appsec.s.res.user_agent") + keys.contains("_dd.appsec.s.res.content_type") + keys.contains("_dd.appsec.s.res.user_agent_lower") + keys.contains("_dd.appsec.s.res.content_type_upper") + } + + def "test attribute handling with unknown address"() { + given: + def context = new AppSecRequestContext() + + def attributes = [ + "_dd.appsec.s.res.unknown": [ + "address": "server.request.unknown" + ] + ] + + when: + context.reportDerivatives(attributes) + def keys = context.getDerivativeKeys() + + then: + keys.size() == 0 // No attributes should be added for unknown addresses + } + + def "test attribute handling with invalid key path"() { + given: + def context = new AppSecRequestContext() + context.addRequestHeader("user-agent", "TestAgent/1.0") + + def attributes = [ + "_dd.appsec.s.res.invalid": [ + "address": "server.request.headers", + "key_path": ["non-existent-header"] + ] + ] + + when: + context.reportDerivatives(attributes) + def keys = context.getDerivativeKeys() + + then: + keys.size() == 0 // No attributes should be added for invalid key paths + } } diff --git a/dd-smoke-tests/appsec/springboot/src/test/groovy/datadog/smoketest/appsec/TraceTaggingSmokeTest.groovy b/dd-smoke-tests/appsec/springboot/src/test/groovy/datadog/smoketest/appsec/TraceTaggingSmokeTest.groovy new file mode 100644 index 00000000000..5a10eb7a197 --- /dev/null +++ b/dd-smoke-tests/appsec/springboot/src/test/groovy/datadog/smoketest/appsec/TraceTaggingSmokeTest.groovy @@ -0,0 +1,268 @@ +package datadog.smoketest.appsec + +import groovy.json.JsonSlurper +import okhttp3.Request +import spock.lang.Shared + +class TraceTaggingSmokeTest extends AbstractAppSecServerSmokeTest { + + @Override + def logLevel() { + 'DEBUG' + } + + @Shared + String buildDir = new File(System.getProperty("datadog.smoketest.builddir")).absolutePath + @Shared + String customRulesPath = "${buildDir}/appsec_custom_rules.json" + + def prepareCustomRules() { + // Create a custom rules file with rules_compat section + def rulesContent = [ + version: '2.1', + metadata: [ + rules_version: '1.2.7' + ], + rules: [ + [ + id: 'arachni_rule', + name: 'Arachni', + tags: [ + type: 'security_scanner', + category: 'attack_attempt' + ], + conditions: [ + [ + parameters: [ + inputs: [ + [ + address: 'server.request.headers.no_cookies', + key_path: ['user-agent'] + ] + ], + regex: '^Arachni\\/v' + ], + operator: 'match_regex' + ] + ], + transformers: [], + on_match: ['block'] + ] + ], + rules_compat: [ + [ + id: 'ttr-000-001', + name: 'Trace Tagging Rule: Attributes, No Keep, No Event', + tags: [ + type: 'security_scanner', + category: 'attack_attempt' + ], + conditions: [ + [ + parameters: [ + inputs: [ + [ + address: 'server.request.headers.no_cookies', + key_path: ['user-agent'] + ] + ], + regex: '^TraceTagging\\/v1' + ], + operator: 'match_regex' + ] + ], + output: [ + event: false, + keep: false, + attributes: [ + '_dd.appsec.trace.integer': [ + value: 662607015 + ], + '_dd.appsec.trace.agent': [ + value: 'TraceTagging/v1' + ] + ] + ], + on_match: [] + ], + [ + id: 'ttr-000-002', + name: 'Trace Tagging Rule: Attributes, Keep, No Event', + tags: [ + type: 'security_scanner', + category: 'attack_attempt' + ], + conditions: [ + [ + parameters: [ + inputs: [ + [ + address: 'server.request.headers.no_cookies', + key_path: ['user-agent'] + ] + ], + regex: '^TraceTagging\\/v2' + ], + operator: 'match_regex' + ] + ], + output: [ + event: false, + keep: true, + attributes: [ + '_dd.appsec.trace.integer': [ + value: 602214076 + ], + '_dd.appsec.trace.agent': [ + value: 'TraceTagging/v2' + ] + ] + ], + on_match: [] + ], + [ + id: 'ttr-000-003', + name: 'Trace Tagging Rule: Attributes, Keep, Event', + tags: [ + type: 'security_scanner', + category: 'attack_attempt' + ], + conditions: [ + [ + parameters: [ + inputs: [ + [ + address: 'server.request.headers.no_cookies', + key_path: ['user-agent'] + ] + ], + regex: '^TraceTagging\\/v3' + ], + operator: 'match_regex' + ] + ], + output: [ + event: true, + keep: true, + attributes: [ + '_dd.appsec.trace.integer': [ + value: 299792458 + ], + '_dd.appsec.trace.agent': [ + value: 'TraceTagging/v3' + ] + ] + ], + on_match: [] + ] + ] + ] + + // Write the custom rules to file + def gen = new groovy.json.JsonGenerator.Options().build() + new File(customRulesPath).withWriter { writer -> + writer.write(gen.toJson(rulesContent)) + } + + // Add a new property pointing to the new ruleset + defaultAppSecProperties += "-Ddd.appsec.rules=${customRulesPath}" as String + } + + @Override + ProcessBuilder createProcessBuilder() { + // We run this here to ensure it runs before starting the process. Child setupSpec runs after parent setupSpec, + // so it is not a valid location. + prepareCustomRules() + + String springBootShadowJar = System.getProperty("datadog.smoketest.appsec.springboot.shadowJar.path") + + List command = new ArrayList<>() + command.add(javaPath()) + command.addAll(defaultJavaProperties) + command.addAll(defaultAppSecProperties) + command.addAll((String[]) ["-jar", springBootShadowJar, "--server.port=${httpPort}"]) + + ProcessBuilder processBuilder = new ProcessBuilder(command) + processBuilder.directory(new File(buildDirectory)) + } + + def "test trace tagging rule with attributes, no keep and no event"() { + when: + String url = "http://localhost:${httpPort}/greeting" + def request = new Request.Builder() + .url(url) + .addHeader("User-Agent", "TraceTagging/v1") + .build() + def response = client.newCall(request).execute() + def responseBodyStr = response.body().string() + waitForTraceCount(1) + + then: + responseBodyStr == "Sup AppSec Dawg" + response.code() == 200 + rootSpans.size() == 1 + + def rootSpan = rootSpans[0] + assert rootSpan.meta['_dd.appsec.trace.agent'] != null, "Missing _dd.appsec.trace.agent from span's meta" + assert rootSpan.metrics['_dd.appsec.trace.integer'] != null, "Missing _dd.appsec.trace.integer from span's metrics" + + assert rootSpan.meta['_dd.appsec.trace.agent'].startsWith("TraceTagging/v1") + assert rootSpan.metrics['_dd.appsec.trace.integer'] == 662607015 + } + + def "test trace tagging rule with attributes, keep and no event"() { + when: + String url = "http://localhost:${httpPort}/greeting" + def request = new Request.Builder() + .url(url) + .addHeader("User-Agent", "TraceTagging/v2") + .build() + def response = client.newCall(request).execute() + def responseBodyStr = response.body().string() + waitForTraceCount(1) + + then: + responseBodyStr == "Sup AppSec Dawg" + response.code() == 200 + rootSpans.size() == 1 + + def rootSpan = rootSpans[0] + assert rootSpan.meta['_dd.appsec.trace.agent'] != null, "Missing _dd.appsec.trace.agent from span's meta" + assert rootSpan.metrics['_dd.appsec.trace.integer'] != null, "Missing _dd.appsec.trace.integer from span's metrics" + + assert rootSpan.meta['_dd.appsec.trace.agent'].startsWith("TraceTagging/v2") + assert rootSpan.metrics['_dd.appsec.trace.integer'] == 602214076 + assert rootSpan.metrics.get('_sampling_priority_v1') == 2 // USER_KEEP + } + + def "test trace tagging rule with attributes, keep and event"() { + when: + String url = "http://localhost:${httpPort}/greeting" + def request = new Request.Builder() + .url(url) + .addHeader("User-Agent", "TraceTagging/v3") + .build() + def response = client.newCall(request).execute() + def responseBodyStr = response.body().string() + waitForTraceCount(1) + + then: + responseBodyStr == "Sup AppSec Dawg" + response.code() == 200 + rootSpans.size() == 1 + + def rootSpan = rootSpans[0] + assert rootSpan.meta['_dd.appsec.trace.agent'] != null, "Missing _dd.appsec.trace.agent from span's meta" + assert rootSpan.metrics['_dd.appsec.trace.integer'] != null, "Missing _dd.appsec.trace.integer from span's metrics" + + assert rootSpan.meta['_dd.appsec.trace.agent'].startsWith("TraceTagging/v3") + assert rootSpan.metrics['_dd.appsec.trace.integer'] == 299792458 + assert rootSpan.metrics.get('_sampling_priority_v1') == 2 // USER_KEEP + + // Check for WAF attack event + assert rootSpan.meta['_dd.appsec.json'] != null, "Missing WAF attack event" + def appsecJson = new JsonSlurper().parseText(rootSpan.meta['_dd.appsec.json']) + assert appsecJson.triggers != null, "Missing triggers in WAF attack event" + } + +}