Skip to content

Commit 93c0a25

Browse files
committed
Collect client IP tags for AI Guard requests
When DD_AI_GUARD_ENABLED=true, resolve the client IP eagerly during HTTP server request decoration and stash it on the request context. Apply the tags (http.client_ip and network.client.ip) on the local root span only when an ai_guard span is actually created, so non-AI requests of an AI-Guard-enabled service do not get IP tags. This honors the RFC scope (PII collection limited to AI interactions) and makes the feature work without DD_TRACE_CLIENT_IP_ENABLED or AppSec. APPSEC-62199
1 parent 2297add commit 93c0a25

13 files changed

Lines changed: 355 additions & 2 deletions

File tree

dd-java-agent/agent-aiguard/src/main/java/com/datadog/aiguard/AIGuardInternal.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,12 @@
2424
import datadog.trace.api.aiguard.AIGuard.ToolCall.Function;
2525
import datadog.trace.api.aiguard.Evaluator;
2626
import datadog.trace.api.aiguard.noop.NoOpEvaluator;
27+
import datadog.trace.api.gateway.RequestContext;
2728
import datadog.trace.api.telemetry.WafMetricCollector;
2829
import datadog.trace.bootstrap.instrumentation.api.AgentScope;
2930
import datadog.trace.bootstrap.instrumentation.api.AgentSpan;
3031
import datadog.trace.bootstrap.instrumentation.api.AgentTracer;
32+
import datadog.trace.bootstrap.instrumentation.api.ClientIpAddressData;
3133
import datadog.trace.bootstrap.instrumentation.api.Tags;
3234
import java.io.IOException;
3335
import java.lang.annotation.Annotation;
@@ -213,6 +215,32 @@ private boolean isBlockingEnabled(final Options options, final Object isBlocking
213215
return options.block() && "true".equalsIgnoreCase(isBlockingEnabled.toString());
214216
}
215217

218+
/**
219+
* Applies the {@link ClientIpAddressData} captured during HTTP server request decoration to the
220+
* local root span. This is the lazy half of AI Guard client IP collection: {@code
221+
* HttpServerDecorator} resolves the IP eagerly and stashes it on the {@link RequestContext}; we
222+
* consume it here once an {@code ai_guard} span is created, so IP tags are not added to spans of
223+
* non-AI requests in services that have AI Guard enabled.
224+
*/
225+
private static void applyClientIpTags(final AgentSpan localRootSpan) {
226+
final RequestContext requestContext = localRootSpan.getRequestContext();
227+
if (requestContext == null) {
228+
return;
229+
}
230+
final ClientIpAddressData clientIpAddressData = requestContext.getAndResetClientIpAddressData();
231+
if (clientIpAddressData == null) {
232+
return;
233+
}
234+
final String peerIp = clientIpAddressData.getPeerIp();
235+
if (peerIp != null && localRootSpan.getTag(Tags.NETWORK_CLIENT_IP) == null) {
236+
localRootSpan.setTag(Tags.NETWORK_CLIENT_IP, peerIp);
237+
}
238+
final String inferredClientIp = clientIpAddressData.getInferredClientIp();
239+
if (inferredClientIp != null && localRootSpan.getTag(Tags.HTTP_CLIENT_IP) == null) {
240+
localRootSpan.setTag(Tags.HTTP_CLIENT_IP, inferredClientIp);
241+
}
242+
}
243+
216244
@Override
217245
public Evaluation evaluate(final List<Message> messages, final Options options) {
218246
if (messages == null || messages.isEmpty()) {
@@ -229,6 +257,7 @@ public Evaluation evaluate(final List<Message> messages, final Options options)
229257
if (localRootSpan != null) {
230258
localRootSpan.setTag(Tags.AI_GUARD_KEEP, true);
231259
localRootSpan.setTag(EVENT_TAG, true);
260+
applyClientIpTags(localRootSpan);
232261
}
233262
try (final AgentScope scope = tracer.activateSpan(span)) {
234263
final Message last = messages.get(messages.size() - 1);

dd-java-agent/agent-aiguard/src/test/groovy/com/datadog/aiguard/AIGuardInternalTests.groovy

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import com.squareup.moshi.Moshi
77
import datadog.common.version.VersionInfo
88
import datadog.trace.api.Config
99
import datadog.trace.api.aiguard.AIGuard
10+
import datadog.trace.api.gateway.RequestContext
1011
import datadog.trace.api.telemetry.WafMetricCollector
1112
import datadog.trace.bootstrap.instrumentation.api.AgentSpan
1213
import datadog.trace.bootstrap.instrumentation.api.AgentTracer
14+
import datadog.trace.bootstrap.instrumentation.api.ClientIpAddressData
1315
import datadog.trace.bootstrap.instrumentation.api.Tags
1416
import datadog.trace.test.util.DDSpecification
1517
import okhttp3.Call
@@ -272,6 +274,68 @@ class AIGuardInternalTests extends DDSpecification {
272274
new AIGuard.Options().block(false) | true | false
273275
}
274276

277+
void 'test evaluate applies captured client ip tags to local root span'() {
278+
given:
279+
final requestContext = Mock(RequestContext)
280+
localRootSpan.getRequestContext() >> requestContext
281+
requestContext.getAndResetClientIpAddressData() >> new ClientIpAddressData('4.4.4.4', '2.3.4.5')
282+
final aiguard = mockClient(200, [data: [attributes: [action: 'ALLOW', reason: 'It is fine']]])
283+
284+
when:
285+
aiguard.evaluate(TOOL_CALL, AIGuard.Options.DEFAULT)
286+
287+
then:
288+
1 * localRootSpan.getTag(Tags.NETWORK_CLIENT_IP) >> null
289+
1 * localRootSpan.setTag(Tags.NETWORK_CLIENT_IP, '4.4.4.4')
290+
1 * localRootSpan.getTag(Tags.HTTP_CLIENT_IP) >> null
291+
1 * localRootSpan.setTag(Tags.HTTP_CLIENT_IP, '2.3.4.5')
292+
}
293+
294+
void 'test evaluate does not overwrite existing client ip tags'() {
295+
given:
296+
final requestContext = Mock(RequestContext)
297+
localRootSpan.getRequestContext() >> requestContext
298+
requestContext.getAndResetClientIpAddressData() >> new ClientIpAddressData('4.4.4.4', '2.3.4.5')
299+
final aiguard = mockClient(200, [data: [attributes: [action: 'ALLOW', reason: 'It is fine']]])
300+
301+
when:
302+
aiguard.evaluate(TOOL_CALL, AIGuard.Options.DEFAULT)
303+
304+
then:
305+
1 * localRootSpan.getTag(Tags.NETWORK_CLIENT_IP) >> '9.9.9.9'
306+
0 * localRootSpan.setTag(Tags.NETWORK_CLIENT_IP, _)
307+
1 * localRootSpan.getTag(Tags.HTTP_CLIENT_IP) >> '8.8.8.8'
308+
0 * localRootSpan.setTag(Tags.HTTP_CLIENT_IP, _)
309+
}
310+
311+
void 'test evaluate is a noop for client ip tags when no data captured'() {
312+
given:
313+
final requestContext = Mock(RequestContext)
314+
localRootSpan.getRequestContext() >> requestContext
315+
requestContext.getAndResetClientIpAddressData() >> null
316+
final aiguard = mockClient(200, [data: [attributes: [action: 'ALLOW', reason: 'It is fine']]])
317+
318+
when:
319+
aiguard.evaluate(TOOL_CALL, AIGuard.Options.DEFAULT)
320+
321+
then:
322+
0 * localRootSpan.setTag(Tags.NETWORK_CLIENT_IP, _)
323+
0 * localRootSpan.setTag(Tags.HTTP_CLIENT_IP, _)
324+
}
325+
326+
void 'test evaluate is a noop for client ip tags when no request context'() {
327+
given:
328+
localRootSpan.getRequestContext() >> null
329+
final aiguard = mockClient(200, [data: [attributes: [action: 'ALLOW', reason: 'It is fine']]])
330+
331+
when:
332+
aiguard.evaluate(TOOL_CALL, AIGuard.Options.DEFAULT)
333+
334+
then:
335+
0 * localRootSpan.setTag(Tags.NETWORK_CLIENT_IP, _)
336+
0 * localRootSpan.setTag(Tags.HTTP_CLIENT_IP, _)
337+
}
338+
275339
void 'test evaluate with API errors'() {
276340
given:
277341
final errors = [[status: 400, title: 'Bad request']]

dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/HttpServerDecorator.java

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import datadog.trace.bootstrap.instrumentation.api.AgentSpan;
3131
import datadog.trace.bootstrap.instrumentation.api.AgentSpanContext;
3232
import datadog.trace.bootstrap.instrumentation.api.AgentTracer;
33+
import datadog.trace.bootstrap.instrumentation.api.ClientIpAddressData;
3334
import datadog.trace.bootstrap.instrumentation.api.ErrorPriorities;
3435
import datadog.trace.bootstrap.instrumentation.api.InternalSpanTypes;
3536
import datadog.trace.bootstrap.instrumentation.api.ResourceNamePriorities;
@@ -250,6 +251,13 @@ public AgentSpan onRequest(
250251
AgentSpanContext.Extracted extracted = getExtractedSpanContext(parentContext);
251252
boolean clientIpResolverEnabled =
252253
config.isClientIpEnabled() || traceClientIpResolverEnabled && APPSEC_ACTIVE;
254+
// AI Guard requires client IP tags on the local root span when an ai_guard span is created.
255+
// To respect the RFC's PII scope, resolve the IPs eagerly but do not tag the span yet; stash
256+
// them on the request context so AIGuardInternal can apply them lazily, only on requests that
257+
// actually create an ai_guard span.
258+
boolean aiGuardClientIpCaptureEnabled =
259+
!clientIpResolverEnabled && config.isAiGuardEnabled() && traceClientIpResolverEnabled;
260+
boolean resolveClientIp = clientIpResolverEnabled || aiGuardClientIpCaptureEnabled;
253261
if (extracted != null) {
254262
if (clientIpResolverEnabled) {
255263
String forwarded = extracted.getForwarded();
@@ -332,7 +340,7 @@ public AgentSpan onRequest(
332340
}
333341

334342
String inferredAddressStr = null;
335-
if (clientIpResolverEnabled && extracted != null) {
343+
if (resolveClientIp && extracted != null) {
336344
InetAddress inferredAddress = ClientIpAddressResolver.resolve(extracted, span);
337345
// the peer address should be used if:
338346
// 1. the headers yield nothing, regardless of whether it is public or not
@@ -349,7 +357,9 @@ public AgentSpan onRequest(
349357
}
350358
if (inferredAddress != null) {
351359
inferredAddressStr = inferredAddress.getHostAddress();
352-
span.setTag(Tags.HTTP_CLIENT_IP, inferredAddressStr);
360+
if (clientIpResolverEnabled) {
361+
span.setTag(Tags.HTTP_CLIENT_IP, inferredAddressStr);
362+
}
353363
}
354364
} else if (clientIpResolverEnabled && span.getLocalRootSpan() != span) {
355365
// in this case extracted == null
@@ -374,6 +384,12 @@ public AgentSpan onRequest(
374384
span.setTag(Tags.NETWORK_CLIENT_IP, peerIp);
375385
}
376386
}
387+
if (aiGuardClientIpCaptureEnabled && (peerIp != null || inferredAddressStr != null)) {
388+
RequestContext requestContext = span.getRequestContext();
389+
if (requestContext != null) {
390+
requestContext.setClientIpAddressData(new ClientIpAddressData(peerIp, inferredAddressStr));
391+
}
392+
}
377393
setPeerPort(span, peerPort);
378394
Flow<Void> flow = callIGCallbackAddressAndPort(span, peerIp, peerPort, inferredAddressStr);
379395
if (flow.getAction() instanceof RequestBlockingAction) {

dd-java-agent/agent-bootstrap/src/test/groovy/datadog/trace/bootstrap/instrumentation/decorator/HttpServerDecoratorTest.groovy

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import datadog.trace.bootstrap.instrumentation.api.AgentSpan
1515
import datadog.trace.bootstrap.instrumentation.api.AgentSpanContext
1616
import datadog.trace.bootstrap.instrumentation.api.AgentTracer
1717
import datadog.trace.bootstrap.instrumentation.api.AgentTracer.TracerAPI
18+
import datadog.trace.bootstrap.instrumentation.api.ClientIpAddressData
1819
import datadog.trace.bootstrap.instrumentation.api.ContextVisitors
1920
import datadog.trace.bootstrap.instrumentation.api.ErrorPriorities
2021
import datadog.trace.bootstrap.instrumentation.api.ResourceNamePriorities
@@ -300,6 +301,61 @@ class HttpServerDecoratorTest extends ServerDecoratorTest {
300301
0 * this.span.setTag(Tags.NETWORK_CLIENT_IP, _)
301302
}
302303

304+
void 'enabling ai guard captures client ip data without tagging the span during request decoration'() {
305+
setup:
306+
injectSysConfig('dd.ai_guard.enabled', 'true')
307+
ActiveSubsystems.APPSEC_ACTIVE = false
308+
309+
def extracted = Mock(AgentSpanContext.Extracted)
310+
def context = AgentSpan.fromSpanContext(extracted)
311+
def requestContext = Mock(RequestContext)
312+
def decorator = newDecorator()
313+
314+
when:
315+
decorator.onRequest(this.span, [peerIp: '4.4.4.4'], null, context)
316+
317+
then:
318+
_ * extracted.getXForwardedFor() >> '2.3.4.5'
319+
_ * extracted.getXClusterClientIp() >> null
320+
_ * extracted.getXRealIp() >> null
321+
_ * extracted.getXClientIp() >> null
322+
_ * extracted.getCustomIpHeader() >> null
323+
_ * extracted.getTrueClientIp() >> null
324+
_ * extracted.getFastlyClientIp() >> null
325+
_ * extracted.getCfConnectingIp() >> null
326+
_ * extracted.getCfConnectingIpv6() >> null
327+
_ * this.span.getRequestContext() >> requestContext
328+
1 * requestContext.setClientIpAddressData({ ClientIpAddressData data ->
329+
data.peerIp == '4.4.4.4' && data.inferredClientIp == '2.3.4.5'
330+
})
331+
0 * this.span.setTag(Tags.HTTP_CLIENT_IP, _)
332+
0 * this.span.setTag(Tags.NETWORK_CLIENT_IP, _)
333+
0 * this.span.setTag(Tags.HTTP_FORWARDED_IP, _)
334+
}
335+
336+
void 'enabling ai guard does not override client_ip_without_appsec tagging behavior'() {
337+
setup:
338+
injectSysConfig('dd.ai_guard.enabled', 'true')
339+
injectSysConfig('dd.trace.client-ip.enabled', 'true')
340+
ActiveSubsystems.APPSEC_ACTIVE = false
341+
342+
def extracted = Mock(AgentSpanContext.Extracted)
343+
def context = AgentSpan.fromSpanContext(extracted)
344+
def requestContext = Mock(RequestContext)
345+
def decorator = newDecorator()
346+
347+
when:
348+
decorator.onRequest(this.span, [peerIp: '4.4.4.4'], null, context)
349+
350+
then:
351+
_ * this.span.getRequestContext() >> requestContext
352+
2 * extracted.getXForwardedFor() >> '2.3.4.5'
353+
1 * this.span.setTag(Tags.HTTP_CLIENT_IP, '2.3.4.5')
354+
1 * this.span.setTag(Tags.NETWORK_CLIENT_IP, '4.4.4.4')
355+
// ai guard capture is skipped because tags were already set
356+
0 * requestContext.setClientIpAddressData(_)
357+
}
358+
303359
void 'disabling appsec but enabling client_ip_without_appsec enables header collection and ip address resolution'() {
304360
setup:
305361
injectSysConfig('dd.trace.client-ip.enabled', 'true')

dd-java-agent/appsec/src/jmh/java/datadog/appsec/benchmark/AppSecBenchmark.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import datadog.trace.api.gateway.RequestContextSlot;
1818
import datadog.trace.api.gateway.SubscriptionService;
1919
import datadog.trace.api.internal.TraceSegment;
20+
import datadog.trace.bootstrap.instrumentation.api.ClientIpAddressData;
2021
import datadog.trace.bootstrap.instrumentation.api.URIDataAdapter;
2122
import datadog.trace.bootstrap.instrumentation.api.URIDefaultDataAdapter;
2223
import java.io.IOException;
@@ -254,6 +255,14 @@ public <T> T getOrCreateMetaStructTop(String key, Function<String, T> defaultVal
254255
return null;
255256
}
256257

258+
@Override
259+
public void setClientIpAddressData(ClientIpAddressData clientIpAddressData) {}
260+
261+
@Override
262+
public ClientIpAddressData getAndResetClientIpAddressData() {
263+
return null;
264+
}
265+
257266
@Override
258267
public void close() throws IOException {}
259268
}

dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/gateway/GatewayBridgeSpecification.groovy

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import datadog.trace.api.gateway.RequestContextSlot
2828
import datadog.trace.api.gateway.SubscriptionService
2929
import datadog.trace.api.http.StoredBodySupplier
3030
import datadog.trace.api.internal.TraceSegment
31+
import datadog.trace.bootstrap.instrumentation.api.ClientIpAddressData
3132
import datadog.trace.api.telemetry.LoginEvent
3233
import datadog.trace.api.telemetry.RuleType
3334
import datadog.trace.api.telemetry.WafMetricCollector
@@ -79,6 +80,14 @@ class GatewayBridgeSpecification extends DDSpecification {
7980
return null
8081
}
8182

83+
@Override
84+
void setClientIpAddressData(ClientIpAddressData clientIpAddressData) {}
85+
86+
@Override
87+
ClientIpAddressData getAndResetClientIpAddressData() {
88+
return null
89+
}
90+
8291
@Override
8392
void close() throws IOException {}
8493
}
@@ -1253,6 +1262,14 @@ class GatewayBridgeSpecification extends DDSpecification {
12531262
return null
12541263
}
12551264

1265+
@Override
1266+
void setClientIpAddressData(ClientIpAddressData clientIpAddressData) {}
1267+
1268+
@Override
1269+
ClientIpAddressData getAndResetClientIpAddressData() {
1270+
return null
1271+
}
1272+
12561273
@Override
12571274
void close() throws IOException {}
12581275
}

dd-java-agent/instrumentation-testing/src/main/groovy/datadog/trace/agent/test/ValidatingRequestContextDecorator.groovy

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import datadog.trace.api.gateway.BlockResponseFunction
44
import datadog.trace.api.gateway.RequestContext
55
import datadog.trace.api.gateway.RequestContextSlot
66
import datadog.trace.api.internal.TraceSegment
7+
import datadog.trace.bootstrap.instrumentation.api.ClientIpAddressData
78

89
import java.util.function.Function
910

@@ -56,6 +57,16 @@ class ValidatingRequestContextDecorator implements RequestContext {
5657
return delegate.getOrCreateMetaStructTop(key, defaultValue)
5758
}
5859

60+
@Override
61+
void setClientIpAddressData(ClientIpAddressData clientIpAddressData) {
62+
delegate.setClientIpAddressData(clientIpAddressData)
63+
}
64+
65+
@Override
66+
ClientIpAddressData getAndResetClientIpAddressData() {
67+
return delegate.getAndResetClientIpAddressData()
68+
}
69+
5970
@Override
6071
void close() throws IOException {
6172
delegate.close()

0 commit comments

Comments
 (0)