Skip to content

Commit 75e5963

Browse files
New API security sampling algorithm (#8178)
Co-authored-by: Santiago Mola <[email protected]>
1 parent 3e2867a commit 75e5963

File tree

35 files changed

+1000
-344
lines changed

35 files changed

+1000
-344
lines changed

.circleci/config.continue.yml.j2

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ instrumentation_modules: &instrumentation_modules "dd-java-agent/instrumentation
3636
debugger_modules: &debugger_modules "dd-java-agent/agent-debugger|dd-java-agent/agent-bootstrap|dd-java-agent/agent-builder|internal-api|communication|dd-trace-core"
3737
profiling_modules: &profiling_modules "dd-java-agent/agent-profiling"
3838

39-
default_system_tests_commit: &default_system_tests_commit 69a5e874384dd256e2e3f42fc1c95807a67efbe6
39+
default_system_tests_commit: &default_system_tests_commit 1ef00a34ad1f83ae999887e510ef1ea1c27b151b
4040

4141
parameters:
4242
nightly:

dd-java-agent/appsec/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ ext {
8282
'com.datadog.appsec.config.AppSecFeatures.ApiSecurity',
8383
'com.datadog.appsec.config.AppSecFeatures.AutoUserInstrum',
8484
'com.datadog.appsec.event.ReplaceableEventProducerService',
85+
'com.datadog.appsec.api.security.ApiSecuritySampler.NoOp',
8586
]
8687
excludedClassesBranchCoverage = [
8788
'com.datadog.appsec.gateway.GatewayBridge',

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.datadog.appsec;
22

3-
import com.datadog.appsec.api.security.ApiSecurityRequestSampler;
3+
import com.datadog.appsec.api.security.ApiSecuritySampler;
4+
import com.datadog.appsec.api.security.ApiSecuritySamplerImpl;
5+
import com.datadog.appsec.api.security.AppSecSpanPostProcessor;
46
import com.datadog.appsec.blocking.BlockingServiceImpl;
57
import com.datadog.appsec.config.AppSecConfigService;
68
import com.datadog.appsec.config.AppSecConfigServiceImpl;
@@ -21,6 +23,7 @@
2123
import datadog.trace.api.telemetry.ProductChange;
2224
import datadog.trace.api.telemetry.ProductChangeCollector;
2325
import datadog.trace.bootstrap.ActiveSubsystems;
26+
import datadog.trace.bootstrap.instrumentation.api.SpanPostProcessor;
2427
import java.util.Collections;
2528
import java.util.HashMap;
2629
import java.util.List;
@@ -66,7 +69,17 @@ private static void doStart(SubscriptionService gw, SharedCommunicationObjects s
6669
EventDispatcher eventDispatcher = new EventDispatcher();
6770
REPLACEABLE_EVENT_PRODUCER.replaceEventProducerService(eventDispatcher);
6871

69-
ApiSecurityRequestSampler requestSampler = new ApiSecurityRequestSampler(config);
72+
ApiSecuritySampler requestSampler;
73+
if (Config.get().isApiSecurityEnabled()) {
74+
requestSampler = new ApiSecuritySamplerImpl();
75+
// When DD_API_SECURITY_ENABLED=true, ths post-processor is set even when AppSec is inactive.
76+
// This should be low overhead since the post-processor exits early if there's no AppSec
77+
// context.
78+
SpanPostProcessor.Holder.INSTANCE =
79+
new AppSecSpanPostProcessor(requestSampler, REPLACEABLE_EVENT_PRODUCER);
80+
} else {
81+
requestSampler = new ApiSecuritySampler.NoOp();
82+
}
7083

7184
ConfigurationPoller configurationPoller = sco.configurationPoller(config);
7285
// may throw and abort startup

dd-java-agent/appsec/src/main/java/com/datadog/appsec/api/security/ApiSecurityRequestSampler.java

Lines changed: 0 additions & 56 deletions
This file was deleted.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.datadog.appsec.api.security;
2+
3+
import com.datadog.appsec.gateway.AppSecRequestContext;
4+
import javax.annotation.Nonnull;
5+
6+
public interface ApiSecuritySampler {
7+
/**
8+
* Prepare a request context for later sampling decision. This method should be called at request
9+
* end, and is thread-safe. If a request can potentially be sampled, this method will return true.
10+
* If this method returns true, the caller MUST call {@link #releaseOne()} once the context is not
11+
* needed anymore.
12+
*/
13+
boolean preSampleRequest(final @Nonnull AppSecRequestContext ctx);
14+
15+
/** Get the final sampling decision. This method is NOT required to be thread-safe. */
16+
boolean sampleRequest(AppSecRequestContext ctx);
17+
18+
/** Release one permit for the sampler. This must be called after processing a span. */
19+
void releaseOne();
20+
21+
final class NoOp implements ApiSecuritySampler {
22+
@Override
23+
public boolean preSampleRequest(@Nonnull AppSecRequestContext ctx) {
24+
return false;
25+
}
26+
27+
@Override
28+
public boolean sampleRequest(AppSecRequestContext ctx) {
29+
return false;
30+
}
31+
32+
@Override
33+
public void releaseOne() {}
34+
}
35+
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package com.datadog.appsec.api.security;
2+
3+
import com.datadog.appsec.gateway.AppSecRequestContext;
4+
import datadog.trace.api.Config;
5+
import datadog.trace.api.time.SystemTimeSource;
6+
import datadog.trace.api.time.TimeSource;
7+
import java.util.Deque;
8+
import java.util.concurrent.ConcurrentHashMap;
9+
import java.util.concurrent.ConcurrentLinkedDeque;
10+
import java.util.concurrent.Semaphore;
11+
import javax.annotation.Nonnull;
12+
import org.slf4j.Logger;
13+
import org.slf4j.LoggerFactory;
14+
15+
public class ApiSecuritySamplerImpl implements ApiSecuritySampler {
16+
17+
private static final Logger log = LoggerFactory.getLogger(ApiSecuritySamplerImpl.class);
18+
19+
/**
20+
* A maximum number of request contexts we'll keep open past the end of request at any given time.
21+
* This will avoid excessive memory usage in case of a high number of concurrent requests, and
22+
* should also prevent memory leaks.
23+
*/
24+
private static final int MAX_POST_PROCESSING_TASKS = 4;
25+
/** Maximum number of entries in the access map. */
26+
private static final int MAX_SIZE = 4096;
27+
/** Mapping from endpoint hash to last access timestamp in millis. */
28+
private final ConcurrentHashMap<Long, Long> accessMap;
29+
/** Deque of endpoint hashes ordered by access time. Oldest is always first. */
30+
private final Deque<Long> accessDeque;
31+
32+
private final long expirationTimeInMs;
33+
private final int capacity;
34+
private final TimeSource timeSource;
35+
private final Semaphore counter = new Semaphore(MAX_POST_PROCESSING_TASKS);
36+
37+
public ApiSecuritySamplerImpl() {
38+
this(
39+
MAX_SIZE,
40+
(long) (Config.get().getApiSecuritySampleDelay() * 1_000),
41+
SystemTimeSource.INSTANCE);
42+
}
43+
44+
public ApiSecuritySamplerImpl(
45+
int capacity, long expirationTimeInMs, @Nonnull TimeSource timeSource) {
46+
this.capacity = capacity;
47+
this.expirationTimeInMs = expirationTimeInMs;
48+
this.accessMap = new ConcurrentHashMap<>();
49+
this.accessDeque = new ConcurrentLinkedDeque<>();
50+
this.timeSource = timeSource;
51+
}
52+
53+
@Override
54+
public boolean preSampleRequest(final @Nonnull AppSecRequestContext ctx) {
55+
final String route = ctx.getRoute();
56+
if (route == null) {
57+
return false;
58+
}
59+
final String method = ctx.getMethod();
60+
if (method == null) {
61+
return false;
62+
}
63+
final int statusCode = ctx.getResponseStatus();
64+
if (statusCode <= 0) {
65+
return false;
66+
}
67+
long hash = computeApiHash(route, method, statusCode);
68+
ctx.setApiSecurityEndpointHash(hash);
69+
if (!isApiAccessExpired(hash)) {
70+
return false;
71+
}
72+
if (counter.tryAcquire()) {
73+
log.debug("API security sampling is required for this request (presampled)");
74+
ctx.setKeepOpenForApiSecurityPostProcessing(true);
75+
return true;
76+
}
77+
return false;
78+
}
79+
80+
/** Get the final sampling decision. This method is NOT thread-safe. */
81+
@Override
82+
public boolean sampleRequest(AppSecRequestContext ctx) {
83+
if (ctx == null) {
84+
return false;
85+
}
86+
final Long hash = ctx.getApiSecurityEndpointHash();
87+
if (hash == null) {
88+
// This should never happen, it should have been short-circuited before.
89+
return false;
90+
}
91+
return updateApiAccessIfExpired(hash);
92+
}
93+
94+
@Override
95+
public void releaseOne() {
96+
counter.release();
97+
}
98+
99+
private boolean updateApiAccessIfExpired(final long hash) {
100+
final long currentTime = timeSource.getCurrentTimeMillis();
101+
102+
Long lastAccess = accessMap.get(hash);
103+
if (lastAccess != null && currentTime - lastAccess < expirationTimeInMs) {
104+
return false;
105+
}
106+
107+
if (accessMap.put(hash, currentTime) == null) {
108+
accessDeque.addLast(hash);
109+
// If we added a new entry, we perform purging.
110+
cleanupExpiredEntries(currentTime);
111+
} else {
112+
// This is now the most recently accessed entry.
113+
accessDeque.remove(hash);
114+
accessDeque.addLast(hash);
115+
}
116+
117+
return true;
118+
}
119+
120+
private boolean isApiAccessExpired(final long hash) {
121+
final long currentTime = timeSource.getCurrentTimeMillis();
122+
final Long lastAccess = accessMap.get(hash);
123+
return lastAccess == null || currentTime - lastAccess >= expirationTimeInMs;
124+
}
125+
126+
private void cleanupExpiredEntries(final long currentTime) {
127+
// Purge all expired entries.
128+
while (!accessDeque.isEmpty()) {
129+
final Long oldestHash = accessDeque.peekFirst();
130+
if (oldestHash == null) {
131+
// Should never happen
132+
continue;
133+
}
134+
135+
final Long lastAccessTime = accessMap.get(oldestHash);
136+
if (lastAccessTime == null) {
137+
// Should never happen
138+
continue;
139+
}
140+
141+
if (currentTime - lastAccessTime < expirationTimeInMs) {
142+
// The oldest hash is up-to-date, so stop here.
143+
break;
144+
}
145+
146+
accessDeque.pollFirst();
147+
accessMap.remove(oldestHash);
148+
}
149+
150+
// If we went over capacity, remove the oldest entries until we are within the limit.
151+
// This should never be more than 1.
152+
final int toRemove = accessMap.size() - this.capacity;
153+
for (int i = 0; i < toRemove; i++) {
154+
Long oldestHash = accessDeque.pollFirst();
155+
if (oldestHash != null) {
156+
accessMap.remove(oldestHash);
157+
}
158+
}
159+
}
160+
161+
private long computeApiHash(final String route, final String method, final int statusCode) {
162+
long result = 17;
163+
result = 31 * result + route.hashCode();
164+
result = 31 * result + method.hashCode();
165+
result = 31 * result + statusCode;
166+
return result;
167+
}
168+
}

0 commit comments

Comments
 (0)