feat(security): recovery alerts, gate-block sharing, rolling-window burst and admin breakdown for CrowdSec
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m42s
CI / tests-unit (push) Successful in 1m50s
CI / tests-ui (push) Successful in 2m42s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m3s
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m42s
CI / tests-unit (push) Successful in 1m50s
CI / tests-ui (push) Successful in 2m42s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m3s
This commit is contained in:
1 parent
301edd2c9a
commit
3e1a3f92c8
8 files changed
+448
-65
No files matched your search
@@ -21,6 +21,7 @@ import { type CrowdsecDailyStat, getCrowdsecStats } from "./crowdsec-stats";
|
||||
// deliberately never touches Cloudflare, so no Cloudflare surface is stubbed.
|
||||
const state = vi.hoisted(() => ({
|
||||
map: new Map<string, string>(),
|
||||
z: new Map<string, Array<[number, string]>>(),
|
||||
sendAlert: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -59,6 +60,21 @@ vi.mock("@/lib/redis", () => ({
|
||||
},
|
||||
expire: async () => 1,
|
||||
pttl: async () => 60_000,
|
||||
zadd: async (key: string, score: number, member: string) => {
|
||||
const list = state.z.get(key) ?? [];
|
||||
list.push([score, member]);
|
||||
list.sort((a, b) => a[0] - b[0]);
|
||||
state.z.set(key, list);
|
||||
return 1;
|
||||
},
|
||||
zremrangebyscore: async (key: string, min: number, max: number) => {
|
||||
const list = (state.z.get(key) ?? []).filter(
|
||||
([score]) => score < min || score > max,
|
||||
);
|
||||
state.z.set(key, list);
|
||||
return 1;
|
||||
},
|
||||
zcard: async (key: string) => (state.z.get(key) ?? []).length,
|
||||
},
|
||||
__esModule: true,
|
||||
}));
|
||||
@@ -125,6 +141,7 @@ describe("crowdsec-api", () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
state.map.clear();
|
||||
state.z.clear();
|
||||
state.sendAlert.mockReset();
|
||||
resetCrowdsecCache();
|
||||
fetchMock = vi.fn();
|
||||
@@ -418,6 +435,47 @@ describe("crowdsec-api", () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("raises a critical ops alert when the CTI key is rejected (403)", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
fetchMock.mockResolvedValue(jsonResponse({ message: "Invalid key" }, 403));
|
||||
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
await tick();
|
||||
expect(state.sendAlert).toHaveBeenCalledTimes(1);
|
||||
const [input] = state.sendAlert.mock.calls[0];
|
||||
expect(input.type).toBe("ddos");
|
||||
expect(input.severity).toBe("critical");
|
||||
expect(input.message).toContain("403");
|
||||
expect(input.message).toContain("CROWDSEC_API_KEY");
|
||||
expect(input.context).toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it("raises a warning ops alert when the CTI rate limit is hit (429)", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
fetchMock.mockResolvedValue(jsonResponse({ message: "rate limited" }, 429));
|
||||
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
await tick();
|
||||
expect(state.sendAlert).toHaveBeenCalledTimes(1);
|
||||
const [input] = state.sendAlert.mock.calls[0];
|
||||
expect(input.type).toBe("ddos");
|
||||
expect(input.severity).toBe("warning");
|
||||
expect(input.message).toContain("rate limited");
|
||||
expect(input.context).toMatchObject({ status: 429 });
|
||||
});
|
||||
|
||||
it("swallows API failures instead of throwing on the hot path", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
fetchMock.mockResolvedValue(jsonResponse({ message: "boom" }, 500));
|
||||
@@ -602,6 +660,50 @@ describe("crowdsec-api", () => {
|
||||
expect(input.context).toMatchObject({ quota: 1 });
|
||||
});
|
||||
|
||||
it("alerts once when quota becomes available again after exhaustion", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
vi.stubEnv("CROWDSEC_CTI_DAILY_QUOTA", "1");
|
||||
fetchMock.mockImplementation(() =>
|
||||
Promise.resolve(jsonResponse(maliciousItem(blockIp()))),
|
||||
);
|
||||
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
// Exhaust the counter.
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: "198.51.100.2",
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
// The counter is externally reset (new billing day / fresh deployment):
|
||||
// the next successful reserve should call it out.
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
state.map.set(`crowdsec:usage:${date}`, "0");
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: "198.51.100.3",
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
await tick();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(state.sendAlert).toHaveBeenCalledTimes(2);
|
||||
const alerts = state.sendAlert.mock.calls.map(([input]) => input);
|
||||
expect(alerts[0].severity).toBe("warning");
|
||||
expect(alerts[1].severity).toBe("info");
|
||||
expect(alerts[1].message).toContain("available again");
|
||||
expect(alerts[1].context).toMatchObject({ quota: 1 });
|
||||
});
|
||||
|
||||
it("floods once per cooldown window when blocks burst past the threshold", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
vi.stubEnv("CROWDSEC_ALERT_BLOCK_BURST", "2");
|
||||
@@ -685,6 +787,8 @@ describe("crowdsec-api", () => {
|
||||
expect(today?.lookups).toBe(3);
|
||||
expect(today?.blocks).toBe(2);
|
||||
expect(today?.reportFailures).toBe(0);
|
||||
expect(today?.categories).toMatchObject({ api: 2 });
|
||||
expect(today?.reputations).toMatchObject({ malicious: 2 });
|
||||
expect(
|
||||
state.map.get(
|
||||
`crowdsec:stat:lookups:${new Date().toISOString().slice(0, 10)}`,
|
||||
|
||||
Reference in new issue
Block a user