Skip to content

Commit f4a03b5

Browse files
committed
feat(gateway): behavioral anomaly detection for adaptive rate limiting (Smartdevs17#615)
Static per-IP / per-key limits miss distributed attacks (botnets, rotating IPs/keys). This adds unsupervised behavioral anomaly scoring that learns normal per-key usage and adaptively tightens limits when traffic looks anomalous. backend (TypeScript, jest): - backend/gateway/featureExtraction.ts — request rate, endpoint-distribution entropy, time-of-day, payload size, user-agent entropy, geographic spread. - backend/gateway/isolationForest.ts — dependency-free Isolation Forest (unsupervised), deterministic via seeded PRNG, score in [0,1]. - backend/gateway/anomalyDetector.ts — train on normal windows, score new ones. - backend/gateway/adaptiveRateLimit.ts — reduce limit 50% past the threshold (0.8), 90% past 0.95; allow-list (webhooks/health) + per-key override for false positives. - backend/gateway/middleware/adaptiveRateLimitMiddleware.ts — Express-compatible. - backend/monitoring/anomalyMetrics.ts — per-key anomaly-score Prometheus gauge. - 15 jest tests (forest, features, adaptive decisions, detector, metrics, middleware). ml-service (Python, FastAPI — mirrors the model, no new deps): - ml-service/anomaly/{isolation_forest,features,detector}.py — pure-Python port. - ml-service/routers/anomaly.py — /v1/anomaly train/score/status; registered in main. - ml-service/tests/test_anomaly.py — 6 tests. READMEs document covered criteria and documented follow-ups (Slack/PagerDuty alerting, admin dashboard screen, seasonal model + weekly retrain/drift alerts). Closes Smartdevs17#615
1 parent 3e44aa7 commit f4a03b5

16 files changed

Lines changed: 1236 additions & 1 deletion

backend/gateway/README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Rate-Limit Anomaly Detection Gateway (#615)
2+
3+
Behavioral anomaly scoring + adaptive rate limiting to catch distributed attacks
4+
(botnets, rotating IPs/API keys) that slip past static per-IP / per-key limits.
5+
6+
## Pieces
7+
8+
- **`featureExtraction.ts`** — turns a window of recent requests into a feature
9+
vector: request rate, endpoint-distribution entropy, time-of-day, average
10+
payload size, user-agent entropy, geographic (distinct-IP) spread.
11+
- **`isolationForest.ts`** — dependency-free Isolation Forest (unsupervised) that
12+
scores anomalies in `[0, 1]`; deterministic via a seeded PRNG.
13+
- **`anomalyDetector.ts`** — trains on normal-traffic windows and scores new ones.
14+
- **`adaptiveRateLimit.ts`** — when a key's score crosses the threshold (default
15+
`0.8`) the effective limit is reduced 50%, and 90% past `0.95`. Allow-listed
16+
paths (webhooks/health) bypass reduction; per-key overrides handle false
17+
positives.
18+
- **`middleware/adaptiveRateLimitMiddleware.ts`** — Express-compatible middleware
19+
wiring it together (sliding window per key, scoring, enforcement, headers).
20+
- **`../monitoring/anomalyMetrics.ts`** — per-key anomaly-score gauge
21+
(Prometheus text exposition + the repo's flat metric shape).
22+
23+
The Python **`ml-service`** mirrors the model (`ml-service/anomaly/`,
24+
`routers/anomaly.py` at `/v1/anomaly`) so scoring can run in-process in the
25+
gateway or be delegated to the ML service.
26+
27+
## Tests
28+
29+
```bash
30+
# Backend (TS)
31+
npx jest --config jest.backend.config.js backend/gateway/__tests__/anomalyRateLimit.test.ts
32+
33+
# ml-service (Python)
34+
cd ml-service && python -m pytest tests/test_anomaly.py
35+
```
36+
37+
## Covered acceptance criteria
38+
39+
- Feature extraction (rate, endpoint distribution, time-of-day, payload size,
40+
user-agent entropy, geographic spread).
41+
- Isolation Forest anomaly scoring with a configurable threshold.
42+
- Adaptive limiting: reduce by 50% past threshold, 90% past the severe threshold.
43+
- False-positive handling: allow-listed patterns + per-key manual override.
44+
- Anomaly-score Prometheus metric per key (`backend/monitoring`).
45+
46+
## Follow-ups (out of this PR's core)
47+
48+
- Real-time Slack/PagerDuty alerting on high-confidence attacks (score > 0.95) —
49+
the detector already surfaces `high_confidence`.
50+
- Admin `RateLimitDashboardScreen` (`mobile/app/screens/`).
51+
- Seasonal model + event-day whitelisting; weekly auto-retrain + drift alerting
52+
(hooks belong in `ml-service/jobs/` / `ml-service/retrain.py`).
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
/**
2+
* Tests for the rate-limit anomaly detection gateway (#615).
3+
*/
4+
5+
import { IsolationForest } from "../isolationForest";
6+
import { extractFeatures, toVector } from "../featureExtraction";
7+
import { AnomalyDetector } from "../anomalyDetector";
8+
import { decideLimit, isAllowlisted, severityFor } from "../adaptiveRateLimit";
9+
import { AnomalyMetrics } from "../../monitoring/anomalyMetrics";
10+
import {
11+
createAdaptiveRateLimitMiddleware,
12+
type MinimalRequest,
13+
} from "../middleware/adaptiveRateLimitMiddleware";
14+
import type { RequestSample } from "../featureExtraction";
15+
16+
function normalVectors(): number[][] {
17+
// A "normal" cluster with spread in every dimension (constant dims would make
18+
// the forest pick degenerate splits and dilute discrimination).
19+
const out: number[][] = [];
20+
for (let i = 0; i < 200; i++) {
21+
out.push([
22+
1 + (i % 5) * 0.2,
23+
2 + (i % 3) * 0.3,
24+
0.4 + (i % 4) * 0.05,
25+
500 + (i % 7) * 10,
26+
0.1 + (i % 3) * 0.02,
27+
1 + (i % 2),
28+
]);
29+
}
30+
return out;
31+
}
32+
33+
describe("IsolationForest", () => {
34+
it("scores an outlier higher than an inlier", () => {
35+
const forest = new IsolationForest({ trees: 100, sampleSize: 128, seed: 7 }).fit(normalVectors());
36+
const inlier = forest.score([1, 2, 0.5, 500, 0.1, 1]);
37+
const outlier = forest.score([50, 9, 0.99, 90000, 5, 40]);
38+
expect(outlier).toBeGreaterThan(inlier);
39+
expect(inlier).toBeGreaterThanOrEqual(0);
40+
expect(outlier).toBeLessThanOrEqual(1);
41+
});
42+
43+
it("is deterministic for a fixed seed", () => {
44+
const a = new IsolationForest({ seed: 1 }).fit(normalVectors()).score([1, 2, 0.5, 500, 0.1, 1]);
45+
const b = new IsolationForest({ seed: 1 }).fit(normalVectors()).score([1, 2, 0.5, 500, 0.1, 1]);
46+
expect(a).toBe(b);
47+
});
48+
});
49+
50+
describe("featureExtraction", () => {
51+
function reqs(n: number, endpoint: string, spanMs: number): RequestSample[] {
52+
const out: RequestSample[] = [];
53+
for (let i = 0; i < n; i++) {
54+
out.push({
55+
timestamp: 1_700_000_000_000 + (i * spanMs) / n,
56+
endpoint,
57+
payloadSize: 100,
58+
userAgent: "agent/1",
59+
ip: "1.2.3.4",
60+
});
61+
}
62+
return out;
63+
}
64+
65+
it("computes request rate and entropy", () => {
66+
const f = extractFeatures(reqs(60, "/a", 60_000));
67+
expect(f.requestRate).toBeGreaterThan(0);
68+
expect(f.endpointEntropy).toBe(0); // single endpoint => zero entropy
69+
expect(f.geoSpread).toBe(1);
70+
expect(toVector(f)).toHaveLength(6);
71+
});
72+
73+
it("higher endpoint diversity raises entropy", () => {
74+
const mixed: RequestSample[] = [
75+
{ timestamp: 1, endpoint: "/a", payloadSize: 1, userAgent: "x", ip: "1" },
76+
{ timestamp: 2, endpoint: "/b", payloadSize: 1, userAgent: "y", ip: "2" },
77+
{ timestamp: 3, endpoint: "/c", payloadSize: 1, userAgent: "z", ip: "3" },
78+
];
79+
expect(extractFeatures(mixed).endpointEntropy).toBeGreaterThan(0);
80+
expect(extractFeatures(mixed).geoSpread).toBe(3);
81+
});
82+
83+
it("handles an empty window", () => {
84+
expect(extractFeatures([]).requestRate).toBe(0);
85+
});
86+
});
87+
88+
describe("decideLimit (adaptive limiting)", () => {
89+
const config = { baseLimit: 100, threshold: 0.8, severeThreshold: 0.95 };
90+
91+
it("keeps the base limit below threshold", () => {
92+
const d = decideLimit({ key: "k", score: 0.3, config });
93+
expect(d.action).toBe("normal");
94+
expect(d.effectiveLimit).toBe(100);
95+
});
96+
97+
it("reduces by 50% at the threshold", () => {
98+
const d = decideLimit({ key: "k", score: 0.85, config });
99+
expect(d.action).toBe("reduced");
100+
expect(d.effectiveLimit).toBe(50);
101+
expect(d.severity).toBe("medium");
102+
});
103+
104+
it("reduces by 90% at the severe threshold", () => {
105+
const d = decideLimit({ key: "k", score: 0.97, config });
106+
expect(d.action).toBe("severely-reduced");
107+
expect(d.effectiveLimit).toBe(10);
108+
expect(d.severity).toBe("high");
109+
});
110+
111+
it("allow-listed paths bypass reduction", () => {
112+
const d = decideLimit({
113+
key: "k",
114+
score: 0.99,
115+
path: "/webhooks/stripe",
116+
config: { ...config, allowlistPaths: ["/webhooks", "/health"] },
117+
});
118+
expect(d.action).toBe("allowlisted");
119+
expect(d.effectiveLimit).toBe(100);
120+
});
121+
122+
it("per-key override wins (false-positive handling)", () => {
123+
const d = decideLimit({
124+
key: "trusted",
125+
score: 0.99,
126+
config: { ...config, overrides: { trusted: 5000 } },
127+
});
128+
expect(d.action).toBe("override");
129+
expect(d.effectiveLimit).toBe(5000);
130+
});
131+
132+
it("isAllowlisted matches by prefix; severityFor buckets scores", () => {
133+
expect(isAllowlisted("/health/live", ["/health"])).toBe(true);
134+
expect(isAllowlisted("/api/x", ["/health"])).toBe(false);
135+
expect(severityFor(0.96, config)).toBe("high");
136+
expect(severityFor(0.81, config)).toBe("medium");
137+
expect(severityFor(0.1, config)).toBe("low");
138+
});
139+
});
140+
141+
describe("AnomalyDetector", () => {
142+
// Realistic normal traffic varies window-to-window, which the model needs in
143+
// order to learn a distribution (identical windows give a degenerate forest).
144+
function normalWindow(w: number): RequestSample[] {
145+
const n = 40 + (w % 20); // ~40–60 requests/min
146+
const base = 1_700_000_000_000 + w * 60_000;
147+
const endpoints = w % 2 ? ["/api/subscriptions", "/api/usage"] : ["/api/subscriptions"];
148+
return Array.from({ length: n }, (_, i) => ({
149+
timestamp: base + Math.floor((i * 60_000) / n),
150+
endpoint: endpoints[i % endpoints.length],
151+
payloadSize: 350 + (i % 100),
152+
userAgent: "app/1.0",
153+
ip: "10.0.0.1",
154+
}));
155+
}
156+
157+
it("scores anomalous windows higher than normal ones", () => {
158+
const detector = new AnomalyDetector({ seed: 3 }).fit(
159+
Array.from({ length: 60 }, (_, w) => normalWindow(w)),
160+
);
161+
162+
const attackWindow: RequestSample[] = Array.from({ length: 5000 }, (_, i) => ({
163+
timestamp: 1_700_000_000_000 + i, // 5000 reqs in 5s = huge rate
164+
endpoint: `/api/ep${i % 50}`, // scanning many endpoints
165+
payloadSize: 50_000,
166+
userAgent: `bot/${i % 100}`, // rotating UAs
167+
ip: `192.168.${i % 255}.${i % 255}`, // distributed IPs
168+
}));
169+
170+
const normal = detector.scoreWindow(normalWindow(3)).score;
171+
const attack = detector.scoreWindow(attackWindow).score;
172+
expect(attack).toBeGreaterThan(normal);
173+
});
174+
});
175+
176+
describe("AnomalyMetrics", () => {
177+
it("tracks per-key score, max, and high-confidence count", () => {
178+
const m = new AnomalyMetrics(0.95);
179+
m.record("k1", 0.2);
180+
m.record("k2", 0.97);
181+
expect(m.scoreFor("k2")).toBe(0.97);
182+
const flat = m.getMetrics();
183+
expect(flat.anomaly_keys_tracked).toBe(2);
184+
expect(flat.anomaly_score_max).toBe(0.97);
185+
expect(flat.anomaly_high_confidence_total).toBe(1);
186+
expect(m.toPrometheus()).toContain('rate_limit_anomaly_score{key="k2"} 0.97');
187+
});
188+
});
189+
190+
describe("adaptive rate-limit middleware", () => {
191+
function fakeReqRes(path: string, apiKey: string) {
192+
const req: MinimalRequest = {
193+
path,
194+
headers: { "x-api-key": apiKey, "user-agent": "app", "content-length": "100" },
195+
ip: "10.0.0.5",
196+
};
197+
const res = {
198+
statusCode: 200,
199+
headers: {} as Record<string, string | number>,
200+
body: undefined as unknown,
201+
setHeader(n: string, v: string | number) {
202+
this.headers[n] = v;
203+
},
204+
status(code: number) {
205+
this.statusCode = code;
206+
return this;
207+
},
208+
json(b: unknown) {
209+
this.body = b;
210+
},
211+
};
212+
return { req, res };
213+
}
214+
215+
it("passes normal traffic and 429s once the (unfitted) base limit is exceeded", () => {
216+
const detector = new AnomalyDetector(); // not fitted -> score 0 -> base limit
217+
const mw = createAdaptiveRateLimitMiddleware({
218+
detector,
219+
config: { baseLimit: 3 },
220+
windowMs: 60_000,
221+
});
222+
223+
let allowed = 0;
224+
let blocked = 0;
225+
for (let i = 0; i < 5; i++) {
226+
const { req, res } = fakeReqRes("/api/x", "key-A");
227+
mw(req, res, () => {
228+
allowed += 1;
229+
});
230+
if (res.statusCode === 429) blocked += 1;
231+
}
232+
expect(allowed).toBe(3);
233+
expect(blocked).toBe(2);
234+
});
235+
236+
it("sets anomaly headers", () => {
237+
const detector = new AnomalyDetector();
238+
const mw = createAdaptiveRateLimitMiddleware({ detector, config: { baseLimit: 100 } });
239+
const { req, res } = fakeReqRes("/api/x", "key-B");
240+
mw(req, res, () => {});
241+
expect(res.headers["X-RateLimit-Limit"]).toBe(100);
242+
expect(res.headers["X-Anomaly-Action"]).toBe("normal");
243+
});
244+
});

0 commit comments

Comments
 (0)