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
799 lines
22 KiB
TypeScript
799 lines
22 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
type CrowdsecVerdict,
|
|
crowdsecEnabled,
|
|
getCrowdsecApiConfig,
|
|
getCrowdsecBlockMeta,
|
|
getCrowdsecQuotaUsage,
|
|
getLastCrowdsecVerify,
|
|
getMemoryVerdictCacheSize,
|
|
lookupCrowdsecVerdict,
|
|
maybeAutoBlockCrowdsec,
|
|
resetCrowdsecCache,
|
|
setLastCrowdsecVerify,
|
|
verdictIsMalicious,
|
|
verifyCrowdsecConnection,
|
|
} from "./crowdsec-api";
|
|
import { type CrowdsecDailyStat, getCrowdsecStats } from "./crowdsec-stats";
|
|
|
|
// Unit-test the CTI client in isolation: a deterministic in-memory Redis fake
|
|
// and a silenced logger, so fetch calls count only CrowdSec lookups. CrowdSec
|
|
// 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(),
|
|
}));
|
|
|
|
vi.mock("@/lib/services/alert", () => ({
|
|
sendAlert: state.sendAlert,
|
|
ddosDetected: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/lib/redis", () => ({
|
|
redis: {
|
|
get: async (key: string) => state.map.get(key) ?? null,
|
|
set: async (
|
|
key: string,
|
|
value: string,
|
|
_mode?: string,
|
|
_seconds?: number,
|
|
nx?: string,
|
|
) => {
|
|
if (nx === "NX" && state.map.has(key)) return null;
|
|
state.map.set(key, value);
|
|
return "OK";
|
|
},
|
|
del: async (...keys: string[]) => {
|
|
for (const key of keys) state.map.delete(key);
|
|
return keys.length;
|
|
},
|
|
incr: async (key: string) => {
|
|
const next = (Number(state.map.get(key)) || 0) + 1;
|
|
state.map.set(key, String(next));
|
|
return next;
|
|
},
|
|
decr: async (key: string) => {
|
|
const next = (Number(state.map.get(key)) || 0) - 1;
|
|
state.map.set(key, String(next));
|
|
return next;
|
|
},
|
|
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,
|
|
}));
|
|
|
|
vi.mock("@/lib/logger", () => ({
|
|
logger: {
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const tick = () => new Promise((resolve) => setTimeout(resolve, 20));
|
|
|
|
function jsonResponse(body: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
|
|
function maliciousItem(ip: string, score = 5): unknown {
|
|
return {
|
|
ip,
|
|
reputation: "malicious",
|
|
confidence: "0.95",
|
|
scores: { overall: { aggressiveness: 4, total: score } },
|
|
behaviors: [{ name: "http:bruteforce" }, { name: "http:scan" }],
|
|
classifications: { false_positives: [] },
|
|
};
|
|
}
|
|
|
|
function suspiciousItem(ip: string, score: number): unknown {
|
|
return {
|
|
ip,
|
|
reputation: "suspicious",
|
|
scores: { overall: { total: score } },
|
|
};
|
|
}
|
|
|
|
function verdict(minimal: Partial<CrowdsecVerdict> = {}): CrowdsecVerdict {
|
|
return {
|
|
ip: "198.51.100.1",
|
|
reputation: "suspicious",
|
|
score: 3,
|
|
aggressiveness: 0,
|
|
confidence: null,
|
|
behaviors: [],
|
|
falsePositive: false,
|
|
checkedAt: Date.now(),
|
|
...minimal,
|
|
};
|
|
}
|
|
|
|
function blockIp(): string {
|
|
return "198.51.100.1";
|
|
}
|
|
|
|
describe("crowdsec-api", () => {
|
|
let fetchMock: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.unstubAllEnvs();
|
|
state.map.clear();
|
|
state.z.clear();
|
|
state.sendAlert.mockReset();
|
|
resetCrowdsecCache();
|
|
fetchMock = vi.fn();
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.unstubAllEnvs();
|
|
state.map.clear();
|
|
resetCrowdsecCache();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("is enabled only when a non-blank API key is configured", () => {
|
|
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
|
vi.stubEnv("CROWDSEC_CTI_BASE_URL", "https://cti.example.test");
|
|
expect(crowdsecEnabled()).toBe(true);
|
|
expect(getCrowdsecApiConfig().apiKey).toBe("cs_key");
|
|
expect(getCrowdsecApiConfig().baseUrl).toBe("https://cti.example.test");
|
|
|
|
vi.stubEnv("CROWDSEC_API_KEY", " ");
|
|
expect(crowdsecEnabled()).toBe(false);
|
|
vi.stubEnv("CROWDSEC_CTI_BASE_URL", "");
|
|
expect(getCrowdsecApiConfig().baseUrl).toBe(
|
|
"https://cti.api.crowdsec.net/v2",
|
|
);
|
|
});
|
|
|
|
it("blocks malicious reputations at any threshold", () => {
|
|
expect(
|
|
verdictIsMalicious(verdict({ reputation: "malicious", score: 0 }), 5),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("never blocks safe or benign reputations", () => {
|
|
expect(verdictIsMalicious(verdict({ reputation: "safe" }), 0)).toBe(false);
|
|
expect(verdictIsMalicious(verdict({ reputation: "benign" }), 1)).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it("vetoes a false-positive tag even for a malicious reputation", () => {
|
|
expect(
|
|
verdictIsMalicious(
|
|
verdict({ reputation: "malicious", score: 5, falsePositive: true }),
|
|
4,
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("applies the score threshold to suspicious/known attackers", () => {
|
|
const v4 = verdict({ reputation: "suspicious", score: 4 });
|
|
expect(verdictIsMalicious(v4, 4)).toBe(true);
|
|
expect(verdictIsMalicious(v4, 5)).toBe(false);
|
|
expect(verdictIsMalicious(verdict({ score: 3 }), 4)).toBe(false);
|
|
});
|
|
|
|
it("never blocks score-0 (unknown) IPs even at threshold 0", () => {
|
|
expect(
|
|
verdictIsMalicious(verdict({ score: 0, reputation: "unknown" }), 0),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("does nothing without an API key", async () => {
|
|
await maybeAutoBlockCrowdsec({
|
|
ip: blockIp(),
|
|
category: "api",
|
|
ttlSeconds: 600,
|
|
scoreThreshold: 4,
|
|
enabled: true,
|
|
});
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does nothing when the runtime toggle is off", async () => {
|
|
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
|
await maybeAutoBlockCrowdsec({
|
|
ip: blockIp(),
|
|
category: "api",
|
|
ttlSeconds: 600,
|
|
scoreThreshold: 4,
|
|
enabled: false,
|
|
});
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("never consults CrowdSec for the unknown-IP sentinel", async () => {
|
|
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
|
await maybeAutoBlockCrowdsec({
|
|
ip: "0.0.0.0",
|
|
category: "api",
|
|
ttlSeconds: 600,
|
|
scoreThreshold: 4,
|
|
enabled: true,
|
|
});
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("hard-blocks a malicious IP in the shared gate key only", async () => {
|
|
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
|
fetchMock.mockResolvedValue(jsonResponse(maliciousItem(blockIp())));
|
|
|
|
await maybeAutoBlockCrowdsec({
|
|
ip: blockIp(),
|
|
category: "api",
|
|
ttlSeconds: 86_400,
|
|
scoreThreshold: 4,
|
|
enabled: true,
|
|
});
|
|
|
|
expect(state.map.get(`antiddos:block:${blockIp()}`)).toBe("crowdsec");
|
|
// No Cloudflare keys may ever be written by CrowdSec.
|
|
expect(
|
|
[...state.map.keys()].some((key) => key.includes("cloudflare")),
|
|
).toBe(false);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const [url, init] = fetchMock.mock.calls[0];
|
|
expect(String(url)).toContain(`/smoke/${blockIp()}`);
|
|
expect((init.headers as Record<string, string>)["x-api-key"]).toBe(
|
|
"cs_key",
|
|
);
|
|
});
|
|
|
|
it("does not block an IP the community knows nothing about", async () => {
|
|
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
|
fetchMock.mockResolvedValue(jsonResponse({}, 404));
|
|
|
|
await maybeAutoBlockCrowdsec({
|
|
ip: blockIp(),
|
|
category: "api",
|
|
ttlSeconds: 600,
|
|
scoreThreshold: 4,
|
|
enabled: true,
|
|
});
|
|
|
|
expect(state.map.has(`antiddos:block:${blockIp()}`)).toBe(false);
|
|
});
|
|
|
|
it("respects a custom score threshold", async () => {
|
|
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
|
fetchMock.mockResolvedValue(jsonResponse(suspiciousItem(blockIp(), 3)));
|
|
|
|
await maybeAutoBlockCrowdsec({
|
|
ip: blockIp(),
|
|
category: "api",
|
|
ttlSeconds: 600,
|
|
scoreThreshold: 4,
|
|
enabled: true,
|
|
});
|
|
expect(state.map.has(`antiddos:block:${blockIp()}`)).toBe(false);
|
|
|
|
fetchMock.mockResolvedValue(jsonResponse(suspiciousItem(blockIp(), 3)));
|
|
await maybeAutoBlockCrowdsec({
|
|
ip: blockIp(),
|
|
category: "api",
|
|
ttlSeconds: 600,
|
|
scoreThreshold: 3,
|
|
enabled: true,
|
|
});
|
|
expect(state.map.get(`antiddos:block:${blockIp()}`)).toBe("crowdsec");
|
|
});
|
|
|
|
it("dedupes concurrent lookups into a single API call", async () => {
|
|
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
|
fetchMock.mockResolvedValue(jsonResponse(maliciousItem(blockIp())));
|
|
|
|
await Promise.all([
|
|
maybeAutoBlockCrowdsec({
|
|
ip: blockIp(),
|
|
category: "api",
|
|
ttlSeconds: 600,
|
|
scoreThreshold: 4,
|
|
enabled: true,
|
|
}),
|
|
maybeAutoBlockCrowdsec({
|
|
ip: blockIp(),
|
|
category: "api",
|
|
ttlSeconds: 600,
|
|
scoreThreshold: 4,
|
|
enabled: true,
|
|
}),
|
|
]);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("reuses the Redis verdict cache for repeat offenders", async () => {
|
|
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
|
fetchMock.mockResolvedValue(jsonResponse(maliciousItem(blockIp())));
|
|
|
|
for (let i = 0; i < 3; i += 1) {
|
|
await maybeAutoBlockCrowdsec({
|
|
ip: blockIp(),
|
|
category: "api",
|
|
ttlSeconds: 600,
|
|
scoreThreshold: 4,
|
|
enabled: true,
|
|
});
|
|
}
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("never shortens an existing longer host block", async () => {
|
|
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
|
fetchMock.mockResolvedValue(jsonResponse(maliciousItem(blockIp())));
|
|
|
|
state.map.set(`antiddos:block:${blockIp()}`, "1");
|
|
const redis = (await import("@/lib/redis")).redis;
|
|
vi.spyOn(redis as NonNullable<typeof redis>, "pttl").mockImplementation(
|
|
async () => 86_400_000,
|
|
);
|
|
|
|
await maybeAutoBlockCrowdsec({
|
|
ip: blockIp(),
|
|
category: "api",
|
|
ttlSeconds: 600,
|
|
scoreThreshold: 4,
|
|
enabled: true,
|
|
});
|
|
|
|
expect(state.map.get(`antiddos:block:${blockIp()}`)).toBe("1");
|
|
});
|
|
|
|
it("backs off after a 403 so it stops hammering a rejected key", 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 maybeAutoBlockCrowdsec({
|
|
ip: "203.0.113.9",
|
|
category: "api",
|
|
ttlSeconds: 600,
|
|
scoreThreshold: 4,
|
|
enabled: true,
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("publishes the backoff to shared Redis so every instance respects it", 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,
|
|
});
|
|
// The shared marker exists and points into the future.
|
|
const until = Number(state.map.get("crowdsec:backoff-until"));
|
|
expect(Number.isFinite(until)).toBe(true);
|
|
expect(until).toBeGreaterThan(Date.now());
|
|
|
|
// A fresh instance (reset in-process state) still honours the marker.
|
|
resetCrowdsecCache();
|
|
await maybeAutoBlockCrowdsec({
|
|
ip: "203.0.113.44",
|
|
category: "api",
|
|
ttlSeconds: 600,
|
|
scoreThreshold: 4,
|
|
enabled: true,
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("backs off after a 429 rate limit as well", 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 maybeAutoBlockCrowdsec({
|
|
ip: "198.51.100.2",
|
|
category: "api",
|
|
ttlSeconds: 600,
|
|
scoreThreshold: 4,
|
|
enabled: true,
|
|
});
|
|
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));
|
|
|
|
await expect(
|
|
maybeAutoBlockCrowdsec({
|
|
ip: blockIp(),
|
|
category: "api",
|
|
ttlSeconds: 600,
|
|
scoreThreshold: 4,
|
|
enabled: true,
|
|
}),
|
|
).resolves.toBeUndefined();
|
|
expect(state.map.has(`antiddos:block:${blockIp()}`)).toBe(false);
|
|
});
|
|
|
|
it("reports a missing credential without calling the API", async () => {
|
|
const status = await verifyCrowdsecConnection();
|
|
expect(status.ok).toBe(false);
|
|
expect(status.message).toContain("CROWDSEC_API_KEY");
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("verifies the key against the CTI probe endpoint", async () => {
|
|
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
|
fetchMock.mockResolvedValue(
|
|
jsonResponse({ ip: "1.1.1.1", reputation: "safe" }),
|
|
);
|
|
|
|
const status = await verifyCrowdsecConnection();
|
|
expect(status.ok).toBe(true);
|
|
expect(status.message).toContain("1.1.1.1");
|
|
expect(String(fetchMock.mock.calls[0][0])).toContain("/smoke/1.1.1.1");
|
|
expect(
|
|
(fetchMock.mock.calls[0][1].headers as Record<string, string>)[
|
|
"x-api-key"
|
|
],
|
|
).toBe("cs_key");
|
|
});
|
|
|
|
it("surfaces a rejected credential", async () => {
|
|
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
|
fetchMock.mockResolvedValue(jsonResponse({ message: "Invalid key" }, 403));
|
|
|
|
const status = await verifyCrowdsecConnection();
|
|
expect(status.ok).toBe(false);
|
|
expect(status.message).toContain("Invalid key");
|
|
});
|
|
|
|
it("round-trips the last verify status through Redis and memory", async () => {
|
|
const status = { ok: true, message: "CTI key accepted", at: Date.now() };
|
|
await setLastCrowdsecVerify(status);
|
|
expect(await getLastCrowdsecVerify()).toEqual(status);
|
|
expect(JSON.parse(state.map.get("crowdsec:last-verify") ?? "{}")).toEqual(
|
|
status,
|
|
);
|
|
});
|
|
|
|
it("records why it blocked an IP next to the gate key", async () => {
|
|
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
|
fetchMock.mockResolvedValue(jsonResponse(maliciousItem(blockIp())));
|
|
|
|
await maybeAutoBlockCrowdsec({
|
|
ip: blockIp(),
|
|
category: "api",
|
|
ttlSeconds: 86_400,
|
|
scoreThreshold: 4,
|
|
enabled: true,
|
|
});
|
|
|
|
const meta = await getCrowdsecBlockMeta(blockIp());
|
|
expect(meta).not.toBeNull();
|
|
expect(meta?.source).toBe("crowdsec");
|
|
expect(meta?.reputation).toBe("malicious");
|
|
expect(meta?.score).toBe(5);
|
|
expect(meta?.behaviors).toEqual(["http:bruteforce", "http:scan"]);
|
|
expect(meta?.category).toBe("api");
|
|
expect(meta?.ttlSeconds).toBe(86_400);
|
|
expect(meta?.blockedAt).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("stops consulting the API once today's quota is spent", async () => {
|
|
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
|
vi.stubEnv("CROWDSEC_CTI_DAILY_QUOTA", "2");
|
|
// Fresh Response per call — a consumed body must never be re-parsed.
|
|
fetchMock.mockImplementation(() =>
|
|
Promise.resolve(jsonResponse(maliciousItem(blockIp()))),
|
|
);
|
|
|
|
await maybeAutoBlockCrowdsec({
|
|
ip: blockIp(),
|
|
category: "api",
|
|
ttlSeconds: 600,
|
|
scoreThreshold: 4,
|
|
enabled: true,
|
|
});
|
|
await maybeAutoBlockCrowdsec({
|
|
ip: "198.51.100.2",
|
|
category: "api",
|
|
ttlSeconds: 600,
|
|
scoreThreshold: 4,
|
|
enabled: true,
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
expect(state.map.get(`antiddos:block:198.51.100.2`)).toBe("crowdsec");
|
|
|
|
// Third bucket-tripping IP arrives after the quota counter hit 2.
|
|
await maybeAutoBlockCrowdsec({
|
|
ip: "198.51.100.3",
|
|
category: "api",
|
|
ttlSeconds: 600,
|
|
scoreThreshold: 4,
|
|
enabled: true,
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
expect(state.map.has(`antiddos:block:198.51.100.3`)).toBe(false);
|
|
|
|
const usage = await getCrowdsecQuotaUsage();
|
|
expect(usage.quota).toBe(2);
|
|
expect(usage.used).toBe(2);
|
|
expect(usage.exhausted).toBe(true);
|
|
});
|
|
|
|
it("exposes today's quota usage for the admin panel", async () => {
|
|
vi.stubEnv("CROWDSEC_CTI_DAILY_QUOTA", "10000");
|
|
const before = await getCrowdsecQuotaUsage();
|
|
expect(before.quota).toBe(10000);
|
|
expect(before.used).toBe(0);
|
|
expect(before.exhausted).toBe(false);
|
|
expect(before.date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
|
|
state.map.set(`crowdsec:usage:${before.date}`, "9876");
|
|
const after = await getCrowdsecQuotaUsage();
|
|
expect(after.used).toBe(9876);
|
|
});
|
|
|
|
it("caps the in-process verdict cache so it cannot grow forever", async () => {
|
|
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
|
vi.stubEnv("CROWDSEC_CTI_DAILY_QUOTA", "0");
|
|
fetchMock.mockImplementation((url: string | URL) =>
|
|
Promise.resolve(
|
|
jsonResponse(maliciousItem(String(url).split("/").pop() ?? "ip")),
|
|
),
|
|
);
|
|
|
|
// One lookup per distinct IP (never cached before), exceeding the cap —
|
|
// the oldest entries are evicted first, so the cache stays bounded.
|
|
for (let i = 0; i < 2100; i += 1) {
|
|
await lookupCrowdsecVerdict(`198.51.100.${i}`);
|
|
}
|
|
expect(getMemoryVerdictCacheSize()).toBe(2000);
|
|
});
|
|
|
|
it("raises an ops alert when the daily quota is exhausted", 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,
|
|
});
|
|
await maybeAutoBlockCrowdsec({
|
|
ip: "198.51.100.2",
|
|
category: "api",
|
|
ttlSeconds: 600,
|
|
scoreThreshold: 4,
|
|
enabled: true,
|
|
});
|
|
await tick();
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
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("quota exhausted");
|
|
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");
|
|
vi.stubEnv("CROWDSEC_CTI_DAILY_QUOTA", "0");
|
|
fetchMock.mockImplementation((url: string | URL) =>
|
|
Promise.resolve(
|
|
jsonResponse(maliciousItem(String(url).split("/").pop() ?? "ip")),
|
|
),
|
|
);
|
|
|
|
await maybeAutoBlockCrowdsec({
|
|
ip: "198.51.100.71",
|
|
category: "api",
|
|
ttlSeconds: 600,
|
|
scoreThreshold: 4,
|
|
enabled: true,
|
|
});
|
|
await maybeAutoBlockCrowdsec({
|
|
ip: "198.51.100.72",
|
|
category: "api",
|
|
ttlSeconds: 600,
|
|
scoreThreshold: 4,
|
|
enabled: true,
|
|
});
|
|
// Third block in the same window: threshold crossed, but the alert is
|
|
// cooldown-gated so it still fires exactly once.
|
|
await maybeAutoBlockCrowdsec({
|
|
ip: "198.51.100.73",
|
|
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.context).toMatchObject({ blocks: 2, threshold: 2 });
|
|
expect(state.map.get(`antiddos:block:198.51.100.72`)).toBe("crowdsec");
|
|
});
|
|
|
|
it("tallies lookups and blocks into the daily stats histogram", async () => {
|
|
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
|
vi.stubEnv("CROWDSEC_CTI_DAILY_QUOTA", "0");
|
|
fetchMock.mockImplementation((url: string | URL) => {
|
|
const ip = String(url).split("/").pop() ?? "ip";
|
|
return Promise.resolve(
|
|
jsonResponse(
|
|
ip === "198.51.100.83" ? suspiciousItem(ip, 3) : maliciousItem(ip),
|
|
),
|
|
);
|
|
});
|
|
|
|
await maybeAutoBlockCrowdsec({
|
|
ip: "198.51.100.81",
|
|
category: "api",
|
|
ttlSeconds: 600,
|
|
scoreThreshold: 4,
|
|
enabled: true,
|
|
});
|
|
await maybeAutoBlockCrowdsec({
|
|
ip: "198.51.100.82",
|
|
category: "api",
|
|
ttlSeconds: 600,
|
|
scoreThreshold: 4,
|
|
enabled: true,
|
|
});
|
|
await maybeAutoBlockCrowdsec({
|
|
ip: "198.51.100.83",
|
|
category: "api",
|
|
ttlSeconds: 600,
|
|
scoreThreshold: 7, // suspicious/known verdicts below threshold: lookup only
|
|
enabled: true,
|
|
});
|
|
await tick();
|
|
|
|
const stats = await getCrowdsecStats(1);
|
|
const today: CrowdsecDailyStat | undefined = stats.find(
|
|
(row) => row.date === new Date().toISOString().slice(0, 10),
|
|
);
|
|
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)}`,
|
|
),
|
|
).toBe("3");
|
|
});
|
|
});
|