Skip to content

Commit 38d0ffc

Browse files
Merge pull request #263 from AikidoSec/attack-wave-samples
Attack wave samples
2 parents f4c0f05 + c619056 commit 38d0ffc

File tree

6 files changed

+97
-13
lines changed

6 files changed

+97
-13
lines changed

agent_api/src/main/java/dev/aikido/agent_api/background/cloud/api/events/DetectedAttackWave.java

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
package dev.aikido.agent_api.background.cloud.api.events;
22

3+
import com.google.gson.Gson;
34
import dev.aikido.agent_api.background.cloud.GetManagerInfo;
45
import dev.aikido.agent_api.context.ContextObject;
56
import dev.aikido.agent_api.context.User;
7+
import dev.aikido.agent_api.storage.attack_wave_detector.AttackWaveDetector;
8+
import dev.aikido.agent_api.storage.attack_wave_detector.AttackWaveDetectorStore;
69

10+
import java.util.List;
711
import java.util.Map;
812

913
import static dev.aikido.agent_api.background.cloud.GetManagerInfo.getManagerInfo;
1014
import static dev.aikido.agent_api.helpers.UnixTimeMS.getUnixTimeMS;
11-
import static dev.aikido.agent_api.storage.ServiceConfigStore.getConfig;
1215

1316
public final class DetectedAttackWave {
1417
private DetectedAttackWave() {
@@ -42,9 +45,15 @@ public static DetectedAttackWaveEvent createAPIEvent(ContextObject context) {
4245
context.getHeader("user-agent"), // userAgent
4346
context.getSource() // source
4447
);
48+
49+
String ip = context.getRemoteAddress();
50+
List<AttackWaveDetector.Sample> samples = AttackWaveDetectorStore.getSamplesForIp(ip);
51+
Map<String, String> metadata = Map.of(
52+
"samples", new Gson().toJson(samples)
53+
);
54+
4555
AttackWaveData attackData = new AttackWaveData(
46-
Map.of(),
47-
context.getUser()
56+
metadata, context.getUser()
4857
);
4958

5059
return new DetectedAttackWaveEvent(

agent_api/src/main/java/dev/aikido/agent_api/storage/attack_wave_detector/AttackWaveDetector.java

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,26 @@
33
import dev.aikido.agent_api.context.ContextObject;
44
import dev.aikido.agent_api.ratelimiting.LRUCache;
55

6+
import java.util.ArrayList;
7+
import java.util.List;
8+
import java.util.Objects;
9+
610
import static dev.aikido.agent_api.vulnerabilities.attack_wave_detection.WebScanDetector.isWebScanner;
711

812
public class AttackWaveDetector {
9-
private final LRUCache<String, Integer> suspiciousRequestsMap;
13+
private final LRUCache<String, Integer> suspiciousRequestsCounts;
14+
private final LRUCache<String, List<Sample>> suspiciousRequestsSamples;
1015
private final LRUCache<String, Long> sentEventsMap;
1116
private final int attackWaveThreshold;
17+
private final int maxSamplesPerIP;
1218

1319
public AttackWaveDetector() {
1420
this(
1521
/* attackWaveThreshold, default: 15 requests */ 15,
1622
/* attackWaveTimeFrame, default: 1 min */ 60_000L,
1723
/* minTimeBetweenEvents, default: 20 min */ 20 * 60_000L,
18-
/* maxLRUEntries, default: 10,000 entries */ 10_000
24+
/* maxLRUEntries, default: 10,000 entries */ 10_000,
25+
/* maxSamplesPerIP, default: 15 samples */ 15
1926
);
2027
}
2128

@@ -26,9 +33,11 @@ public AttackWaveDetector() {
2633
* @param maxLRUEntries Maximum number of entries in the LRU cache
2734
*/
2835
public AttackWaveDetector(int attackWaveThreshold, long attackWaveTimeFrame,
29-
long minTimeBetweenEvents, int maxLRUEntries) {
36+
long minTimeBetweenEvents, int maxLRUEntries, int maxSamplesPerIP) {
3037
this.attackWaveThreshold = attackWaveThreshold;
31-
this.suspiciousRequestsMap = new LRUCache<>(maxLRUEntries, attackWaveTimeFrame);
38+
this.maxSamplesPerIP = maxSamplesPerIP;
39+
this.suspiciousRequestsCounts = new LRUCache<>(maxLRUEntries, attackWaveTimeFrame);
40+
this.suspiciousRequestsSamples = new LRUCache<>(maxLRUEntries, attackWaveTimeFrame);
3241
this.sentEventsMap = new LRUCache<>(maxLRUEntries, minTimeBetweenEvents);
3342
}
3443

@@ -56,16 +65,42 @@ public boolean check(ContextObject ctx) {
5665

5766
// Add 1 to the suspiciousRequests counter.
5867
int suspiciousRequests = 1;
59-
Integer existingSuspiciousRequests = this.suspiciousRequestsMap.get(ip);
68+
Integer existingSuspiciousRequests = this.suspiciousRequestsCounts.get(ip);
6069
if (existingSuspiciousRequests != null) {
6170
suspiciousRequests = existingSuspiciousRequests + 1;
6271
}
63-
this.suspiciousRequestsMap.set(ip, suspiciousRequests);
72+
this.suspiciousRequestsCounts.set(ip, suspiciousRequests);
73+
74+
this.trackSample(ip, ctx.getMethod(), ctx.getUrl());
6475

6576
if (suspiciousRequests < this.attackWaveThreshold) {
6677
return false;
6778
}
6879
this.sentEventsMap.set(ip, System.currentTimeMillis());
6980
return true;
7081
}
82+
83+
public List<Sample> getSamplesForIp(String ip) {
84+
List<Sample> samples = this.suspiciousRequestsSamples.get(ip);
85+
if (samples == null) {
86+
samples = new ArrayList<>();
87+
}
88+
return samples;
89+
}
90+
91+
public record Sample(String method, String url) {}
92+
private void trackSample(String ip, String method, String url) {
93+
List<Sample> samples = getSamplesForIp(ip);
94+
if (samples.size() >= this.maxSamplesPerIP) {
95+
return;
96+
}
97+
98+
for (Sample sample : samples) {
99+
if (Objects.equals(sample.method, method) && Objects.equals(sample.url, url)) {
100+
return; // an equivalent entry already exists, skipping
101+
}
102+
}
103+
samples.add(new Sample(method, url));
104+
this.suspiciousRequestsSamples.set(ip, samples);
105+
}
71106
}

agent_api/src/main/java/dev/aikido/agent_api/storage/attack_wave_detector/AttackWaveDetectorStore.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import dev.aikido.agent_api.helpers.logging.LogManager;
55
import dev.aikido.agent_api.helpers.logging.Logger;
66

7+
import java.util.List;
78
import java.util.concurrent.locks.ReentrantLock;
89

910
public final class AttackWaveDetectorStore {
@@ -25,4 +26,13 @@ public static boolean check(ContextObject ctx) {
2526
mutex.unlock();
2627
}
2728
}
29+
30+
public static List<AttackWaveDetector.Sample> getSamplesForIp(String ip) {
31+
mutex.lock();
32+
try {
33+
return detector.getSamplesForIp(ip);
34+
} finally {
35+
mutex.unlock();
36+
}
37+
}
2838
}

agent_api/src/test/java/attack_wave_detection/AttackWaveDetectorTest.java

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44
import org.junit.jupiter.api.Test;
55
import utils.EmptySampleContextObject;
66

7-
import static org.junit.jupiter.api.Assertions.assertFalse;
8-
import static org.junit.jupiter.api.Assertions.assertTrue;
7+
import java.util.List;
8+
9+
import static org.junit.jupiter.api.Assertions.*;
910

1011
class AttackWaveDetectorTest {
1112

1213
private AttackWaveDetector newAttackWaveDetector() {
1314
// Use much smaller time frames for testing (e.g., 100ms instead of 60s)
14-
return new AttackWaveDetector(6, 100L, 200L, 10_000);
15+
return new AttackWaveDetector(6, 100L, 200L, 10_000, 3);
1516
}
1617

1718
private static boolean checkDetector(AttackWaveDetector detector, String ip, boolean isWebScanner) {
@@ -59,6 +60,12 @@ void testWebScannerWithDelays() throws InterruptedException {
5960
assertFalse(checkDetector(detector, "::1", true));
6061
assertFalse(checkDetector(detector, "::1", true));
6162
assertFalse(checkDetector(detector, "::1", true));
63+
assertArrayEquals(
64+
List.of(
65+
new AttackWaveDetector.Sample("BADMETHOD", "https://example.com/wp-config.php")
66+
).toArray(),
67+
detector.getSamplesForIp("::1").toArray()
68+
);
6269

6370
// Small delay (50ms)
6471
Thread.sleep(50);
@@ -118,4 +125,24 @@ void testSlowWebScannerThirdInterval() throws InterruptedException {
118125
assertFalse(checkDetector(detector, "::1", true));
119126
assertTrue(checkDetector(detector, "::1", true));
120127
}
128+
129+
@Test
130+
void testItRespectsSamplesLimit() {
131+
AttackWaveDetector detector = newAttackWaveDetector();
132+
detector.check(new EmptySampleContextObject("", "/../etc/passwd", "GET"));
133+
detector.check(new EmptySampleContextObject("", "/../etc/passwd", "GET"));
134+
detector.check(new EmptySampleContextObject("", "/test2", "GET"));
135+
detector.check(new EmptySampleContextObject("", "/../etc/passwd", "POST"));
136+
detector.check(new EmptySampleContextObject("", "/test3", "PUT"));
137+
detector.check(new EmptySampleContextObject("", "/.env", "GET"));
138+
detector.check(new EmptySampleContextObject("", "/test4", "BADMETHOD"));
139+
assertArrayEquals(
140+
List.of(
141+
new AttackWaveDetector.Sample("GET", "https://example.com/../etc/passwd"),
142+
new AttackWaveDetector.Sample("POST", "https://example.com/../etc/passwd"),
143+
new AttackWaveDetector.Sample("GET", "https://example.com/.env")
144+
).toArray(),
145+
detector.getSamplesForIp("192.168.1.1").toArray()
146+
);
147+
}
121148
}

agent_api/src/test/java/collectors/WebResponseCollectorTest.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,9 @@ void testReport_WithAttackWaveContext() throws InterruptedException {
135135
assertEquals("123", event.attack().user().id());
136136
assertEquals("Jane Doe", event.attack().user().name());
137137

138-
assertEquals(0, event.attack().metadata().size());
138+
assertEquals(1, event.attack().metadata().size());
139+
assertEquals("[{\"method\":\"BADMETHOD\",\"url\":\"https://example.com/api/resource\"}]", event.attack().metadata().get("samples"));
140+
139141
// check stats changed
140142
assertEquals(1, StatisticsStore.getStatsRecord().requests().attackWaves().total());
141143
}

agent_api/src/test/java/utils/EmptySampleContextObject.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ public EmptySampleContextObject(String argument, String route, String method) {
3636
this();
3737
this.query.put("arg", List.of(argument));
3838
this.route = route;
39+
this.url = "https://example.com" + route;
3940
this.method = method;
4041
}
4142
public EmptySampleContextObject(String route, String method, Map<String, List<String>> queryParams) {

0 commit comments

Comments
 (0)