Skip to content

Commit 445beac

Browse files
New Api Security Sampling mechanism
1 parent 03478c8 commit 445beac

File tree

11 files changed

+165
-207
lines changed

11 files changed

+165
-207
lines changed

dd-java-agent/appsec/src/main/java/com/datadog/appsec/AppSecSystem.java

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,7 @@ private static void doStart(SubscriptionService gw, SharedCommunicationObjects s
7272
// may throw and abort startup
7373
APP_SEC_CONFIG_SERVICE =
7474
new AppSecConfigServiceImpl(
75-
config,
76-
configurationPoller,
77-
requestSampler,
78-
() -> reloadSubscriptions(REPLACEABLE_EVENT_PRODUCER));
75+
config, configurationPoller, () -> reloadSubscriptions(REPLACEABLE_EVENT_PRODUCER));
7976
APP_SEC_CONFIG_SERVICE.init();
8077

8178
sco.createRemaining(config);
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package com.datadog.appsec.api.security;
2+
3+
import java.util.Collections;
4+
import java.util.LinkedHashMap;
5+
import java.util.Map;
6+
7+
/**
8+
* The ApiAccessTracker class provides a mechanism to track API access events, managing them within
9+
* a specified capacity limit. Each event is associated with a unique combination of route, method,
10+
* and status code, which is used to generate a unique key for tracking access timestamps.
11+
*
12+
* <p>Usage: - When an API access event occurs, the `updateApiAccessIfExpired` method is called with
13+
* the route, method, and status code of the API request. - If the access event for the given
14+
* parameters is new or has expired (based on the expirationTimeInMs threshold), the event's
15+
* timestamp is updated, effectively moving the event to the end of the tracking list. - If the
16+
* tracker's capacity is reached, the oldest event is automatically removed to make room for new
17+
* events. - This mechanism ensures that the tracker always contains the most recent access events
18+
* within the specified capacity limit, with older, less relevant events being discarded.
19+
*/
20+
public class ApiAccessTracker {
21+
private static final int INTERVAL_SECONDS = 30;
22+
private static final int MAX_SIZE = 4096;
23+
private final Map<Long, Long> apiAccessLog; // Map<hash, timestamp>
24+
private final long expirationTimeInMs;
25+
26+
public ApiAccessTracker() {
27+
this(MAX_SIZE, INTERVAL_SECONDS * 1000);
28+
}
29+
30+
public ApiAccessTracker(int capacity, long expirationTimeInMs) {
31+
this.expirationTimeInMs = expirationTimeInMs;
32+
this.apiAccessLog = Collections.synchronizedMap(new LinkedHashMap<Long, Long>() {
33+
@Override
34+
protected boolean removeEldestEntry(Map.Entry<Long, Long> eldest) {
35+
return size() > capacity;
36+
}
37+
});
38+
}
39+
40+
/**
41+
* Updates the API access log with the given route, method, and status code. If the record exists
42+
* and is outdated, it is updated by moving to the end of the list. If the record does not exist,
43+
* a new record is added. If the capacity limit is reached, the oldest record is removed. Returns
44+
* true if the record was updated or added, false otherwise.
45+
*
46+
* @param route The route of the API endpoint request
47+
* @param method The method of the API request
48+
* @param statusCode The HTTP response status code of the API request
49+
* @return return true if the record was updated or added, false otherwise
50+
*/
51+
public boolean updateApiAccessIfExpired(String route, String method, int statusCode) {
52+
long currentTime = System.currentTimeMillis();
53+
long hash = computeApiHash(route, method, statusCode);
54+
55+
synchronized (apiAccessLog) {
56+
if (apiAccessLog.containsKey(hash)) {
57+
long lastAccessTime = apiAccessLog.get(hash);
58+
if (currentTime - lastAccessTime > expirationTimeInMs) {
59+
apiAccessLog.put(hash, currentTime);
60+
return true;
61+
}
62+
return false;
63+
} else {
64+
apiAccessLog.put(hash, currentTime);
65+
return true;
66+
}
67+
}
68+
}
69+
70+
private long computeApiHash(String route, String method, int statusCode) {
71+
long result = 17;
72+
result = 31 * result + route.hashCode();
73+
result = 31 * result + method.hashCode();
74+
result = 31 * result + statusCode;
75+
return result;
76+
}
77+
}
Lines changed: 26 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,45 @@
11
package com.datadog.appsec.api.security;
22

33
import datadog.trace.api.Config;
4-
import java.util.concurrent.atomic.AtomicLong;
4+
import datadog.trace.api.gateway.IGSpanInfo;
5+
import datadog.trace.bootstrap.instrumentation.api.Tags;
6+
import java.util.Map;
57

68
public class ApiSecurityRequestSampler {
79

8-
private volatile int sampling;
9-
private final AtomicLong cumulativeCounter = new AtomicLong();
10+
private final ApiAccessTracker apiAccessTracker;
11+
private final Config config;
1012

1113
public ApiSecurityRequestSampler(final Config config) {
12-
sampling = computeSamplingParameter(config.getApiSecurityRequestSampleRate());
14+
this.apiAccessTracker = new ApiAccessTracker();
15+
this.config = config;
1316
}
1417

15-
/**
16-
* Sets the new sampling parameter
17-
*
18-
* @return {@code true} if the value changed
19-
*/
20-
public boolean setSampling(final float newSamplingFloat) {
21-
int newSampling = computeSamplingParameter(newSamplingFloat);
22-
if (newSampling != sampling) {
23-
sampling = newSampling;
24-
cumulativeCounter.set(0); // Reset current sampling counter
25-
return true;
18+
public boolean sampleRequest(IGSpanInfo span) {
19+
if (!config.isApiSecurityEnabled() || span == null) {
20+
return false;
2621
}
27-
return false;
28-
}
2922

30-
public int getSampling() {
31-
return sampling;
32-
}
23+
Map<String, Object> tags = span.getTags();
3324

34-
public boolean sampleRequest() {
35-
long prevValue = cumulativeCounter.getAndAdd(sampling);
36-
long newValue = prevValue + sampling;
37-
if (newValue / 100 == prevValue / 100 + 1) {
38-
// Sample request
39-
return true;
25+
Object routeObj = tags.get(Tags.HTTP_ROUTE);
26+
String route = routeObj instanceof String ? (String) routeObj : null;
27+
if (route == null) {
28+
return false;
4029
}
41-
// Skipped by sampling
42-
return false;
43-
}
4430

45-
static int computeSamplingParameter(final float pct) {
46-
if (pct >= 1) {
47-
return 100;
31+
Object methodObj = tags.get(Tags.HTTP_METHOD);
32+
String method = methodObj instanceof String ? (String) methodObj : null;
33+
if (method == null) {
34+
return false;
4835
}
49-
if (pct < 0) {
50-
// Api security can only be disabled by setting the sampling to zero, so we set it to 100%.
51-
// TODO: We probably want a warning here.
52-
return 100;
36+
37+
Object statusCodeObj = tags.get(Tags.HTTP_STATUS);
38+
int statusCode = statusCodeObj instanceof Integer ? (Integer) statusCodeObj : 0;
39+
if (statusCode == 0) {
40+
return false;
5341
}
54-
return (int) (pct * 100);
42+
43+
return apiAccessTracker.updateApiAccessIfExpired(route, method, statusCode);
5544
}
5645
}

dd-java-agent/appsec/src/main/java/com/datadog/appsec/config/AppSecConfigServiceImpl.java

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
import static datadog.remoteconfig.Capabilities.CAPABILITY_ENDPOINT_FINGERPRINT;
2525

2626
import com.datadog.appsec.AppSecSystem;
27-
import com.datadog.appsec.api.security.ApiSecurityRequestSampler;
2827
import com.datadog.appsec.config.AppSecModuleConfigurer.SubconfigListener;
2928
import com.datadog.appsec.config.CurrentAppSecConfig.DirtyStatus;
3029
import com.datadog.appsec.util.AbortStartupException;
@@ -72,8 +71,6 @@ public class AppSecConfigServiceImpl implements AppSecConfigService {
7271
private final Config tracerConfig;
7372
private final List<TraceSegmentPostProcessor> traceSegmentPostProcessors = new ArrayList<>();
7473
private final AppSecModuleConfigurer.Reconfiguration reconfiguration;
75-
private final ApiSecurityRequestSampler apiSecurityRequestSampler;
76-
7774
private final ConfigurationEndListener applyRemoteConfigListener =
7875
this::applyRemoteConfigListener;
7976

@@ -82,12 +79,10 @@ public class AppSecConfigServiceImpl implements AppSecConfigService {
8279
public AppSecConfigServiceImpl(
8380
Config tracerConfig,
8481
ConfigurationPoller configurationPoller,
85-
ApiSecurityRequestSampler apiSecurityRequestSampler,
8682
AppSecModuleConfigurer.Reconfiguration reconfig) {
8783
this.tracerConfig = tracerConfig;
8884
this.configurationPoller = configurationPoller;
8985
this.reconfiguration = reconfig;
90-
this.apiSecurityRequestSampler = apiSecurityRequestSampler;
9186
}
9287

9388
private void subscribeConfigurationPoller() {
@@ -385,7 +380,6 @@ private void applyRemoteConfigListener() {
385380
// apply ASM_FEATURES configuration first as they might enable AppSec
386381
final AppSecFeatures features = mergedAsmFeatures.getMergedData();
387382
setAppSecActivation(features.asm);
388-
setApiSecuritySampling(features.apiSecurity);
389383
setUserIdCollectionMode(features.autoUserInstrum);
390384

391385
if (!AppSecSystem.isActive() || !currentAppSecConfig.dirtyStatus.isAnyDirty()) {
@@ -415,25 +409,6 @@ private void setAppSecActivation(final AppSecFeatures.Asm asm) {
415409
}
416410
}
417411

418-
private void setApiSecuritySampling(final AppSecFeatures.ApiSecurity apiSecurity) {
419-
final float newSampling;
420-
if (apiSecurity == null) {
421-
newSampling = tracerConfig.getApiSecurityRequestSampleRate();
422-
} else {
423-
newSampling = apiSecurity.requestSampleRate;
424-
}
425-
if (apiSecurityRequestSampler.setSampling(newSampling)) {
426-
int pct = apiSecurityRequestSampler.getSampling();
427-
if (pct == 0) {
428-
log.info("Api Security is disabled via remote-config");
429-
} else {
430-
log.info(
431-
"Api Security changed via remote-config. New sampling rate is {}% of all requests.",
432-
pct);
433-
}
434-
}
435-
}
436-
437412
private void setUserIdCollectionMode(final AppSecFeatures.AutoUserInstrum autoUserInstrum) {
438413
UserIdCollectionMode current = UserIdCollectionMode.get();
439414
UserIdCollectionMode newMode;

dd-java-agent/appsec/src/main/java/com/datadog/appsec/config/AppSecFeatures.java

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,6 @@
33
public class AppSecFeatures {
44
public Asm asm;
55

6-
@com.squareup.moshi.Json(name = "api_security")
7-
public ApiSecurity apiSecurity;
8-
96
@com.squareup.moshi.Json(name = "auto_user_instrum")
107
public AutoUserInstrum autoUserInstrum;
118

@@ -18,16 +15,6 @@ public String toString() {
1815
}
1916
}
2017

21-
public static class ApiSecurity {
22-
@com.squareup.moshi.Json(name = "request_sample_rate")
23-
public Float requestSampleRate;
24-
25-
@Override
26-
public String toString() {
27-
return "ApiSecurity{" + "requestSampleRate=" + requestSampleRate + '}';
28-
}
29-
}
30-
3118
public static class AutoUserInstrum {
3219
public String mode;
3320

@@ -39,13 +26,6 @@ public String toString() {
3926

4027
@Override
4128
public String toString() {
42-
return "AppSecFeatures{"
43-
+ "asm="
44-
+ asm
45-
+ ", apiSecurity="
46-
+ apiSecurity
47-
+ ", autoUserInstrum="
48-
+ autoUserInstrum
49-
+ '}';
29+
return "AppSecFeatures{" + "asm=" + asm + ", autoUserInstrum=" + autoUserInstrum + '}';
5030
}
5131
}

dd-java-agent/appsec/src/main/java/com/datadog/appsec/config/MergedAsmFeatures.java

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ public AppSecFeatures getMergedData() {
3131

3232
private AppSecFeatures merge(final AppSecFeatures target, final AppSecFeatures newFeatures) {
3333
mergeAsm(target, newFeatures.asm);
34-
mergeApiSecurity(target, newFeatures.apiSecurity);
3534
mergeAutoUserInstrum(target, newFeatures.autoUserInstrum);
3635
return target;
3736
}
@@ -43,14 +42,6 @@ private void mergeAsm(final AppSecFeatures target, final AppSecFeatures.Asm newV
4342
target.asm = newValue;
4443
}
4544

46-
private void mergeApiSecurity(
47-
final AppSecFeatures target, final AppSecFeatures.ApiSecurity newValue) {
48-
if (newValue == null || newValue.requestSampleRate == null) {
49-
return;
50-
}
51-
target.apiSecurity = newValue;
52-
}
53-
5445
private void mergeAutoUserInstrum(
5546
final AppSecFeatures target, final AppSecFeatures.AutoUserInstrum newValue) {
5647
if (newValue == null || newValue.mode == null) {

dd-java-agent/appsec/src/main/java/com/datadog/appsec/gateway/GatewayBridge.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -740,7 +740,7 @@ private NoopFlow onRequestEnded(RequestContext ctx_, IGSpanInfo spanInfo) {
740740
return NoopFlow.INSTANCE;
741741
}
742742

743-
maybeExtractSchemas(ctx);
743+
maybeExtractSchemas(ctx, spanInfo);
744744

745745
// WAF call
746746
ctx.closeAdditive();
@@ -1031,10 +1031,10 @@ private Flow<Void> maybePublishResponseData(AppSecRequestContext ctx) {
10311031
}
10321032
}
10331033

1034-
private void maybeExtractSchemas(AppSecRequestContext ctx) {
1034+
private void maybeExtractSchemas(AppSecRequestContext ctx, IGSpanInfo spanInfo) {
10351035
boolean extractSchema = false;
1036-
if (Config.get().isApiSecurityEnabled() && requestSampler != null) {
1037-
extractSchema = requestSampler.sampleRequest();
1036+
if (Config.get().isApiSecurityEnabled() && requestSampler != null && spanInfo != null) {
1037+
extractSchema = requestSampler.sampleRequest(spanInfo);
10381038
}
10391039

10401040
if (!extractSchema) {
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package com.datadog.appsec.api.security
2+
3+
import datadog.trace.test.util.DDSpecification
4+
5+
class ApiAccessTrackerTest extends DDSpecification {
6+
def "should add new api access and update if expired"() {
7+
given: "An ApiAccessTracker with capacity 2 and expiration time 1 second"
8+
def tracker = new ApiAccessTracker(2, 1000)
9+
10+
when: "Adding new api access"
11+
tracker.updateApiAccessIfExpired("route1", "GET", 200)
12+
def firstAccessTime = tracker.apiAccessLog.values().iterator().next()
13+
14+
then: "The access is added"
15+
tracker.apiAccessLog.size() == 1
16+
17+
when: "Waiting more than expiration time and adding another access with the same key"
18+
Thread.sleep(1100) // Waiting more than 1 second to ensure expiration
19+
tracker.updateApiAccessIfExpired("route1", "GET", 200)
20+
def secondAccessTime = tracker.apiAccessLog.values().iterator().next()
21+
22+
then: "The access is updated and moved to the end"
23+
tracker.apiAccessLog.size() == 1
24+
secondAccessTime > firstAccessTime
25+
}
26+
27+
def "should remove the oldest access when capacity is exceeded"() {
28+
given: "An ApiAccessTracker with capacity 1"
29+
def tracker = new ApiAccessTracker(1, 1000)
30+
31+
when: "Adding two api accesses"
32+
tracker.updateApiAccessIfExpired("route1", "GET", 200)
33+
Thread.sleep(100) // Delay to ensure different timestamps
34+
tracker.updateApiAccessIfExpired("route2", "POST", 404)
35+
36+
then: "The oldest access is removed"
37+
tracker.apiAccessLog.size() == 1
38+
!tracker.apiAccessLog.containsKey(tracker.computeApiHash("route1", "GET", 200))
39+
tracker.apiAccessLog.containsKey(tracker.computeApiHash("route2", "POST", 404))
40+
}
41+
42+
def "should not update access if not expired"() {
43+
given: "An ApiAccessTracker with a short expiration time"
44+
def tracker = new ApiAccessTracker(2, 2000) // 2 seconds expiration
45+
46+
when: "Adding an api access and updating it before it expires"
47+
tracker.updateApiAccessIfExpired("route1", "GET", 200)
48+
def updateTime = System.currentTimeMillis()
49+
boolean updatedBeforeExpiration = tracker.updateApiAccessIfExpired("route1", "GET", 200)
50+
51+
then: "The access is not updated"
52+
!updatedBeforeExpiration
53+
tracker.apiAccessLog.get(tracker.computeApiHash("route1", "GET", 200)) == updateTime
54+
}
55+
}

0 commit comments

Comments
 (0)