From 4793e4a968826c0e7f5ae6d7d9c45d2a6c3fd0b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Thu, 4 Sep 2025 15:37:19 +0200 Subject: [PATCH 1/2] Count all detected attacks for wave --- library/agent/Agent.ts | 4 ++ .../http-server/createRequestListener.ts | 10 +++-- .../http-server/http2/createStreamListener.ts | 10 +++-- .../AttackWaveDetector.ts | 42 +++++++++++++------ 4 files changed, 47 insertions(+), 19 deletions(-) diff --git a/library/agent/Agent.ts b/library/agent/Agent.ts index 8d9c0e629..a69db72ab 100644 --- a/library/agent/Agent.ts +++ b/library/agent/Agent.ts @@ -240,6 +240,10 @@ export class Agent { blocked, }); + if (request.remoteAddress) { + this.attackWaveDetector.increaseSuspiciousCount(request.remoteAddress); + } + this.attackLogger.log(attack); if (this.token) { diff --git a/library/sources/http-server/createRequestListener.ts b/library/sources/http-server/createRequestListener.ts index b8b685a1d..102ea9647 100644 --- a/library/sources/http-server/createRequestListener.ts +++ b/library/sources/http-server/createRequestListener.ts @@ -111,9 +111,13 @@ function createOnFinishRequestHandler( agent.onRouteRateLimited(context.rateLimitedEndpoint); } - if (agent.getAttackWaveDetector().check(context)) { - agent.onDetectedAttackWave({ request: context, metadata: {} }); - agent.getInspectionStatistics().onAttackWaveDetected(); + if (context.remoteAddress) { + agent.getAttackWaveDetector().check(context); + + if (agent.getAttackWaveDetector().shouldReport(context.remoteAddress)) { + agent.onDetectedAttackWave({ request: context, metadata: {} }); + agent.getInspectionStatistics().onAttackWaveDetected(); + } } } }; diff --git a/library/sources/http-server/http2/createStreamListener.ts b/library/sources/http-server/http2/createStreamListener.ts index f94f9a4db..ff24d5902 100644 --- a/library/sources/http-server/http2/createStreamListener.ts +++ b/library/sources/http-server/http2/createStreamListener.ts @@ -76,9 +76,13 @@ function discoverRouteFromStream( agent.onRouteRateLimited(context.rateLimitedEndpoint); } - if (agent.getAttackWaveDetector().check(context)) { - agent.onDetectedAttackWave({ request: context, metadata: {} }); - agent.getInspectionStatistics().onAttackWaveDetected(); + if (context.remoteAddress) { + agent.getAttackWaveDetector().check(context); + + if (agent.getAttackWaveDetector().shouldReport(context.remoteAddress)) { + agent.onDetectedAttackWave({ request: context, metadata: {} }); + agent.getInspectionStatistics().onAttackWaveDetected(); + } } } } diff --git a/library/vulnerabilities/attack-wave-detection/AttackWaveDetector.ts b/library/vulnerabilities/attack-wave-detection/AttackWaveDetector.ts index 5570511be..6e2ec6e0d 100644 --- a/library/vulnerabilities/attack-wave-detection/AttackWaveDetector.ts +++ b/library/vulnerabilities/attack-wave-detection/AttackWaveDetector.ts @@ -40,30 +40,37 @@ export class AttackWaveDetector { /** * Checks if the request is part of an attack wave - * Will report to core once in a defined time frame when the threshold is exceeded - * @returns true if an attack wave is detected and should be reported + * If yes, it will increase the count of suspicious requests */ - check(context: Context): boolean { + check(context: Context): void { if (!context.remoteAddress) { - return false; + return; } - const ip = context.remoteAddress; - - const sentEventTime = this.sentEventsMap.get(ip); - - if (sentEventTime) { + if (this.sentEventsMap.get(context.remoteAddress)) { // The last event was sent recently - return false; + return; } if (!isWebScanner(context)) { - return false; + return; } - const suspiciousRequests = (this.suspiciousRequestsMap.get(ip) || 0) + 1; - this.suspiciousRequestsMap.set(ip, suspiciousRequests); + this.increaseSuspiciousCount(context.remoteAddress); + } + + /** + * Checks if a new detected attack wave should be reported + * @returns True if a event should be sent to core, false otherwise + */ + shouldReport(ip: string): boolean { + const sentEventTime = this.sentEventsMap.get(ip); + if (sentEventTime) { + // The last event was sent recently + return false; + } + const suspiciousRequests = this.getSuspiciousCount(ip); if (suspiciousRequests < this.attackWaveThreshold) { return false; } @@ -72,4 +79,13 @@ export class AttackWaveDetector { return true; } + + increaseSuspiciousCount(ip: string) { + const suspiciousRequests = (this.suspiciousRequestsMap.get(ip) || 0) + 1; + this.suspiciousRequestsMap.set(ip, suspiciousRequests); + } + + getSuspiciousCount(ip: string) { + return this.suspiciousRequestsMap.get(ip) || 0; + } } From 54002107353ea582d3d556700201137564c117cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Thu, 4 Sep 2025 16:39:21 +0200 Subject: [PATCH 2/2] Updates tests --- library/agent/Agent.test.ts | 46 +++++ .../AttackWaveDetector.test.ts | 190 ++++++++++++------ 2 files changed, 177 insertions(+), 59 deletions(-) diff --git a/library/agent/Agent.test.ts b/library/agent/Agent.test.ts index a7f9beab2..543a610ef 100644 --- a/library/agent/Agent.test.ts +++ b/library/agent/Agent.test.ts @@ -1278,3 +1278,49 @@ t.test("attack wave detected event", async (t) => { }, ]); }); + +t.test( + "attack reported by sink increases attack wave suspicious count", + async (t) => { + const logger = new LoggerNoop(); + const api = new ReportingAPIForTesting(); + const agent = createTestAgent({ + api, + logger, + token: new Token("123"), + }); + + t.same(agent.getAttackWaveDetector().getSuspiciousCount("::1"), 0); + + agent.onDetectedAttack({ + module: "mongodb", + kind: "nosql_injection", + blocked: true, + source: "body", + request: { + method: "POST", + cookies: {}, + query: {}, + headers: { + "user-agent": "agent", + }, + body: "payload", + url: "http://localhost:4000", + remoteAddress: "::1", + source: "express", + route: "/posts/:id", + routeParams: {}, + }, + operation: "operation", + payload: { $gt: "" }, + stack: "stack", + paths: [".nested"], + metadata: { + db: "app", + }, + }); + + t.same(agent.getAttackWaveDetector().getSuspiciousCount("::1"), 1); + t.same(agent.getAttackWaveDetector().getSuspiciousCount("::2"), 0); + } +); diff --git a/library/vulnerabilities/attack-wave-detection/AttackWaveDetector.test.ts b/library/vulnerabilities/attack-wave-detection/AttackWaveDetector.test.ts index 7fa888e70..b69faf62d 100644 --- a/library/vulnerabilities/attack-wave-detection/AttackWaveDetector.test.ts +++ b/library/vulnerabilities/attack-wave-detection/AttackWaveDetector.test.ts @@ -35,69 +35,103 @@ function newAttackWaveDetector() { t.test("no ip address", async (t) => { const detector = newAttackWaveDetector(); - t.notOk(detector.check(getTestContext(undefined, "/wp-config.php", "GET"))); + detector.check(getTestContext(undefined, "/wp-config.php", "GET")); }); t.test("not a web scanner", async (t) => { const detector = newAttackWaveDetector(); - t.notOk(detector.check(getTestContext("::1", "/", "OPTIONS"))); - t.notOk(detector.check(getTestContext("::1", "/", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/login", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/dashboard", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/dashboard/2", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/settings", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/dashboard", "GET"))); + + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/", "OPTIONS")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/login", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/dashboard", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/dashboard/2", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/settings", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/dashboard", "GET")); + t.notOk(detector.shouldReport("::1")); + + t.notOk(detector.shouldReport("::2")); }); t.test("a web scanner", async (t) => { const detector = newAttackWaveDetector(); - t.notOk(detector.check(getTestContext("::1", "/wp-config.php", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/wp-config.php.bak", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/.git/config", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/.env", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/.htaccess", "GET"))); + detector.check(getTestContext("::1", "/wp-config.php", "GET")); + detector.check(getTestContext("::1", "/wp-config.php.bak", "GET")); + detector.check(getTestContext("::1", "/.git/config", "GET")); + detector.check(getTestContext("::1", "/.env", "GET")); + detector.check(getTestContext("::1", "/.htaccess", "GET")); // Is true because the threshold is 6 - t.ok(detector.check(getTestContext("::1", "/.htpasswd", "GET"))); + detector.check(getTestContext("::1", "/.htpasswd", "GET")); + t.ok(detector.shouldReport("::1")); + // False again because event should have been sent last time - t.notOk(detector.check(getTestContext("::1", "/.htpasswd", "GET"))); + detector.check(getTestContext("::1", "/.htpasswd", "GET")); }); t.test("a web scanner with delays", async (t) => { const clock = FakeTimers.install(); const detector = newAttackWaveDetector(); - t.notOk(detector.check(getTestContext("::1", "/wp-config.php", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/wp-config.php.bak", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/.git/config", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/.env", "GET"))); + detector.check(getTestContext("::1", "/wp-config.php", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/wp-config.php.bak", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/.git/config", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/.env", "GET")); + t.notOk(detector.shouldReport("::1")); clock.tick(30 * 1000); - t.notOk(detector.check(getTestContext("::1", "/.htaccess", "GET"))); + detector.check(getTestContext("::1", "/.htaccess", "GET")); + t.notOk(detector.shouldReport("::1")); + // Is true because the threshold is 6 - t.ok(detector.check(getTestContext("::1", "/.htpasswd", "GET"))); + detector.check(getTestContext("::1", "/.htpasswd", "GET")); + t.ok(detector.shouldReport("::1")); // False again because event should have been sent last time - t.notOk(detector.check(getTestContext("::1", "/.htpasswd", "GET"))); + detector.check(getTestContext("::1", "/.htpasswd", "GET")); + t.notOk(detector.shouldReport("::1")); clock.tick(30 * 60 * 1000); // Still false because minimum time between events is 1 hour - t.notOk(detector.check(getTestContext("::1", "/.env", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/wp-config.php", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/wp-config.php.bak", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/.git/config", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/.env", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/.htaccess", "GET"))); + detector.check(getTestContext("::1", "/.env", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/wp-config.php", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/wp-config.php.bak", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/.git/config", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/.env", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/.htaccess", "GET")); + t.notOk(detector.shouldReport("::1")); clock.tick(32 * 60 * 1000); // Should resend event after 1 hour - t.notOk(detector.check(getTestContext("::1", "/.env", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/wp-config.php", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/wp-config.php.bak", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/.git/config", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/.env", "GET"))); - t.ok(detector.check(getTestContext("::1", "/.htaccess", "GET"))); + detector.check(getTestContext("::1", "/.env", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/wp-config.php", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/wp-config.php.bak", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/.git/config", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/.env", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/.htaccess", "GET")); + t.ok(detector.shouldReport("::1")); clock.uninstall(); }); @@ -105,19 +139,30 @@ t.test("a web scanner with delays", async (t) => { t.test("a slow web scanner that triggers in the second interval", async (t) => { const clock = FakeTimers.install(); const detector = newAttackWaveDetector(); - t.notOk(detector.check(getTestContext("::1", "/wp-config.php", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/wp-config.php.bak", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/.git/config", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/.env", "GET"))); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/wp-config.php", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/wp-config.php.bak", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/.git/config", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/.env", "GET")); + t.notOk(detector.shouldReport("::1")); clock.tick(62 * 1000); - t.notOk(detector.check(getTestContext("::1", "/.env", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/wp-config.php", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/wp-config.php.bak", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/.git/config", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/.env", "GET"))); - t.ok(detector.check(getTestContext("::1", "/.htaccess", "GET"))); + detector.check(getTestContext("::1", "/.env", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/wp-config.php", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/wp-config.php.bak", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/.git/config", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/.env", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/.htaccess", "GET")); + t.ok(detector.shouldReport("::1")); clock.uninstall(); }); @@ -125,28 +170,55 @@ t.test("a slow web scanner that triggers in the second interval", async (t) => { t.test("a slow web scanner that triggers in the third interval", async (t) => { const clock = FakeTimers.install(); const detector = newAttackWaveDetector(); - t.notOk(detector.check(getTestContext("::1", "/wp-config.php", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/wp-config.php.bak", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/.git/config", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/.env", "GET"))); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/wp-config.php", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/wp-config.php.bak", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/.git/config", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/.env", "GET")); + t.notOk(detector.shouldReport("::1")); clock.tick(62 * 1000); // Still false because minimum time between events is 1 hour - t.notOk(detector.check(getTestContext("::1", "/.env", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/wp-config.php", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/wp-config.php.bak", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/.git/config", "GET"))); + detector.check(getTestContext("::1", "/.env", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/wp-config.php", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/wp-config.php.bak", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/.git/config", "GET")); + t.notOk(detector.shouldReport("::1")); clock.tick(62 * 1000); // Should resend event after 1 hour - t.notOk(detector.check(getTestContext("::1", "/.env", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/wp-config.php", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/wp-config.php.bak", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/.git/config", "GET"))); - t.notOk(detector.check(getTestContext("::1", "/.env", "GET"))); - t.ok(detector.check(getTestContext("::1", "/.htaccess", "GET"))); + detector.check(getTestContext("::1", "/.env", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/wp-config.php", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/wp-config.php.bak", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/.git/config", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/.env", "GET")); + t.notOk(detector.shouldReport("::1")); + detector.check(getTestContext("::1", "/.htaccess", "GET")); + t.ok(detector.shouldReport("::1")); clock.uninstall(); }); + +t.test("increase attack count manually", async (t) => { + const detector = newAttackWaveDetector(); + + for (let i = 0; i < 6; i++) { + t.notOk(detector.shouldReport("::1")); + detector.increaseSuspiciousCount("::1"); + } + t.ok(detector.shouldReport("::1")); + + t.same(detector.getSuspiciousCount("::1"), 6); +});