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 51e5f07187b..29f086d8784 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; @@ -113,6 +115,8 @@ public AppSecConfigServiceImpl( if (tracerConfig.isAppSecWafMetrics()) { traceSegmentPostProcessors.add(statsReporter); } + // Add trace tagging post processor for handling trace attributes + traceSegmentPostProcessors.add(new com.datadog.appsec.ddwaf.TraceTaggingPostProcessor()); } private void subscribeConfigurationPoller() { @@ -140,7 +144,9 @@ private void subscribeConfigurationPoller() { | CAPABILITY_ENDPOINT_FINGERPRINT | CAPABILITY_ASM_SESSION_FINGERPRINT | CAPABILITY_ASM_NETWORK_FINGERPRINT - | CAPABILITY_ASM_HEADER_FINGERPRINT; + | CAPABILITY_ASM_HEADER_FINGERPRINT + | CAPABILITY_ASM_DD_MULTICONFIG + | CAPABILITY_ASM_TRACE_TAGGING_RULES; if (tracerConfig.isAppSecRaspEnabled()) { capabilities |= CAPABILITY_ASM_RASP_SQLI; capabilities |= CAPABILITY_ASM_RASP_SSRF; @@ -490,7 +496,9 @@ public void close() { | CAPABILITY_ENDPOINT_FINGERPRINT | CAPABILITY_ASM_SESSION_FINGERPRINT | CAPABILITY_ASM_NETWORK_FINGERPRINT - | CAPABILITY_ASM_HEADER_FINGERPRINT); + | CAPABILITY_ASM_HEADER_FINGERPRINT + | CAPABILITY_ASM_DD_MULTICONFIG + | 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/TraceTaggingPostProcessor.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/ddwaf/TraceTaggingPostProcessor.java new file mode 100644 index 00000000000..af7c8fb44d8 --- /dev/null +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/ddwaf/TraceTaggingPostProcessor.java @@ -0,0 +1,51 @@ +package com.datadog.appsec.ddwaf; + +import com.datadog.appsec.config.TraceSegmentPostProcessor; +import com.datadog.appsec.gateway.AppSecRequestContext; +import com.datadog.appsec.report.AppSecEvent; +import datadog.trace.api.internal.TraceSegment; +import java.util.Collection; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Post processor that serializes trace attributes from the AppSec request context to the trace + * segment during trace post-processing. + * + *

This processor handles the new trace tagging feature where WAF rules can specify attributes to + * be added to the trace segment. + */ +public class TraceTaggingPostProcessor implements TraceSegmentPostProcessor { + private static final Logger log = LoggerFactory.getLogger(TraceTaggingPostProcessor.class); + + @Override + public void processTraceSegment( + TraceSegment segment, AppSecRequestContext ctx, Collection collectedEvents) { + + Map traceAttributes = ctx.getTraceAttributes(); + if (traceAttributes == null || traceAttributes.isEmpty()) { + return; + } + + log.debug("Serializing {} trace attributes to trace segment", traceAttributes.size()); + + // Serialize each attribute to the trace segment + for (Map.Entry entry : traceAttributes.entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + + if (key != null && !key.isEmpty() && value != null) { + try { + // Use setTagTop to add the attribute to the trace segment + segment.setTagTop(key, value); + log.debug("Added trace attribute: {} = {}", key, value); + } catch (Exception e) { + log.warn("Failed to serialize trace attribute {} = {}", key, value, e); + } + } else { + log.debug("Skipping invalid trace attribute: key='{}', value='{}'", key, value); + } + } + } +} diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/ddwaf/TraceTaggingResultProcessor.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/ddwaf/TraceTaggingResultProcessor.java new file mode 100644 index 00000000000..548b1c7247e --- /dev/null +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/ddwaf/TraceTaggingResultProcessor.java @@ -0,0 +1,304 @@ +package com.datadog.appsec.ddwaf; + +import com.datadog.appsec.event.ChangeableFlow; +import com.datadog.appsec.gateway.AppSecRequestContext; +import com.datadog.appsec.gateway.GatewayContext; +import com.datadog.appsec.gateway.RateLimiter; +import com.datadog.appsec.report.AppSecEvent; +import datadog.appsec.api.blocking.BlockingContentType; +import datadog.trace.api.Config; +import datadog.trace.api.ProductTraceSource; +import datadog.trace.api.gateway.Flow; +import datadog.trace.api.telemetry.WafMetricCollector; +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.stacktrace.StackTraceEvent; +import datadog.trace.util.stacktrace.StackTraceFrame; +import datadog.trace.util.stacktrace.StackUtils; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Processor for handling trace tagging results from the WAF. This class processes the new trace + * tagging result structure that includes keep flags, attributes, and event generation control. + */ +public class TraceTaggingResultProcessor { + private static final Logger log = LoggerFactory.getLogger(TraceTaggingResultProcessor.class); + private static final String EXPLOIT_DETECTED_MSG = "Exploit detected"; + + private final RateLimiter rateLimiter; + + public TraceTaggingResultProcessor(RateLimiter rateLimiter) { + this.rateLimiter = rateLimiter; + } + + /** + * Process a trace tagging result from the WAF. + * + * @param result The trace tagging result to process + * @param flow The changeable flow for request modification + * @param reqCtx The request context + * @param gwCtx The gateway context + */ + public void processResult( + WAFResultData.TraceTaggingResult result, + ChangeableFlow flow, + AppSecRequestContext reqCtx, + GatewayContext gwCtx) { + + // Handle timeout + if (result.timeout) { + if (gwCtx.isRasp) { + reqCtx.increaseRaspTimeouts(); + WafMetricCollector.get().raspTimeout(gwCtx.raspRuleType); + } else { + reqCtx.increaseWafTimeouts(); + log.debug("Timeout calling the WAF"); + } + return; + } + + // Handle keep flag for sampling priority + if (result.keep) { + handleKeepFlag(reqCtx); + } + + // Handle actions (blocking, redirects, stack generation, trace tagging) + if (result.actions != null && !result.actions.isEmpty()) { + handleActions(result.actions, flow, reqCtx, gwCtx); + } + + // Handle events + if (result.events != null && !result.events.isEmpty()) { + handleEvents(result.events, reqCtx, gwCtx); + } + + // Handle attributes for trace tagging + if (result.attributes != null && !result.attributes.isEmpty()) { + handleAttributes(result.attributes, reqCtx); + } + + // Set blocking state if flow is blocking + if (flow.isBlocking()) { + if (gwCtx.isRasp) { + reqCtx.setRaspBlocked(); + } else { + reqCtx.setWafBlocked(); + } + } + } + + /** Handle the keep flag by setting appropriate span tags for sampling priority. */ + private void handleKeepFlag(AppSecRequestContext reqCtx) { + AgentSpan activeSpan = AgentTracer.get().activeSpan(); + if (activeSpan != null) { + // Keep event related span, because it could be ignored in case of + // reduced datadog sampling rate. + activeSpan.getLocalRootSpan().setTag(Tags.ASM_KEEP, true); + // If APM is disabled, inform downstream services that the current + // distributed trace contains at least one ASM event and must inherit + // the given force-keep priority + activeSpan.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 + log.debug("There is no active span available"); + } + } + + /** Handle actions from the WAF result. */ + private void handleActions( + Map> actions, + ChangeableFlow flow, + AppSecRequestContext reqCtx, + GatewayContext gwCtx) { + + for (Map.Entry> action : actions.entrySet()) { + String actionType = action.getKey(); + Map actionParams = action.getValue(); + + if ("trace_tagging".equals(actionType)) { + handleTraceTaggingAction(actionParams, reqCtx); + } else { + WAFModule.ActionInfo actionInfo = new WAFModule.ActionInfo(actionType, actionParams); + + if ("block_request".equals(actionInfo.type)) { + Flow.Action.RequestBlockingAction rba = + createBlockRequestAction(actionInfo, reqCtx, gwCtx.isRasp); + flow.setAction(rba); + } else if ("redirect_request".equals(actionInfo.type)) { + Flow.Action.RequestBlockingAction rba = + createRedirectRequestAction(actionInfo, reqCtx, gwCtx.isRasp); + flow.setAction(rba); + } else if ("generate_stack".equals(actionInfo.type)) { + if (Config.get().isAppSecStackTraceEnabled()) { + String stackId = (String) actionInfo.parameters.get("stack_id"); + StackTraceEvent stackTraceEvent = createExploitStackTraceEvent(stackId); + reqCtx.reportStackTrace(stackTraceEvent); + } else { + log.debug("Ignoring action with type generate_stack (stack traces disabled)"); + } + } else { + log.info("Ignoring action with type {}", actionInfo.type); + if (!gwCtx.isRasp) { + reqCtx.setWafRequestBlockFailure(); + } + } + } + } + } + + /** Handle trace tagging action. */ + private void handleTraceTaggingAction( + Map actionParams, AppSecRequestContext reqCtx) { + // Handle keep flag + Object keepObj = actionParams.get("keep"); + if (keepObj instanceof Boolean && (Boolean) keepObj) { + handleKeepFlag(reqCtx); + } + + // Handle attributes + Object attrsObj = actionParams.get("attributes"); + if (attrsObj instanceof Map) { + @SuppressWarnings("unchecked") + Map attributes = (Map) attrsObj; + if (!attributes.isEmpty()) { + handleAttributes(attributes, reqCtx); + } + } + } + + /** Handle events from the WAF result. */ + private void handleEvents( + Collection events, AppSecRequestContext reqCtx, GatewayContext gwCtx) { + + if (!events.isEmpty()) { + if (!reqCtx.isThrottled(rateLimiter)) { + AgentSpan activeSpan = AgentTracer.get().activeSpan(); + if (activeSpan != null) { + log.debug("Setting force-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); + // If APM is disabled, inform downstream services that the current + // distributed trace contains at least one ASM event and must inherit + // the given force-keep priority + activeSpan + .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 + log.debug("There is no active span available"); + } + + // Convert WAFResultData to AppSecEvent + Collection appSecEvents = convertToAppSecEvents(events); + reqCtx.reportEvents(appSecEvents); + } else { + log.debug("Rate limited WAF events"); + if (!gwCtx.isRasp) { + reqCtx.setWafRateLimited(); + } + } + } + } + + /** Handle attributes for trace tagging. */ + private void handleAttributes(Map attributes, AppSecRequestContext reqCtx) { + // Serialize attributes to the trace segment + // This will be handled by the trace segment post processor + reqCtx.setTraceAttributes(attributes); + } + + /** Convert WAFResultData collection to AppSecEvent collection. */ + private Collection convertToAppSecEvents(Collection wafResults) { + return wafResults.stream() + .map(this::buildEvent) + .filter(event -> event != null) + .collect(java.util.stream.Collectors.toList()); + } + + /** Build an AppSecEvent from WAFResultData. */ + private AppSecEvent buildEvent(WAFResultData wafResult) { + if (wafResult == null || wafResult.rule == null || wafResult.rule_matches == null) { + log.warn("WAF result is empty: {}", wafResult); + return null; + } + + Long spanId = null; + AgentSpan agentSpan = AgentTracer.get().activeSpan(); + if (agentSpan != null) { + spanId = agentSpan.getSpanId(); + } + + return new AppSecEvent.Builder() + .withRule(wafResult.rule) + .withRuleMatches(wafResult.rule_matches) + .withSpanId(spanId) + .withStackId(wafResult.stack_id) + .build(); + } + + /** Create a block request action. */ + private Flow.Action.RequestBlockingAction createBlockRequestAction( + WAFModule.ActionInfo actionInfo, AppSecRequestContext reqCtx, boolean isRasp) { + + Integer statusCode = (Integer) actionInfo.parameters.get("status_code"); + if (statusCode == null) { + statusCode = 403; + } + + String type = (String) actionInfo.parameters.get("type"); + BlockingContentType blockingContentType = BlockingContentType.AUTO; + if ("json".equals(type)) { + blockingContentType = BlockingContentType.JSON; + } else if ("html".equals(type)) { + blockingContentType = BlockingContentType.HTML; + } + + if (!isRasp) { + reqCtx.setWafBlocked(); + } + + return new Flow.Action.RequestBlockingAction(statusCode, blockingContentType); + } + + /** Create a redirect request action. */ + private Flow.Action.RequestBlockingAction createRedirectRequestAction( + WAFModule.ActionInfo actionInfo, AppSecRequestContext reqCtx, boolean isRasp) { + + Integer statusCode = (Integer) actionInfo.parameters.get("status_code"); + if (statusCode == null) { + statusCode = 303; + } + + String location = (String) actionInfo.parameters.get("location"); + if (location == null) { + location = "https://example.com/"; + } + + Map extraHeaders = Collections.singletonMap("Location", location); + + if (!isRasp) { + reqCtx.setWafBlocked(); + } + + return new Flow.Action.RequestBlockingAction( + statusCode, BlockingContentType.AUTO, extraHeaders); + } + + /** Create an exploit stack trace event. */ + private StackTraceEvent createExploitStackTraceEvent(String stackId) { + if (stackId == null || stackId.isEmpty()) { + return null; + } + List result = StackUtils.generateUserCodeStackTrace(); + return new StackTraceEvent(result, "java", stackId, EXPLOIT_DETECTED_MSG); + } +} 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 609b2fdb64d..9d41b2f39b9 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 @@ -86,11 +86,11 @@ public class WAFModule implements AppSecModule { private String rulesetVersion; private WafBuilder wafBuilder; - private static class ActionInfo { + public static class ActionInfo { final String type; final Map parameters; - private ActionInfo(String type, Map parameters) { + public ActionInfo(String type, Map parameters) { this.type = type; this.parameters = parameters; } @@ -142,6 +142,7 @@ static void createLimitsObject() { Config.get().isAppSecWafMetrics(); // could be static if not for tests private final AtomicReference ctxAndAddresses = new AtomicReference<>(); private final RateLimiter rateLimiter; + private final TraceTaggingResultProcessor traceTaggingResultProcessor; public WAFModule() { this(null); @@ -149,6 +150,7 @@ public WAFModule() { public WAFModule(Monitoring monitoring) { this.rateLimiter = getRateLimiter(monitoring); + this.traceTaggingResultProcessor = new TraceTaggingResultProcessor(rateLimiter); } @Override @@ -361,85 +363,94 @@ public void onDataAvailable( StandardizedLogging.inAppWafReturn(log, resultWithData); - if (resultWithData.result != Waf.Result.OK) { - if (log.isDebugEnabled()) { - log.warn("WAF signalled result {}: {}", resultWithData.result, resultWithData.data); - } + // Check if this is a new trace tagging result structure + if (isTraceTaggingResult(resultWithData)) { + // Handle new trace tagging result structure + WAFResultData.TraceTaggingResult traceTaggingResult = + convertToTraceTaggingResult(resultWithData); + traceTaggingResultProcessor.processResult(traceTaggingResult, flow, reqCtx, gwCtx); + } else { + // Handle legacy result structure + if (resultWithData.result != Waf.Result.OK) { + if (log.isDebugEnabled()) { + log.warn("WAF signalled result {}: {}", resultWithData.result, resultWithData.data); + } - if (gwCtx.isRasp) { - reqCtx.setRaspMatched(true); - WafMetricCollector.get().raspRuleMatch(gwCtx.raspRuleType); - } + if (gwCtx.isRasp) { + reqCtx.setRaspMatched(true); + WafMetricCollector.get().raspRuleMatch(gwCtx.raspRuleType); + } - for (Map.Entry> action : resultWithData.actions.entrySet()) { - String actionType = action.getKey(); - Map actionParams = action.getValue(); - - ActionInfo actionInfo = new ActionInfo(actionType, actionParams); - - if ("block_request".equals(actionInfo.type)) { - Flow.Action.RequestBlockingAction rba = - createBlockRequestAction(actionInfo, reqCtx, gwCtx.isRasp); - flow.setAction(rba); - } else if ("redirect_request".equals(actionInfo.type)) { - Flow.Action.RequestBlockingAction rba = - createRedirectRequestAction(actionInfo, reqCtx, gwCtx.isRasp); - flow.setAction(rba); - } else if ("generate_stack".equals(actionInfo.type)) { - if (Config.get().isAppSecStackTraceEnabled()) { - String stackId = (String) actionInfo.parameters.get("stack_id"); - StackTraceEvent stackTraceEvent = createExploitStackTraceEvent(stackId); - reqCtx.reportStackTrace(stackTraceEvent); + for (Map.Entry> action : resultWithData.actions.entrySet()) { + String actionType = action.getKey(); + Map actionParams = action.getValue(); + + ActionInfo actionInfo = new ActionInfo(actionType, actionParams); + + if ("block_request".equals(actionInfo.type)) { + Flow.Action.RequestBlockingAction rba = + createBlockRequestAction(actionInfo, reqCtx, gwCtx.isRasp); + flow.setAction(rba); + } else if ("redirect_request".equals(actionInfo.type)) { + Flow.Action.RequestBlockingAction rba = + createRedirectRequestAction(actionInfo, reqCtx, gwCtx.isRasp); + flow.setAction(rba); + } else if ("generate_stack".equals(actionInfo.type)) { + if (Config.get().isAppSecStackTraceEnabled()) { + String stackId = (String) actionInfo.parameters.get("stack_id"); + StackTraceEvent stackTraceEvent = createExploitStackTraceEvent(stackId); + reqCtx.reportStackTrace(stackTraceEvent); + } else { + log.debug("Ignoring action with type generate_stack (disabled by config)"); + } } else { - log.debug("Ignoring action with type generate_stack (disabled by config)"); - } - } else { - log.info("Ignoring action with type {}", actionInfo.type); - if (!gwCtx.isRasp) { - reqCtx.setWafRequestBlockFailure(); + log.info("Ignoring action with type {}", actionInfo.type); + if (!gwCtx.isRasp) { + reqCtx.setWafRequestBlockFailure(); + } } } - } - Collection events = buildEvents(resultWithData); - - if (!events.isEmpty()) { - if (!reqCtx.isThrottled(rateLimiter)) { - AgentSpan activeSpan = AgentTracer.get().activeSpan(); - if (activeSpan != null) { - log.debug("Setting force-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); - // If APM is disabled, inform downstream services that the current - // distributed trace contains at least one ASM event and must inherit - // the given force-keep priority - activeSpan - .getLocalRootSpan() - .setTag(Tags.PROPAGATED_TRACE_SOURCE, ProductTraceSource.ASM); + Collection events = buildEvents(resultWithData); + + if (!events.isEmpty()) { + if (!reqCtx.isThrottled(rateLimiter)) { + AgentSpan activeSpan = AgentTracer.get().activeSpan(); + if (activeSpan != null) { + log.debug("Setting force-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); + // If APM is disabled, inform downstream services that the current + // distributed trace contains at least one ASM event and must inherit + // the given force-keep priority + activeSpan + .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 + log.debug("There is no active span available"); + } + reqCtx.reportEvents(events); } else { - // If active span is not available the ASM_KEEP tag will be set in the GatewayBridge - // when the request ends - log.debug("There is no active span available"); + log.debug("Rate limited WAF events"); + if (!gwCtx.isRasp) { + reqCtx.setWafRateLimited(); + } } - reqCtx.reportEvents(events); - } else { - log.debug("Rate limited WAF events"); + } + + if (flow.isBlocking()) { if (!gwCtx.isRasp) { - reqCtx.setWafRateLimited(); + reqCtx.setWafBlocked(); } } } - if (flow.isBlocking()) { - if (!gwCtx.isRasp) { - reqCtx.setWafBlocked(); - } + if (resultWithData.derivatives != null) { + reqCtx.reportDerivatives(resultWithData.derivatives); } } - - if (resultWithData.derivatives != null) { - reqCtx.reportDerivatives(resultWithData.derivatives); - } } private Flow.Action.RequestBlockingAction createBlockRequestAction( @@ -556,6 +567,86 @@ private Waf.ResultWithData runWafTransient( new DataBundleMapWrapper(ctxAndAddr.addressesOfInterest, newData), LIMITS, metrics); } + /** + * Check if the result is a new trace tagging result structure. For now, we'll use a simple + * heuristic based on the presence of trace tagging actions. + */ + private boolean isTraceTaggingResult(Waf.ResultWithData resultWithData) { + // Check if the result has trace tagging actions + if (resultWithData.actions != null) { + for (String actionType : resultWithData.actions.keySet()) { + if ("trace_tagging".equals(actionType)) { + return true; + } + } + } + return false; + } + + /** Convert the legacy ResultWithData to the new TraceTaggingResult structure. */ + private WAFResultData.TraceTaggingResult convertToTraceTaggingResult( + Waf.ResultWithData resultWithData) { + WAFResultData.TraceTaggingResult result = new WAFResultData.TraceTaggingResult(); + + // Map the fields from the legacy structure to the new structure + result.timeout = false; // Timeout is handled separately in the calling code + result.keep = extractKeepFlag(resultWithData); + result.duration = 0L; // Duration is not available in current structure + result.events = buildWafResultData(resultWithData); + result.actions = resultWithData.actions; + result.attributes = extractTraceAttributes(resultWithData); + + return result; + } + + /** Extract keep flag from trace tagging actions. */ + private boolean extractKeepFlag(Waf.ResultWithData resultWithData) { + if (resultWithData.actions != null) { + Map traceTaggingAction = resultWithData.actions.get("trace_tagging"); + if (traceTaggingAction != null) { + Object keepObj = traceTaggingAction.get("keep"); + if (keepObj instanceof Boolean) { + return (Boolean) keepObj; + } + } + } + return false; + } + + /** Extract trace attributes from the WAF result. */ + private Map extractTraceAttributes(Waf.ResultWithData resultWithData) { + Map attributes = new HashMap<>(); + + // Extract attributes from trace tagging actions + if (resultWithData.actions != null) { + Map traceTaggingAction = resultWithData.actions.get("trace_tagging"); + if (traceTaggingAction != null) { + Object attrs = traceTaggingAction.get("attributes"); + if (attrs instanceof Map) { + @SuppressWarnings("unchecked") + Map attrMap = (Map) attrs; + attributes.putAll(attrMap); + } + } + } + + return attributes; + } + + private List buildWafResultData(Waf.ResultWithData actionWithData) { + List listResults; + try { + listResults = RES_JSON_ADAPTER.fromJson(actionWithData.data); + } catch (IOException e) { + throw new UndeclaredThrowableException(e); + } + + if (listResults != null && !listResults.isEmpty()) { + return listResults; + } + return emptyList(); + } + private Collection buildEvents(Waf.ResultWithData actionWithData) { Collection listResults; try { diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/ddwaf/WAFResultData.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/ddwaf/WAFResultData.java index 051afd9b62d..756167cc24b 100644 --- a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/ddwaf/WAFResultData.java +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/ddwaf/WAFResultData.java @@ -1,13 +1,23 @@ package com.datadog.appsec.ddwaf; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; public class WAFResultData { Rule rule; List rule_matches; String stack_id; + // Forbidden addresses that contain sensitive data and must not be allowed + private static final Set FORBIDDEN_ADDRESSES = new HashSet<>(); + + static { + FORBIDDEN_ADDRESSES.add("usr.session_id"); + FORBIDDEN_ADDRESSES.add("server.request.cookies"); + } + public static class RuleMatch { String operator; String operator_value; @@ -18,6 +28,158 @@ public static class Rule { public String id; // expose for log message String name; Map tags; + Output output; // optional output field for trace tagging support + + /** + * Check if events should be generated for this rule. Backwards compatibility: if the output is + * null or keep is null, we default to true. + * + * @return true if events should be generated, false otherwise + */ + public boolean shouldGenerateEvents() { + return output == null || output.event == null || output.event; + } + + /** + * Check if the trace should be kept for this rule. Backwards compatibility: if the output is + * null or keep is null, we default to true. + * + * @return true if the trace should be kept, false otherwise + */ + public boolean shouldKeepTrace() { + return output == null || output.keep == null || output.keep; + } + + /** + * Get the attributes to be added to the trace. + * + * @return the attributes map, or null if no attributes + */ + public Map getAttributes() { + return output != null ? output.attributes : null; + } + } + + /** Represents the optional "output" field in a rule for trace tagging support. */ + public static class Output { + private final Boolean keep; + private final Boolean event; + private final Map attributes; + + public Output(Boolean keep, Boolean event, Map attributes) { + this.keep = keep; + this.event = event; + this.attributes = attributes; + } + + /** Whether to keep the trace (set sampling priority). */ + public Boolean getKeep() { + return keep; + } + + /** Whether to generate events. */ + public Boolean getEvent() { + return event; + } + + /** Get the attributes to be added to the trace. */ + public Map getAttributes() { + return attributes; + } + } + + /** + * Represents an attribute value that can be either a literal value or extracted from request + * data. + */ + public static class AttributeValue { + private final Object literalValue; + private final String address; + private final List keyPath; + private final List transformers; + + /** Create a literal attribute value. */ + public static AttributeValue literal(Object value) { + if (value != null && !isScalar(value)) { + throw new IllegalArgumentException( + "Literal values must be scalar (string, number, boolean)"); + } + return new AttributeValue(value, null, null, null); + } + + /** Create an attribute value extracted from request data. */ + public static AttributeValue fromRequestData( + String address, List keyPath, List transformers) { + if (address == null || address.trim().isEmpty()) { + throw new IllegalArgumentException("Address cannot be null or empty"); + } + + // Check for forbidden addresses + if (FORBIDDEN_ADDRESSES.contains(address)) { + throw new IllegalArgumentException( + "Address '" + address + "' is forbidden as it contains sensitive data"); + } + + return new AttributeValue(null, address, keyPath, transformers); + } + + private AttributeValue( + Object literalValue, String address, List keyPath, List transformers) { + this.literalValue = literalValue; + this.address = address; + this.keyPath = keyPath; + this.transformers = transformers; + } + + /** Check if this is a literal value. */ + public boolean isLiteral() { + return address == null; + } + + /** Get the literal value (only valid if isLiteral() returns true). */ + public Object getLiteralValue() { + return literalValue; + } + + /** Get the address for request data extraction (only valid if isLiteral() returns false). */ + public String getAddress() { + return address; + } + + /** Get the key path for request data extraction (only valid if isLiteral() returns false). */ + public List getKeyPath() { + return keyPath; + } + + /** + * Get the transformers for request data extraction (only valid if isLiteral() returns false). + */ + public List getTransformers() { + return transformers; + } + + /** Check if a value is scalar (string, number, boolean). */ + private static boolean isScalar(Object value) { + return value instanceof String + || value instanceof Number + || value instanceof Boolean + || value instanceof Character; + } + + @Override + public String toString() { + if (isLiteral()) { + return "AttributeValue{literal=" + literalValue + "}"; + } else { + return "AttributeValue{address='" + + address + + "', keyPath=" + + keyPath + + ", transformers=" + + transformers + + "}"; + } + } } public static class Parameter extends MatchInfo { @@ -32,4 +194,27 @@ public static class MatchInfo { List key_path; String value; } + + /** + * New WAF result structure for trace tagging support. This replaces the old ddwaf_result + * structure. + */ + public static class TraceTaggingResult { + public boolean timeout; + public boolean keep; + public long duration; + public List events; + public Map> actions; + public Map attributes; + } + + /** Check if an address is forbidden. */ + public static boolean isForbiddenAddress(String address) { + return FORBIDDEN_ADDRESSES.contains(address); + } + + /** Get the set of forbidden addresses. */ + public static Set getForbiddenAddresses() { + return new HashSet<>(FORBIDDEN_ADDRESSES); + } } diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/event/data/KnownAddresses.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/event/data/KnownAddresses.java index d88c2fb0311..8d6f9a6e5eb 100644 --- a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/event/data/KnownAddresses.java +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/event/data/KnownAddresses.java @@ -144,6 +144,38 @@ public interface KnownAddresses { Address> WAF_CONTEXT_PROCESSOR = new Address<>("waf.context.processor"); + // JWT: single address for the whole token (header, payload, signature) + Address> REQUEST_JWT = new Address<>("server.request.jwt"); + + // JWT-related addresses + /** JWT header as parsed from the token */ + Address> REQUEST_JWT_HEADER = new Address<>("server.request.jwt.header"); + + /** JWT payload as parsed from the token */ + Address> REQUEST_JWT_PAYLOAD = new Address<>("server.request.jwt.payload"); + + /** JWT signing algorithm from header */ + Address REQUEST_JWT_ALGORITHM = new Address<>("server.request.jwt.algorithm"); + + /** JWT issuer claim from payload */ + Address REQUEST_JWT_ISSUER = new Address<>("server.request.jwt.issuer"); + + /** JWT subject claim from payload */ + Address REQUEST_JWT_SUBJECT = new Address<>("server.request.jwt.subject"); + + /** JWT audience claim from payload */ + Address REQUEST_JWT_AUDIENCE = new Address<>("server.request.jwt.audience"); + + /** JWT expiration time claim from payload */ + Address REQUEST_JWT_EXPIRATION = new Address<>("server.request.jwt.expiration"); + + /** JWT issued at time claim from payload */ + Address REQUEST_JWT_ISSUED_AT = new Address<>("server.request.jwt.issued_at"); + + /** JWT custom claims (non-standard claims) from payload */ + Address> REQUEST_JWT_CUSTOM_CLAIMS = + new Address<>("server.request.jwt.custom_claims"); + static Address forName(String name) { switch (name) { case "server.request.body": @@ -224,6 +256,27 @@ static Address forName(String name) { return EXEC_CMD; case "server.sys.shell.cmd": return SHELL_CMD; + // JWT-related addresses + case "server.request.jwt": + return REQUEST_JWT; + case "server.request.jwt.header": + return REQUEST_JWT_HEADER; + case "server.request.jwt.payload": + return REQUEST_JWT_PAYLOAD; + case "server.request.jwt.algorithm": + return REQUEST_JWT_ALGORITHM; + case "server.request.jwt.issuer": + return REQUEST_JWT_ISSUER; + case "server.request.jwt.subject": + return REQUEST_JWT_SUBJECT; + case "server.request.jwt.audience": + return REQUEST_JWT_AUDIENCE; + case "server.request.jwt.expiration": + return REQUEST_JWT_EXPIRATION; + case "server.request.jwt.issued_at": + return REQUEST_JWT_ISSUED_AT; + case "server.request.jwt.custom_claims": + return REQUEST_JWT_CUSTOM_CLAIMS; default: return null; } 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..e51c0eb5bda 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 @@ -126,6 +126,7 @@ public class AppSecRequestContext implements DataBundle, Closeable { private volatile boolean wafTruncated; private volatile boolean wafRequestBlockFailure; private volatile boolean wafRateLimited; + private volatile boolean raspBlocked; private volatile int wafTimeouts; private volatile int raspTimeouts; @@ -140,6 +141,9 @@ public class AppSecRequestContext implements DataBundle, Closeable { // keep a reference to the last published usr.session_id private volatile String sessionId; + // trace attributes for trace tagging support + private volatile Map traceAttributes; + // Used to detect missing request-end event at close. private volatile boolean requestEndCalled; @@ -224,6 +228,14 @@ public boolean isWafRateLimited() { return wafRateLimited; } + public void setRaspBlocked() { + this.raspBlocked = true; + } + + public boolean isRaspBlocked() { + return raspBlocked; + } + public void increaseWafTimeouts() { WAF_TIMEOUTS_UPDATER.incrementAndGet(this); } @@ -703,4 +715,17 @@ public boolean isRaspMatched() { public void setRaspMatched(boolean raspMatched) { this.raspMatched = raspMatched; } + + /** + * Set trace attributes for trace tagging support. These attributes will be serialized to the + * trace segment. + */ + public void setTraceAttributes(Map attributes) { + this.traceAttributes = attributes; + } + + /** Get trace attributes for trace tagging support. */ + public Map getTraceAttributes() { + return traceAttributes; + } } 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 c055f709a74..46e8fd5762a 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 @@ -20,6 +20,7 @@ import com.datadog.appsec.event.data.SingletonDataBundle; import com.datadog.appsec.report.AppSecEvent; import com.datadog.appsec.report.AppSecEventWrapper; +import com.datadog.appsec.util.JwtPreprocessor; import datadog.trace.api.Config; import datadog.trace.api.ProductTraceSource; import datadog.trace.api.gateway.Events; @@ -881,6 +882,60 @@ private void onRequestHeader(RequestContext ctx_, String name, String value) { ctx.addCookies(cookies); } else { ctx.addRequestHeader(name, value); + + // Check for JWT token in Authorization header + if (name.equalsIgnoreCase("authorization") && value != null) { + String jwtToken = extractJwtToken(value); + if (jwtToken != null) { + processJwtToken(ctx, jwtToken); + } + } + } + } + + /** Extract JWT token from Authorization header value. Supports "Bearer " format */ + private String extractJwtToken(String authorizationValue) { + if (authorizationValue == null || authorizationValue.trim().isEmpty()) { + return null; + } + + // Check for Bearer scheme + if (authorizationValue.trim().toLowerCase().startsWith("bearer ")) { + String token = authorizationValue.trim().substring(7).trim(); + if (!token.isEmpty()) { + return token; + } + } + + return null; + } + + /** Process JWT token using the JwtPreprocessor and publish the decoded data. */ + private void processJwtToken(AppSecRequestContext ctx, String jwtToken) { + try { + DataBundle jwtBundle = JwtPreprocessor.processJwt(jwtToken); + if (jwtBundle != null) { + // Add the decoded JWT to the request context + ctx.addAll(jwtBundle); + + // Publish the JWT data to subscribers if any exist + publishJwtData(ctx, jwtBundle); + } + } catch (Exception e) { + log.debug("Failed to process JWT token", e); + } + } + + /** Publish JWT data to subscribers if any are registered for the JWT address. */ + private void publishJwtData(AppSecRequestContext ctx, DataBundle jwtBundle) { + try { + DataSubscriberInfo subInfo = producerService.getDataSubscribers(KnownAddresses.REQUEST_JWT); + if (subInfo != null && !subInfo.isEmpty()) { + GatewayContext gwCtx = new GatewayContext(false); + producerService.publishDataEvent(subInfo, ctx, jwtBundle, gwCtx); + } + } catch (Exception e) { + log.debug("Failed to publish JWT data", e); } } diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/util/JwtPreprocessor.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/util/JwtPreprocessor.java new file mode 100644 index 00000000000..73a3563c9b7 --- /dev/null +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/util/JwtPreprocessor.java @@ -0,0 +1,94 @@ +package com.datadog.appsec.util; + +import com.datadog.appsec.event.data.DataBundle; +import com.datadog.appsec.event.data.KnownAddresses; +import com.datadog.appsec.event.data.MapDataBundle; +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.Moshi; +import com.squareup.moshi.Types; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +public class JwtPreprocessor { + private static final Moshi MOSHI = new Moshi.Builder().build(); + private static final JsonAdapter> MAP_ADAPTER = + MOSHI.adapter(Types.newParameterizedType(Map.class, String.class, Object.class)); + + private static final String HEADER_KEY = "header"; + private static final String PAYLOAD_KEY = "payload"; + private static final String SIGNATURE_KEY = "signature"; + private static final String AVAILABLE_KEY = "available"; + + public static DataBundle processJwt(String jwtToken) { + if (jwtToken == null || jwtToken.trim().isEmpty()) { + return null; + } + + // Basic JWT format check + if (!jwtToken.contains(".")) { + return null; + } + + try { + String[] parts = jwtToken.split("\\."); + if (parts.length < 2 || parts.length > 3) { + return null; + } + + // Decode header using Base64URL decoder + String headerJson = new String(Base64.getUrlDecoder().decode(parts[0])); + Map header = MAP_ADAPTER.fromJson(headerJson); + if (header == null) { + return null; + } + + // Decode payload using Base64URL decoder + String payloadJson = new String(Base64.getUrlDecoder().decode(parts[1])); + Map payload = MAP_ADAPTER.fromJson(payloadJson); + if (payload == null) { + return null; + } + + // Convert Double numbers to Long when they are whole numbers + convertDoubleToLong(header); + convertDoubleToLong(payload); + + // Create signature information + Map signature = new HashMap<>(); + signature.put(AVAILABLE_KEY, parts.length == 3); + + // Create JWT structure according to technical specification + Map jwt = new HashMap<>(); + jwt.put(HEADER_KEY, header); + jwt.put(PAYLOAD_KEY, payload); + jwt.put(SIGNATURE_KEY, signature); + + return MapDataBundle.of(KnownAddresses.REQUEST_JWT, jwt); + } catch (Exception e) { + return null; + } + } + + /** + * Recursively converts Double numbers to Long when they are whole numbers. This preserves the + * original JSON types + */ + private static void convertDoubleToLong(Map map) { + for (Map.Entry entry : map.entrySet()) { + Object value = entry.getValue(); + if (value instanceof Double) { + Double doubleValue = (Double) value; + // If it's a whole number, convert to Long + if (doubleValue == doubleValue.longValue()) { + map.put(entry.getKey(), doubleValue.longValue()); + } + } else if (value instanceof Map) { + // Recursively process nested maps + @SuppressWarnings("unchecked") + Map nestedMap = (Map) value; + convertDoubleToLong(nestedMap); + } + } + } +} diff --git a/dd-java-agent/appsec/src/main/java/com/datadog/appsec/util/JwtTraceTagger.java b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/util/JwtTraceTagger.java new file mode 100644 index 00000000000..7cec05070ed --- /dev/null +++ b/dd-java-agent/appsec/src/main/java/com/datadog/appsec/util/JwtTraceTagger.java @@ -0,0 +1,132 @@ +package com.datadog.appsec.util; + +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.Moshi; +import com.squareup.moshi.Types; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.AgentTracer; +import java.io.IOException; +import java.util.Map; + +/** + * Utility for parsing JWT JSON from WAF derivatives and tagging selected claims on the current + * trace/span. + * + *

Privacy: Only non-sensitive, non-user-identifying claims are tagged. Do not add claims such as + * email, name, roles, etc. + * + *

Extensibility: Add new claims to the tagging logic as needed, ensuring privacy review. + */ +public final class JwtTraceTagger { + + private static final String JWT_DERIVATIVE_KEY = "server.request.jwt"; + + private static final JsonAdapter> JWT_JSON_ADAPTER = + new Moshi.Builder() + .build() + .adapter(Types.newParameterizedType(Map.class, String.class, Object.class)); + + private JwtTraceTagger() {} + + /** + * Parses JWT JSON string from WAF derivatives and tags selected claims on the current active + * span. + * + * @param derivatives Map of derivatives from WAF result, containing JWT JSON under + * "server.request.jwt" key + */ + public static void tagJwtClaimsFromDerivatives(Map derivatives) { + if (derivatives == null) { + return; + } + + String jwtJson = derivatives.get(JWT_DERIVATIVE_KEY); + if (jwtJson == null || jwtJson.isEmpty()) { + return; + } + + try { + Map decodedJwt = JWT_JSON_ADAPTER.fromJson(jwtJson); + if (decodedJwt != null) { + tagJwtClaims(decodedJwt); + } + } catch (IOException e) { + // Log parsing error but don't fail the request + // Could add debug logging here if needed + } + } + + /** + * Tags selected JWT claims on the provided span. + * + * @param decodedJwt Map representing the decoded JWT structure from JSON parsing. Expected keys: + * "header", "payload", "signature". + * @param span The span to tag. If null, uses the current active span. + */ + public static void tagJwtClaims(Map decodedJwt, AgentSpan span) { + if (decodedJwt == null) { + return; + } + if (span == null) { + span = AgentTracer.activeSpan(); + if (span == null) { + return; + } + } + + // Tag header claims + Object headerObj = decodedJwt.get("header"); + if (headerObj instanceof Map) { + Map header = (Map) headerObj; + Object alg = header.get("alg"); + if (alg != null) { + span.setTag("jwt.header.alg", alg.toString()); + } + Object typ = header.get("typ"); + if (typ != null) { + span.setTag("jwt.header.typ", typ.toString()); + } + } + + // Tag payload claims + Object payloadObj = decodedJwt.get("payload"); + if (payloadObj instanceof Map) { + Map payload = (Map) payloadObj; + Object sub = payload.get("sub"); + if (sub != null) { + span.setTag("jwt.payload.sub", sub.toString()); + } + Object exp = payload.get("exp"); + if (exp != null) { + String expStr; + if (exp instanceof Number) { + expStr = String.format("%.0f", ((Number) exp).doubleValue()); + } else { + expStr = exp.toString(); + } + System.out.println("DEBUG: Tagging jwt.payload.exp: " + expStr); + span.setTag("jwt.payload.exp", expStr); + } + } + + // Tag signature claims + Object signatureObj = decodedJwt.get("signature"); + if (signatureObj instanceof Map) { + Map signature = (Map) signatureObj; + Object available = signature.get("available"); + if (available != null && Boolean.TRUE.equals(available)) { + span.setTag("jwt.signature.available", available.toString()); + } + } + } + + /** + * Tags selected JWT claims on the current active span. + * + * @param decodedJwt Map representing the decoded JWT structure from JSON parsing. Expected keys: + * "header", "payload", "signature". + */ + public static void tagJwtClaims(Map decodedJwt) { + tagJwtClaims(decodedJwt, null); + } +} 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 bf034235805..f652a593474 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 @@ -24,6 +24,7 @@ import static datadog.remoteconfig.Capabilities.CAPABILITY_ASM_ACTIVATION 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 @@ -37,6 +38,7 @@ import static datadog.remoteconfig.Capabilities.CAPABILITY_ASM_RASP_SQLI 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 @@ -272,7 +274,9 @@ 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_DD_MULTICONFIG + | CAPABILITY_ASM_TRACE_TAGGING_RULES) 0 * _._ when: @@ -423,7 +427,9 @@ 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_DD_MULTICONFIG + | CAPABILITY_ASM_TRACE_TAGGING_RULES) 0 * _._ when: @@ -520,7 +526,9 @@ 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_DD_MULTICONFIG + | CAPABILITY_ASM_TRACE_TAGGING_RULES) 4 * poller.removeListeners(_) 1 * poller.removeConfigurationEndListener(_) 1 * poller.stop() @@ -599,7 +607,9 @@ 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_DD_MULTICONFIG + | CAPABILITY_ASM_TRACE_TAGGING_RULES) 0 * _._ cleanup: @@ -666,7 +676,9 @@ 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_DD_MULTICONFIG + | CAPABILITY_ASM_TRACE_TAGGING_RULES) 0 * _._ when: diff --git a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/ddwaf/TraceTaggingPostProcessorSpecification.groovy b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/ddwaf/TraceTaggingPostProcessorSpecification.groovy new file mode 100644 index 00000000000..cc5982a5c02 --- /dev/null +++ b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/ddwaf/TraceTaggingPostProcessorSpecification.groovy @@ -0,0 +1,109 @@ +package com.datadog.appsec.ddwaf + +import com.datadog.appsec.config.TraceSegmentPostProcessor +import com.datadog.appsec.gateway.AppSecRequestContext +import com.datadog.appsec.report.AppSecEvent +import datadog.trace.api.internal.TraceSegment +import datadog.trace.test.util.DDSpecification + +class TraceTaggingPostProcessorSpecification extends DDSpecification { + + def 'should serialize trace attributes to trace segment'() { + setup: + TraceSegmentPostProcessor processor = new TraceTaggingPostProcessor() + TraceSegment segment = Mock() + AppSecRequestContext ctx = Mock() + Collection events = [] + + Map traceAttributes = [ + 'appsec.rule.id': 'test-rule-123', + 'appsec.attack.type': 'sql_injection', + 'appsec.confidence': 0.95 + ] + + when: + processor.processTraceSegment(segment, ctx, events) + + then: + 1 * ctx.getTraceAttributes() >> traceAttributes + 1 * segment.setTagTop('appsec.rule.id', 'test-rule-123') + 1 * segment.setTagTop('appsec.attack.type', 'sql_injection') + 1 * segment.setTagTop('appsec.confidence', 0.95) + 0 * segment._(*_) + } + + def 'should handle null trace attributes'() { + setup: + TraceSegmentPostProcessor processor = new TraceTaggingPostProcessor() + TraceSegment segment = Mock() + AppSecRequestContext ctx = Mock() + Collection events = [] + + when: + processor.processTraceSegment(segment, ctx, events) + + then: + 1 * ctx.getTraceAttributes() >> null + 0 * segment._(*_) + } + + def 'should handle empty trace attributes'() { + setup: + TraceSegmentPostProcessor processor = new TraceTaggingPostProcessor() + TraceSegment segment = Mock() + AppSecRequestContext ctx = Mock() + Collection events = [] + + when: + processor.processTraceSegment(segment, ctx, events) + + then: + 1 * ctx.getTraceAttributes() >> [:] + 0 * segment._(*_) + } + + def 'should handle null keys or values in trace attributes'() { + setup: + TraceSegmentPostProcessor processor = new TraceTaggingPostProcessor() + TraceSegment segment = Mock() + AppSecRequestContext ctx = Mock() + Collection events = [] + + // Create a Map with actual null keys using put() method + Map traceAttributes = new HashMap<>() + traceAttributes.put('valid.key', 'valid.value') + traceAttributes.put(null, 'null.key') // Actual null key + traceAttributes.put('valid.key2', null) // Null value + traceAttributes.put('', 'empty.key') // Empty key + + when: + processor.processTraceSegment(segment, ctx, events) + + then: + 1 * ctx.getTraceAttributes() >> traceAttributes + 1 * segment.setTagTop('valid.key', 'valid.value') + 0 * segment._(*_) + } + + def 'should handle exceptions during serialization'() { + setup: + TraceSegmentPostProcessor processor = new TraceTaggingPostProcessor() + TraceSegment segment = Mock() + AppSecRequestContext ctx = Mock() + Collection events = [] + + Map traceAttributes = [ + 'good.key': 'good.value', + 'bad.key': 'bad.value' + ] + + when: + processor.processTraceSegment(segment, ctx, events) + + then: + 1 * ctx.getTraceAttributes() >> traceAttributes + 1 * segment.setTagTop('good.key', 'good.value') + 1 * segment.setTagTop('bad.key', 'bad.value') >> { throw new RuntimeException('Test exception') } + 0 * segment._(*_) + } +} diff --git a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/ddwaf/TraceTaggingRuleSpecification.groovy b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/ddwaf/TraceTaggingRuleSpecification.groovy new file mode 100644 index 00000000000..c289219c555 --- /dev/null +++ b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/ddwaf/TraceTaggingRuleSpecification.groovy @@ -0,0 +1,210 @@ +package com.datadog.appsec.ddwaf + +import datadog.trace.test.util.DDSpecification + +class TraceTaggingRuleSpecification extends DDSpecification { + + def 'should create rule with output configuration'() { + setup: + def tags = [type: 'sql_injection', category: 'attack_attempt'] + def attributes = [ + 'appsec.rule.id': WAFResultData.AttributeValue.literal('test-rule-123'), + 'appsec.confidence': WAFResultData.AttributeValue.literal(0.95) + ] + def output = new WAFResultData.Output(true, false, attributes) + + when: + def rule = new WAFResultData.Rule() + rule.id = 'test-rule-123' + rule.name = 'Test Rule' + rule.tags = tags + rule.output = output + + then: + rule.id == 'test-rule-123' + rule.name == 'Test Rule' + rule.tags == tags + rule.output == output + rule.shouldKeepTrace() + !rule.shouldGenerateEvents() + rule.getAttributes() == attributes + } + + def 'should create rule without output configuration'() { + setup: + def tags = [type: 'xss', category: 'attack_attempt'] + + when: + def rule = new WAFResultData.Rule() + rule.id = 'test-rule-456' + rule.name = 'Test Rule 2' + rule.tags = tags + rule.output = null + + then: + rule.id == 'test-rule-456' + rule.name == 'Test Rule 2' + rule.tags == tags + rule.output == null + rule.shouldKeepTrace() // Default to true when output is null + rule.shouldGenerateEvents() // Default to true when output is null + rule.getAttributes() == null + } + + def 'should handle output with null values'() { + setup: + def output = new WAFResultData.Output(null, null, null) + + when: + def rule = new WAFResultData.Rule() + rule.id = 'test-rule-789' + rule.name = 'Test Rule 3' + rule.tags = [:] + rule.output = output + + then: + rule.shouldKeepTrace() // Default to true when keep is null + rule.shouldGenerateEvents() // Default to true when event is null + rule.getAttributes() == null + } + + def 'should handle output with partial configuration'() { + setup: + def attributes = ['appsec.attack.type': WAFResultData.AttributeValue.literal('sql_injection')] + def output = new WAFResultData.Output(true, true, attributes) + + when: + def rule = new WAFResultData.Rule() + rule.id = 'test-rule-partial' + rule.name = 'Test Rule Partial' + rule.tags = [:] + rule.output = output + + then: + rule.shouldKeepTrace() + rule.shouldGenerateEvents() + rule.getAttributes() == attributes + } + + def 'should handle empty attributes map'() { + setup: + def output = new WAFResultData.Output(false, true, [:]) + + when: + def rule = new WAFResultData.Rule() + rule.id = 'test-rule-empty' + rule.name = 'Test Rule Empty' + rule.tags = [:] + rule.output = output + + then: + !rule.shouldKeepTrace() + rule.shouldGenerateEvents() + rule.getAttributes() == [:] + } + + def 'should create literal attribute values'() { + when: + def stringValue = WAFResultData.AttributeValue.literal('test-string') + def numberValue = WAFResultData.AttributeValue.literal(42) + def booleanValue = WAFResultData.AttributeValue.literal(true) + def nullValue = WAFResultData.AttributeValue.literal(null) + + then: + stringValue.isLiteral() + stringValue.getLiteralValue() == 'test-string' + numberValue.isLiteral() + numberValue.getLiteralValue() == 42 + booleanValue.isLiteral() + booleanValue.getLiteralValue() == true + nullValue.isLiteral() + nullValue.getLiteralValue() == null + } + + def 'should reject non-scalar literal values'() { + when: + WAFResultData.AttributeValue.literal([1, 2, 3]) + + then: + thrown(IllegalArgumentException) + } + + def 'should create request data attribute values'() { + setup: + def keyPath = ['user', 'name'] + def transformers = ['lowercase'] + + when: + def attrValue = WAFResultData.AttributeValue.fromRequestData('server.request.headers', keyPath, transformers) + + then: + !attrValue.isLiteral() + attrValue.getAddress() == 'server.request.headers' + attrValue.getKeyPath() == keyPath + attrValue.getTransformers() == transformers + } + + def 'should reject null or empty address'() { + when: + WAFResultData.AttributeValue.fromRequestData(null, [], []) + + then: + thrown(IllegalArgumentException) + + when: + WAFResultData.AttributeValue.fromRequestData('', [], []) + + then: + thrown(IllegalArgumentException) + + when: + WAFResultData.AttributeValue.fromRequestData(' ', [], []) + + then: + thrown(IllegalArgumentException) + } + + def 'should reject forbidden addresses'() { + when: + WAFResultData.AttributeValue.fromRequestData('usr.session_id', [], []) + + then: + thrown(IllegalArgumentException) + + when: + WAFResultData.AttributeValue.fromRequestData('server.request.cookies', [], []) + + then: + thrown(IllegalArgumentException) + } + + def 'should check forbidden addresses'() { + expect: + WAFResultData.isForbiddenAddress('usr.session_id') + WAFResultData.isForbiddenAddress('server.request.cookies') + !WAFResultData.isForbiddenAddress('server.request.headers') + !WAFResultData.isForbiddenAddress('server.request.body') + } + + def 'should get forbidden addresses set'() { + when: + def forbiddenAddresses = WAFResultData.getForbiddenAddresses() + + then: + forbiddenAddresses.contains('usr.session_id') + forbiddenAddresses.contains('server.request.cookies') + forbiddenAddresses.size() == 2 + } + + def 'should handle attribute value toString'() { + setup: + def literalValue = WAFResultData.AttributeValue.literal('test') + def requestDataValue = WAFResultData.AttributeValue.fromRequestData('server.request.headers', ['user-agent'], ['lowercase']) + + expect: + literalValue.toString().contains('literal=test') + requestDataValue.toString().contains("address='server.request.headers'") + requestDataValue.toString().contains('keyPath=[user-agent]') + requestDataValue.toString().contains('transformers=[lowercase]') + } +} \ No newline at end of file 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 7cddcd62523..d2e4f649aea 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 @@ -904,7 +904,7 @@ class WAFModuleSpecification extends DDSpecification { ChangeableFlow flow = new ChangeableFlow() TraceSegment segment = Mock() - TraceSegmentPostProcessor pp = service.traceSegmentPostProcessors.last() + TraceSegmentPostProcessor pp = service.traceSegmentPostProcessors[1] when: dataListener.onDataAvailable(flow, ctx, db, gwCtx) @@ -939,7 +939,7 @@ class WAFModuleSpecification extends DDSpecification { ChangeableFlow flow = new ChangeableFlow() TraceSegment segment = Mock() - TraceSegmentPostProcessor pp = service.traceSegmentPostProcessors.last() + TraceSegmentPostProcessor pp = service.traceSegmentPostProcessors[1] gwCtx = new GatewayContext(false, RuleType.SQL_INJECTION) diff --git a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/event/data/KnownAddressesSpecificationForkedTest.groovy b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/event/data/KnownAddressesSpecificationForkedTest.groovy index e634a2bf978..edca0542a43 100644 --- a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/event/data/KnownAddressesSpecificationForkedTest.groovy +++ b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/event/data/KnownAddressesSpecificationForkedTest.groovy @@ -45,13 +45,24 @@ class KnownAddressesSpecificationForkedTest extends Specification { 'server.io.fs.file', 'server.sys.exec.cmd', 'server.sys.shell.cmd', - 'waf.context.processor' + 'waf.context.processor', + 'server.request.jwt', + 'server.request.jwt.header', + 'server.request.jwt.payload', + 'server.request.jwt.algorithm', + 'server.request.jwt.issuer', + 'server.request.jwt.subject', + 'server.request.jwt.audience', + 'server.request.jwt.expiration', + 'server.request.jwt.issued_at', + 'server.request.jwt.custom_claims' ] } void 'number of known addresses is expected number'() { expect: - Address.instanceCount() == 39 - KnownAddresses.WAF_CONTEXT_PROCESSOR.serial == Address.instanceCount() - 1 + Address.instanceCount() == 49 + KnownAddresses.WAF_CONTEXT_PROCESSOR.serial == Address.instanceCount() - 2 + KnownAddresses.REQUEST_JWT.serial == Address.instanceCount() - 1 } } diff --git a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/GatewayBridgeSpecification.groovy b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/GatewayBridgeSpecification.groovy index 38eaf9f1208..e07940d2a6c 100644 --- a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/GatewayBridgeSpecification.groovy +++ b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/GatewayBridgeSpecification.groovy @@ -1355,4 +1355,103 @@ class GatewayBridgeSpecification extends DDSpecification { } } + void 'test JWT token extraction from Authorization header'() { + setup: + eventDispatcher.getDataSubscribers(KnownAddresses.REQUEST_JWT) >> nonEmptyDsInfo + DataBundle jwtBundle + GatewayContext gwCtx + + when: + reqHeaderCB.accept(ctx, 'Authorization', 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c') + + then: + 1 * eventDispatcher.publishDataEvent(nonEmptyDsInfo, ctx.data, _, _) >> { args -> + jwtBundle = args[2] + gwCtx = args[3] + NoopFlow.INSTANCE + } + + jwtBundle != null + jwtBundle.hasAddress(KnownAddresses.REQUEST_JWT) + gwCtx.isTransient == false + gwCtx.isRasp == false + + def jwt = jwtBundle.get(KnownAddresses.REQUEST_JWT) as Map + jwt != null + jwt.containsKey("header") + jwt.containsKey("payload") + jwt.containsKey("signature") + + def header = jwt.get("header") as Map + def payload = jwt.get("payload") as Map + def signature = jwt.get("signature") as Map + + header.get("alg") == "HS256" + header.get("typ") == "JWT" + payload.get("sub") == "1234567890" + payload.get("name") == "John Doe" + payload.get("iat") == 1516239022L + signature.get("available") == true + } + + void 'test JWT token extraction with different Bearer formats'() { + setup: + eventDispatcher.getDataSubscribers(KnownAddresses.REQUEST_JWT) >> nonEmptyDsInfo + + when: + reqHeaderCB.accept(ctx, 'Authorization', 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c') + + then: + 1 * eventDispatcher.publishDataEvent(nonEmptyDsInfo, ctx.data, _, _) >> NoopFlow.INSTANCE + + when: + reqHeaderCB.accept(ctx, 'Authorization', 'bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c') + + then: + 1 * eventDispatcher.publishDataEvent(nonEmptyDsInfo, ctx.data, _, _) >> NoopFlow.INSTANCE + + when: + reqHeaderCB.accept(ctx, 'Authorization', 'BEARER eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c') + + then: + 1 * eventDispatcher.publishDataEvent(nonEmptyDsInfo, ctx.data, _, _) >> NoopFlow.INSTANCE + } + + void 'test JWT token extraction ignores non-Bearer Authorization headers'() { + setup: + eventDispatcher.getDataSubscribers(KnownAddresses.REQUEST_JWT) >> nonEmptyDsInfo + + when: + reqHeaderCB.accept(ctx, 'Authorization', 'Basic dXNlcjpwYXNz') + reqHeaderCB.accept(ctx, 'Authorization', 'Digest username="user", realm="example.com"') + reqHeaderCB.accept(ctx, 'Authorization', 'CustomScheme token123') + + then: + 0 * eventDispatcher.publishDataEvent(nonEmptyDsInfo, ctx.data, _, _) + } + + void 'test JWT token extraction handles invalid tokens gracefully'() { + setup: + eventDispatcher.getDataSubscribers(KnownAddresses.REQUEST_JWT) >> nonEmptyDsInfo + + when: + reqHeaderCB.accept(ctx, 'Authorization', 'Bearer invalid.token.here') + reqHeaderCB.accept(ctx, 'Authorization', 'Bearer not.a.valid.jwt') + reqHeaderCB.accept(ctx, 'Authorization', 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9') + + then: + 0 * eventDispatcher.publishDataEvent(nonEmptyDsInfo, ctx.data, _, _) + } + + void 'test JWT token extraction with no subscribers'() { + setup: + eventDispatcher.getDataSubscribers(KnownAddresses.REQUEST_JWT) >> emptyDsInfo + + when: + reqHeaderCB.accept(ctx, 'Authorization', 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c') + + then: + 0 * eventDispatcher.publishDataEvent(_, _, _, _) + } + } diff --git a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/JwtExtractionUnitTest.groovy b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/JwtExtractionUnitTest.groovy new file mode 100644 index 00000000000..5b5f45066f9 --- /dev/null +++ b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/JwtExtractionUnitTest.groovy @@ -0,0 +1,211 @@ +package com.datadog.appsec.gateway + +import com.datadog.appsec.event.data.DataBundle +import com.datadog.appsec.event.data.KnownAddresses +import datadog.trace.api.gateway.SubscriptionService +import com.datadog.appsec.event.EventProducerService +import com.datadog.appsec.api.security.ApiSecuritySampler +import datadog.trace.test.util.DDSpecification + +/** + * Unit tests for JWT extraction logic in GatewayBridge. + * Tests private methods through reflection to ensure correct implementation. + */ +class JwtExtractionUnitTest extends DDSpecification { + + def 'test extractJwtToken method handles valid Bearer tokens'() { + given: + def bridge = new GatewayBridge(Mock(SubscriptionService), Mock(EventProducerService), { Mock(ApiSecuritySampler) } as java.util.function.Supplier, []) + def extractMethod = GatewayBridge.getDeclaredMethod('extractJwtToken', String) + extractMethod.setAccessible(true) + + when: + def result1 = extractMethod.invoke(bridge, 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c') + def result2 = extractMethod.invoke(bridge, 'bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c') + def result3 = extractMethod.invoke(bridge, 'BEARER eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c') + + then: + result1 == 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c' + result2 == 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c' + result3 == 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c' + } + + def 'test extractJwtToken method handles invalid or non-Bearer tokens'() { + given: + def bridge = new GatewayBridge(Mock(SubscriptionService), Mock(EventProducerService), { Mock(ApiSecuritySampler) } as java.util.function.Supplier, []) + def extractMethod = GatewayBridge.getDeclaredMethod('extractJwtToken', String) + extractMethod.setAccessible(true) + + when: + def result2 = extractMethod.invoke(bridge, '') + def result3 = extractMethod.invoke(bridge, 'Basic dXNlcjpwYXNz') + def result4 = extractMethod.invoke(bridge, 'Bearer') + def result5 = extractMethod.invoke(bridge, 'Bearer ') + def result6 = extractMethod.invoke(bridge, 'CustomScheme token123') + + then: + result2 == null + result3 == null + result4 == null + result5 == null + result6 == null + } + + def 'test extractJwtToken method handles edge cases'() { + given: + def bridge = new GatewayBridge(Mock(SubscriptionService), Mock(EventProducerService), { Mock(ApiSecuritySampler) } as java.util.function.Supplier, []) + def extractMethod = GatewayBridge.getDeclaredMethod('extractJwtToken', String) + extractMethod.setAccessible(true) + + when: + def result1 = extractMethod.invoke(bridge, 'Bearer') + def result2 = extractMethod.invoke(bridge, 'Bearer ') + def result3 = extractMethod.invoke(bridge, 'Bearer ') + def result4 = extractMethod.invoke(bridge, 'Bearer token') + def result5 = extractMethod.invoke(bridge, 'Bearer token ') + + then: + result1 == null + result2 == null + result3 == null + result4 == 'token' + result5 == 'token' + } + + def 'test processJwtToken method processes valid JWT correctly'() { + given: + def bridge = new GatewayBridge(Mock(SubscriptionService), Mock(EventProducerService), { Mock(ApiSecuritySampler) } as java.util.function.Supplier, []) + def processMethod = GatewayBridge.getDeclaredMethod('processJwtToken', AppSecRequestContext, String) + processMethod.setAccessible(true) + def appSecCtx = new AppSecRequestContext() + def validJwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c' + + when: + processMethod.invoke(bridge, appSecCtx, validJwt) + + then: + appSecCtx.hasAddress(KnownAddresses.REQUEST_JWT) + def jwt = appSecCtx.get(KnownAddresses.REQUEST_JWT) as Map + jwt != null + jwt.containsKey("header") + jwt.containsKey("payload") + jwt.containsKey("signature") + } + + def 'test processJwtToken method handles invalid JWT gracefully'() { + given: + def bridge = new GatewayBridge(Mock(SubscriptionService), Mock(EventProducerService), { Mock(ApiSecuritySampler) } as java.util.function.Supplier, []) + def processMethod = GatewayBridge.getDeclaredMethod('processJwtToken', AppSecRequestContext, String) + processMethod.setAccessible(true) + def appSecCtx = new AppSecRequestContext() + + when: + processMethod.invoke(bridge, appSecCtx, 'invalid.jwt.token') + + then: + !appSecCtx.hasAddress(KnownAddresses.REQUEST_JWT) + noExceptionThrown() + } + + def 'test publishJwtData method publishes data when subscribers exist'() { + given: + def eventDispatcher = Mock(EventProducerService) + def bridge = new GatewayBridge(Mock(SubscriptionService), eventDispatcher, { Mock(ApiSecuritySampler) } as java.util.function.Supplier, []) + def publishMethod = GatewayBridge.getDeclaredMethod('publishJwtData', AppSecRequestContext, DataBundle) + publishMethod.setAccessible(true) + def appSecCtx = new AppSecRequestContext() + def jwtBundle = com.datadog.appsec.event.data.MapDataBundle.of(KnownAddresses.REQUEST_JWT, [test: 'data']) + + when: + publishMethod.invoke(bridge, appSecCtx, jwtBundle) + + then: + 1 * eventDispatcher.getDataSubscribers(KnownAddresses.REQUEST_JWT) >> { + def info = Stub(EventProducerService.DataSubscriberInfo) + info.empty >> false + info + } + 1 * eventDispatcher.publishDataEvent(_, appSecCtx, jwtBundle, _) >> NoopFlow.INSTANCE + } + + def 'test publishJwtData method does not publish when no subscribers exist'() { + given: + def eventDispatcher = Mock(EventProducerService) + def bridge = new GatewayBridge(Mock(SubscriptionService), eventDispatcher, { Mock(ApiSecuritySampler) } as java.util.function.Supplier, []) + def publishMethod = GatewayBridge.getDeclaredMethod('publishJwtData', AppSecRequestContext, DataBundle) + publishMethod.setAccessible(true) + def appSecCtx = new AppSecRequestContext() + def jwtBundle = com.datadog.appsec.event.data.MapDataBundle.of(KnownAddresses.REQUEST_JWT, [test: 'data']) + + when: + publishMethod.invoke(bridge, appSecCtx, jwtBundle) + + then: + 1 * eventDispatcher.getDataSubscribers(KnownAddresses.REQUEST_JWT) >> { + def info = Stub(EventProducerService.DataSubscriberInfo) + info.empty >> true + info + } + 0 * eventDispatcher.publishDataEvent(_, _, _, _) + } + + def 'test JWT extraction follows RFC-6750 Bearer token format'() { + given: + def bridge = new GatewayBridge(Mock(SubscriptionService), Mock(EventProducerService), { Mock(ApiSecuritySampler) } as java.util.function.Supplier, []) + def extractMethod = GatewayBridge.getDeclaredMethod('extractJwtToken', String) + extractMethod.setAccessible(true) + + // Test cases from RFC-6750 examples + when: + def result1 = extractMethod.invoke(bridge, 'Bearer mF_9.B5f-4.1JqM') + def result2 = extractMethod.invoke(bridge, 'Bearer mF_9.B5f-4.1JqM ') + def result3 = extractMethod.invoke(bridge, ' Bearer mF_9.B5f-4.1JqM') + def result4 = extractMethod.invoke(bridge, 'Bearer') + + then: + result1 == 'mF_9.B5f-4.1JqM' + result2 == 'mF_9.B5f-4.1JqM' + result3 == 'mF_9.B5f-4.1JqM' // leading space is trimmed by implementation + result4 == null + } + + def 'test JWT extraction is case insensitive for Bearer scheme'() { + given: + def bridge = new GatewayBridge(Mock(SubscriptionService), Mock(EventProducerService), { Mock(ApiSecuritySampler) } as java.util.function.Supplier, []) + def extractMethod = GatewayBridge.getDeclaredMethod('extractJwtToken', String) + extractMethod.setAccessible(true) + + when: + def result1 = extractMethod.invoke(bridge, 'Bearer token') + def result2 = extractMethod.invoke(bridge, 'bearer token') + def result3 = extractMethod.invoke(bridge, 'BEARER token') + def result4 = extractMethod.invoke(bridge, 'BeArEr token') + + then: + result1 == 'token' + result2 == 'token' + result3 == 'token' + result4 == 'token' + } + + def 'test JWT extraction handles whitespace correctly'() { + given: + def bridge = new GatewayBridge(Mock(SubscriptionService), Mock(EventProducerService), { Mock(ApiSecuritySampler) } as java.util.function.Supplier, []) + def extractMethod = GatewayBridge.getDeclaredMethod('extractJwtToken', String) + extractMethod.setAccessible(true) + + when: + def result1 = extractMethod.invoke(bridge, 'Bearer token') + def result2 = extractMethod.invoke(bridge, 'Bearer token') + def result3 = extractMethod.invoke(bridge, 'Bearer token ') + def result4 = extractMethod.invoke(bridge, ' Bearer token ') + def result5 = extractMethod.invoke(bridge, ' Bearer token ') + + then: + result1 == 'token' + result2 == 'token' + result3 == 'token' + result4 == 'token' + result5 == 'token' + } +} diff --git a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/util/JwtPreprocessorTest.groovy b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/util/JwtPreprocessorTest.groovy new file mode 100644 index 00000000000..b6255a25805 --- /dev/null +++ b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/util/JwtPreprocessorTest.groovy @@ -0,0 +1,181 @@ +package com.datadog.appsec.util + +import com.datadog.appsec.event.data.KnownAddresses +import com.datadog.appsec.event.data.DataBundle +import spock.lang.Specification +import spock.lang.Unroll + +class JwtPreprocessorTest extends Specification { + + @Unroll + def "test JWT processing with #scenario"() { + when: + DataBundle result = JwtPreprocessor.processJwt(jwtToken) + + then: + result != null + result.hasAddress(KnownAddresses.REQUEST_JWT) + + def jwt = result.get(KnownAddresses.REQUEST_JWT) as Map + jwt != null + jwt.containsKey("header") + jwt.containsKey("payload") + jwt.containsKey("signature") + + def header = jwt.get("header") as Map + def payload = jwt.get("payload") as Map + def signature = jwt.get("signature") as Map + + header != null + payload != null + signature != null + signature.containsKey("available") + + // Verify specific claims if provided + if (expectedAlg) { + header.get("alg") == expectedAlg + } + if (expectedIss) { + payload.get("iss") == expectedIss + } + if (expectedSub) { + payload.get("sub") == expectedSub + } + if (expectedExp) { + payload.get("exp") == expectedExp + } + + where: + scenario | jwtToken | expectedAlg | expectedIss | expectedSub | expectedExp + "valid JWT with all claims" | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyMzkwMjMsImlzcyI6Imh0dHA6Ly9leGFtcGxlLmNvbSJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" | "HS256" | "http://example.com" | "1234567890" | 1516239023L + "valid JWT with minimal claims" | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" | "HS256" | null | "1234567890" | null + "valid JWT with custom claims" | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwicm9sZSI6ImFkbWluIiwiZGVwYXJ0bWVudCI6ImVuZ2luZWVyaW5nIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" | "HS256" | null | "1234567890" | null + } + + @Unroll + def "test invalid JWT handling with #scenario"() { + when: + DataBundle result = JwtPreprocessor.processJwt(jwtToken) + + then: + result == null + + where: + scenario | jwtToken + "null token" | null + "empty string" | "" + "single part" | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + "invalid base64" | "invalid.token.here" + "malformed JWT" | "not.a.valid.jwt" + "too many parts" | "part1.part2.part3.part4" + "non-JWT string" | "this.is.not.a.jwt" + } + + def "test JWT with numeric expiration"() { + given: + def jwtToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjoxNTE2MjM5MDIzfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + + when: + DataBundle result = JwtPreprocessor.processJwt(jwtToken) + + then: + result != null + def jwt = result.get(KnownAddresses.REQUEST_JWT) as Map + def payload = jwt.get("payload") as Map + payload.get("exp") == 1516239023L + payload.get("exp") instanceof Long + } + + def "test JWT with string expiration"() { + given: + def jwtToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjoiMTUxNjIzOTAyMyJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + + when: + DataBundle result = JwtPreprocessor.processJwt(jwtToken) + + then: + result != null + def jwt = result.get(KnownAddresses.REQUEST_JWT) as Map + def payload = jwt.get("payload") as Map + payload.get("exp") == 1516239023L + payload.get("exp") instanceof Long + } + + def "test JWT structure verification"() { + given: + def jwtToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + + when: + DataBundle result = JwtPreprocessor.processJwt(jwtToken) + + then: + result != null + result.size() == 1 + result.hasAddress(KnownAddresses.REQUEST_JWT) + + def jwt = result.get(KnownAddresses.REQUEST_JWT) as Map + jwt.size() == 3 + jwt.containsKey("header") + jwt.containsKey("payload") + jwt.containsKey("signature") + + def header = jwt.get("header") as Map + def payload = jwt.get("payload") as Map + def signature = jwt.get("signature") as Map + + header.get("alg") == "HS256" + header.get("typ") == "JWT" + + payload.get("sub") == "1234567890" + payload.get("name") == "John Doe" + payload.get("iat") == 1516239022L + + signature.get("available") == true + } + + def "test JWT with no signature (two parts)"() { + given: + // This is a valid unsecured JWT (rare but valid according to RFC) + def jwtToken = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIn0" + + when: + DataBundle result = JwtPreprocessor.processJwt(jwtToken) + + then: + result != null + def jwt = result.get(KnownAddresses.REQUEST_JWT) as Map + jwt.containsKey("header") + jwt.containsKey("payload") + jwt.containsKey("signature") + + def header = jwt.get("header") as Map + header.get("alg") == "none" + header.get("typ") == "JWT" + + def payload = jwt.get("payload") as Map + payload.get("sub") == "1234567890" + + def signature = jwt.get("signature") as Map + signature.get("available") == false + } + + def "test JWT with complex nested payload"() { + given: + def jwtToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibWV0YWRhdGEiOnsicm9sZSI6ImFkbWluIiwicGVybWlzc2lvbnMiOlsicmVhZCIsIndyaXRlIl19fQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + + when: + DataBundle result = JwtPreprocessor.processJwt(jwtToken) + + then: + result != null + def jwt = result.get(KnownAddresses.REQUEST_JWT) as Map + def payload = jwt.get("payload") as Map + + payload.get("sub") == "1234567890" + payload.containsKey("metadata") + + def metadata = payload.get("metadata") as Map + metadata.get("role") == "admin" + metadata.get("permissions") == ["read", "write"] + } +} \ No newline at end of file diff --git a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/util/JwtSpecificationComplianceTest.groovy b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/util/JwtSpecificationComplianceTest.groovy new file mode 100644 index 00000000000..47f0f228140 --- /dev/null +++ b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/util/JwtSpecificationComplianceTest.groovy @@ -0,0 +1,253 @@ +package com.datadog.appsec.util + +import com.datadog.appsec.event.data.KnownAddresses +import com.datadog.appsec.event.data.DataBundle +import spock.lang.Specification + +/** + * Test to verify JWT preprocessor compliance with the technical specification. + * Tests the exact schema and example provided in the specification. + */ +class JwtSpecificationComplianceTest extends Specification { + + void 'test JWT structure matches technical specification exactly'() { + given: + // Example JWT from the technical specification + def jwtToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + + when: + DataBundle result = JwtPreprocessor.processJwt(jwtToken) + + then: + result != null + result.hasAddress(KnownAddresses.REQUEST_JWT) + result.size() == 1 + + def jwt = result.get(KnownAddresses.REQUEST_JWT) as Map + jwt != null + + // Verify the structure matches the technical specification schema + jwt.size() == 3 + jwt.containsKey("header") + jwt.containsKey("payload") + jwt.containsKey("signature") + + // Verify header structure matches spec + def header = jwt.get("header") as Map + header.size() == 2 + header.get("alg") == "HS256" + header.get("typ") == "JWT" + + // Verify payload structure matches spec - preserve original JSON types + def payload = jwt.get("payload") as Map + payload.size() == 3 + payload.get("sub") == "1234567890" + payload.get("name") == "John Doe" + payload.get("iat") == 1516239022L + + // Verify signature structure matches spec + def signature = jwt.get("signature") as Map + signature.size() == 1 + signature.containsKey("available") + signature.get("available") == true + } + + void 'test JWT address matches technical specification'() { + expect: + KnownAddresses.REQUEST_JWT.getKey() == "server.request.jwt" + } + + void 'test JWT structure schema matches specification requirements'() { + given: + def jwtToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + + when: + DataBundle result = JwtPreprocessor.processJwt(jwtToken) + + then: + result != null + def jwt = result.get(KnownAddresses.REQUEST_JWT) as Map + + // Verify the schema matches the specification: + // { + // "header": { ... }, + // "payload": { ... }, + // "signature": { + // "available": true|false + // } + // } + + jwt instanceof Map + jwt.get("header") instanceof Map + jwt.get("payload") instanceof Map + jwt.get("signature") instanceof Map + + def signature = jwt.get("signature") as Map + signature.get("available") instanceof Boolean + } + + void 'test JWT with no signature (unsecured JWT) matches specification'() { + given: + // Unsecured JWT with alg: "none" - valid according to RFC + def jwtToken = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIn0" + + when: + DataBundle result = JwtPreprocessor.processJwt(jwtToken) + + then: + result != null + def jwt = result.get(KnownAddresses.REQUEST_JWT) as Map + + def header = jwt.get("header") as Map + header.get("alg") == "none" + header.get("typ") == "JWT" + + def payload = jwt.get("payload") as Map + payload.get("sub") == "1234567890" + + def signature = jwt.get("signature") as Map + signature.get("available") == false + } + + void 'test JWT structure allows access to components via key path'() { + given: + def jwtToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + + when: + DataBundle result = JwtPreprocessor.processJwt(jwtToken) + + then: + result != null + def jwt = result.get(KnownAddresses.REQUEST_JWT) as Map + + // Verify that components can be accessed via key path as specified: + // server.request.jwt::header.alg + // server.request.jwt::payload.exp + def header = jwt.get("header") as Map + def payload = jwt.get("payload") as Map + def signature = jwt.get("signature") as Map + + // Test key path access simulation - preserve original JSON types + header.get("alg") == "HS256" + header.get("typ") == "JWT" + payload.get("sub") == "1234567890" + payload.get("name") == "John Doe" + payload.get("iat") == 1516239022L + signature.get("available") == true + } + + void 'test JWT structure preserves all original claims'() { + given: + // JWT with various standard and custom claims + def jwtToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyMzkwMjMsImlzcyI6Imh0dHA6Ly9leGFtcGxlLmNvbSIsImF1ZCI6WyJhcHAxIiwiYXBwMiJdLCJqdGkiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5eiIsIm5iZiI6MTUxNjIzOTAyMSwiY3VzdG9tX2NsYWltIjoiY3VzdG9tX3ZhbHVlIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + + when: + DataBundle result = JwtPreprocessor.processJwt(jwtToken) + + then: + result != null + def jwt = result.get(KnownAddresses.REQUEST_JWT) as Map + def payload = jwt.get("payload") as Map + + // Verify all standard claims are preserved with original JSON types + payload.get("sub") == "1234567890" + payload.get("name") == "John Doe" + payload.get("iat") == 1516239022L + payload.get("exp") == 1516239023L + payload.get("iss") == "http://example.com" + payload.get("aud") == ["app1", "app2"] + payload.get("jti") == "abcdefghijklmnopqrstuvwxyz" + payload.get("nbf") == 1516239021L + + // Verify custom claims are preserved + payload.get("custom_claim") == "custom_value" + } + + void 'test JWT structure handles numeric claims correctly'() { + given: + // JWT with various numeric claim formats + def jwtToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyMzkwMjMsIm5iZiI6MTUxNjIzOTAyMSwiZXhwaXJhdGlvbl9zdHJpbmciOiIxNTE2MjM5MDIzIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + + when: + DataBundle result = JwtPreprocessor.processJwt(jwtToken) + + then: + result != null + def jwt = result.get(KnownAddresses.REQUEST_JWT) as Map + def payload = jwt.get("payload") as Map + + // Verify numeric claims preserve original JSON types + payload.get("iat") == 1516239022L + payload.get("iat") instanceof Long + payload.get("exp") == 1516239023L + payload.get("exp") instanceof Long + payload.get("nbf") == 1516239021L + payload.get("nbf") instanceof Long + + // Verify string values remain strings (no conversion) + payload.get("expiration_string") == "1516239023" + payload.get("expiration_string") instanceof String + } + + void 'test JWT structure handles complex nested objects'() { + given: + // JWT with complex nested payload - fixed to have valid JSON + def jwtToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibWV0YWRhdGEiOnsicm9sZSI6ImFkbWluIiwicGVybWlzc2lvbnMiOlsicmVhZCIsIndyaXRlIl0sInVzZXJfaW5mbyI6eyJpZCI6MTIzNDU2Nzg5MCwibmFtZSI6IkpvaG4gRG9lIiwiZW1haWwiOiJqb2huQGV4YW1wbGUuY29tIiwicHJvZmlsZSI6eyJhdmF0YXIiOiJodHRwczovL2V4YW1wbGUuY29tL2F2YXRhci5qcGciLCJsb2NhdGlvbiI6Ik5ldyBZb3JrIn19fX0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + + when: + DataBundle result = JwtPreprocessor.processJwt(jwtToken) + + then: + result != null + def jwt = result.get(KnownAddresses.REQUEST_JWT) as Map + def payload = jwt.get("payload") as Map + + // Verify nested objects are preserved + payload.containsKey("metadata") + payload.containsKey("sub") + + def metadata = payload.get("metadata") as Map + metadata.get("role") == "admin" + metadata.get("permissions") == ["read", "write"] + metadata.containsKey("user_info") + + def userInfo = metadata.get("user_info") as Map + userInfo.get("id") == 1234567890L + userInfo.get("name") == "John Doe" + userInfo.get("email") == "john@example.com" + + def profile = userInfo.get("profile") as Map + profile.get("avatar") == "https://example.com/avatar.jpg" + profile.get("location") == "New York" + } + + void 'test JWT structure handles decimal numeric claims correctly'() { + given: + // JWT with decimal numeric claims as strings + def jwtToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwicHJpY2UiOiIzLjE0IiwicmF0aW5nIjoiNC41IiwicGVyY2VudGFnZSI6IjEwMC4wIiwid2hvbGVfbnVtYmVyIjoiNDIiLCJub25fbnVtYmVyIjoibm90X2FfbnVtYmVyIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + + when: + DataBundle result = JwtPreprocessor.processJwt(jwtToken) + + then: + result != null + def jwt = result.get(KnownAddresses.REQUEST_JWT) as Map + def payload = jwt.get("payload") as Map + + // Verify string values remain strings (no conversion) + payload.get("price") == "3.14" + payload.get("price") instanceof String + payload.get("rating") == "4.5" + payload.get("rating") instanceof String + payload.get("percentage") == "100.0" + payload.get("percentage") instanceof String + + // Verify string values remain strings (no conversion) + payload.get("whole_number") == "42" + payload.get("whole_number") instanceof String + + // Verify non-numeric strings remain as strings + payload.get("non_number") == "not_a_number" + payload.get("non_number") instanceof String + } +} diff --git a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/util/JwtTraceTaggerTest.groovy b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/util/JwtTraceTaggerTest.groovy new file mode 100644 index 00000000000..fe2cee8bbdf --- /dev/null +++ b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/util/JwtTraceTaggerTest.groovy @@ -0,0 +1,171 @@ +package com.datadog.appsec.util + +import datadog.trace.bootstrap.instrumentation.api.AgentSpan +import datadog.trace.bootstrap.instrumentation.api.AgentTracer +import spock.lang.Specification + +class JwtTraceTaggerTest extends Specification { + + def "should tag JWT claims from derivatives"() { + given: + def mockSpan = Mock(AgentSpan) + + def jwtJson = ''' + { + "header": { + "alg": "HS256", + "typ": "JWT" + }, + "payload": { + "sub": "user123", + "exp": 1640995200, + "iat": 1640908800, + "iss": "example.com" + }, + "signature": { + "available": true + } + } + ''' + + // Parse the JWT JSON as the production code does + def moshi = new com.squareup.moshi.Moshi.Builder().build() + def adapter = moshi.adapter(Map) + def decodedJwt = adapter.fromJson(jwtJson) + + when: + JwtTraceTagger.tagJwtClaims(decodedJwt, mockSpan) + + then: + 1 * mockSpan.setTag("jwt.header.alg", "HS256") + 1 * mockSpan.setTag("jwt.header.typ", "JWT") + 1 * mockSpan.setTag("jwt.payload.sub", "user123") + 1 * mockSpan.setTag("jwt.payload.exp", "1640995200") + 1 * mockSpan.setTag("jwt.signature.available", "true") + 0 * mockSpan.setTag(_, _) + } + + def "debug test - should understand what's happening"() { + given: + def mockSpan = Mock(AgentSpan) + AgentTracer.metaClass.static.activeSpan = { -> mockSpan } + + def jwtJson = ''' + { + "header": { + "alg": "HS256", + "typ": "JWT" + }, + "payload": { + "sub": "user123", + "exp": 1640995200, + "iat": 1640908800, + "iss": "example.com" + }, + "signature": { + "available": true + } + } + ''' + + def derivatives = ["server.request.jwt": jwtJson] + + when: + println "JWT JSON: ${jwtJson}" + println "Derivatives: ${derivatives}" + println "Active span before call: ${AgentTracer.activeSpan()}" + + JwtTraceTagger.tagJwtClaimsFromDerivatives(derivatives) + + println "Active span after call: ${AgentTracer.activeSpan()}" + + then: + // Just verify the method doesn't throw an exception + noExceptionThrown() + } + + def "should handle missing JWT in derivatives"() { + given: + def mockSpan = Mock(AgentSpan) + + AgentTracer.metaClass.static.activeSpan = { -> mockSpan } + + def derivatives = [:] + + when: + JwtTraceTagger.tagJwtClaimsFromDerivatives(derivatives) + + then: + 0 * mockSpan.setTag(_, _) + } + + def "should handle invalid JSON"() { + given: + def mockSpan = Mock(AgentSpan) + + AgentTracer.metaClass.static.activeSpan = { -> mockSpan } + + def derivatives = ["server.request.jwt": "invalid json"] + + when: + JwtTraceTagger.tagJwtClaimsFromDerivatives(derivatives) + + then: + 0 * mockSpan.setTag(_, _) + } + + def "should handle missing fields in JWT"() { + given: + def mockSpan = Mock(AgentSpan) + + def jwtJson = ''' + { + "header": { + "alg": "HS256" + }, + "payload": { + "sub": "user123" + }, + "signature": {} + } + ''' + + // Parse the JWT JSON as the production code does + def moshi = new com.squareup.moshi.Moshi.Builder().build() + def adapter = moshi.adapter(Map) + def decodedJwt = adapter.fromJson(jwtJson) + + when: + JwtTraceTagger.tagJwtClaims(decodedJwt, mockSpan) + + then: + 1 * mockSpan.setTag("jwt.header.alg", "HS256") + 1 * mockSpan.setTag("jwt.payload.sub", "user123") + 0 * mockSpan.setTag(_, _) + } + + def "should handle null span"() { + given: + AgentTracer.metaClass.static.activeSpan = { -> null } + + def jwtJson = ''' + { + "header": { + "alg": "HS256", + "typ": "JWT" + }, + "payload": { + "sub": "user123" + } + } + ''' + + def derivatives = ["server.request.jwt": jwtJson] + + when: + JwtTraceTagger.tagJwtClaimsFromDerivatives(derivatives) + + then: + noExceptionThrown() + } +} diff --git a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Capabilities.java b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Capabilities.java index b8e1124a1b2..6c237d84163 100644 --- a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Capabilities.java +++ b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Capabilities.java @@ -42,4 +42,6 @@ public interface Capabilities { long CAPABILITY_APM_TRACING_ENABLE_EXCEPTION_REPLAY = 1L << 39; long CAPABILITY_APM_TRACING_ENABLE_CODE_ORIGIN = 1L << 40; long CAPABILITY_APM_TRACING_ENABLE_LIVE_DEBUGGING = 1L << 41; + long CAPABILITY_ASM_DD_MULTICONFIG = 1L << 42; + long CAPABILITY_ASM_TRACE_TAGGING_RULES = 1L << 43; }