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
379 lines
12 KiB
TypeScript
379 lines
12 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { CrowdsecVerdict } from "./crowdsec-api";
|
|
import {
|
|
type CrowdsecReportStatus,
|
|
crowdsecReportEnabled,
|
|
getLastCrowdsecReport,
|
|
reportCrowdsecSignal,
|
|
resetCrowdsecReportCache,
|
|
verifyCrowdsecReporting,
|
|
} from "./crowdsec-report";
|
|
|
|
// The signal-push watcher is tested against a deterministic in-memory Redis
|
|
// fake (NX lock + token cache) and a mocked fetch that routes the CAPI paths.
|
|
const state = vi.hoisted(() => ({
|
|
map: new Map<string, string>(),
|
|
sendAlert: 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;
|
|
},
|
|
expire: async () => 1,
|
|
},
|
|
__esModule: true,
|
|
}));
|
|
|
|
vi.mock("@/lib/logger", () => ({
|
|
logger: {
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/lib/services/alert", () => ({
|
|
sendAlert: state.sendAlert,
|
|
ddosDetected: vi.fn(),
|
|
}));
|
|
|
|
const tick = () => new Promise((resolve) => setTimeout(resolve, 20));
|
|
|
|
const CAPI = "https://capi.example.test/v3";
|
|
const MACHINE = "m".repeat(48);
|
|
const PASSWORD = "Strong!1P@ssw0rdStrong!1P@ssw0rd";
|
|
|
|
function jsonResponse(body: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
|
|
function signalInput(ip = "198.51.100.9") {
|
|
const verdict: CrowdsecVerdict = {
|
|
ip,
|
|
reputation: "malicious",
|
|
score: 5,
|
|
aggressiveness: 4,
|
|
confidence: "0.95",
|
|
behaviors: ["http:bruteforce", "http:scan"],
|
|
falsePositive: false,
|
|
checkedAt: Date.now(),
|
|
};
|
|
return {
|
|
ip,
|
|
category: "api",
|
|
ttlSeconds: 86_400,
|
|
verdict,
|
|
meta: {
|
|
source: "crowdsec" as const,
|
|
category: "api",
|
|
reputation: verdict.reputation,
|
|
score: verdict.score,
|
|
behaviors: verdict.behaviors,
|
|
ttlSeconds: 86_400,
|
|
blockedAt: Date.now(),
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("crowdsec-report", () => {
|
|
let fetchMock: ReturnType<typeof vi.fn>;
|
|
|
|
function routeCapi(overrides: Record<string, number> = {}) {
|
|
const statusFor = (path: string) =>
|
|
overrides[path] ?? (path === "/signals" ? 200 : 200);
|
|
fetchMock.mockImplementation((url: string) => {
|
|
const path = String(url).replace(CAPI, "");
|
|
const status = statusFor(path);
|
|
if (status !== 200) {
|
|
return Promise.resolve(jsonResponse({ message: "boom" }, status));
|
|
}
|
|
if (path === "/watchers/login") {
|
|
return Promise.resolve(
|
|
jsonResponse({
|
|
token: "jwt-xyz",
|
|
expire: new Date(Date.now() + 3_600_000).toISOString(),
|
|
}),
|
|
);
|
|
}
|
|
return Promise.resolve(jsonResponse({}));
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.unstubAllEnvs();
|
|
state.map.clear();
|
|
state.sendAlert.mockReset();
|
|
resetCrowdsecReportCache();
|
|
fetchMock = vi.fn();
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
vi.stubEnv("CROWDSEC_REPORT_ENABLED", "true");
|
|
vi.stubEnv("CROWDSEC_REPORT_MACHINE_ID", MACHINE);
|
|
vi.stubEnv("CROWDSEC_REPORT_PASSWORD", PASSWORD);
|
|
vi.stubEnv("CROWDSEC_CAPI_BASE_URL", CAPI);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.unstubAllEnvs();
|
|
state.map.clear();
|
|
resetCrowdsecReportCache();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("is enabled only when the toggle and credentials are present", async () => {
|
|
expect(await crowdsecReportEnabled()).toBe(true);
|
|
|
|
vi.stubEnv("CROWDSEC_REPORT_ENABLED", "");
|
|
resetCrowdsecReportCache();
|
|
expect(await crowdsecReportEnabled()).toBe(false);
|
|
|
|
// Machine id supplied but no password: falls back to generating a
|
|
// stable credential pair persisted in Redis.
|
|
vi.stubEnv("CROWDSEC_REPORT_ENABLED", "true");
|
|
vi.stubEnv("CROWDSEC_REPORT_PASSWORD", "");
|
|
resetCrowdsecReportCache();
|
|
expect(await crowdsecReportEnabled()).toBe(true);
|
|
const storedMachine = state.map.get("crowdsec:report:machine");
|
|
expect(storedMachine).toMatch(/^[A-Za-z0-9]{48}$/);
|
|
expect(state.map.get("crowdsec:report:pass")).toBeTruthy();
|
|
});
|
|
|
|
it("does nothing when the channel is disabled", async () => {
|
|
vi.stubEnv("CROWDSEC_REPORT_ENABLED", "");
|
|
resetCrowdsecReportCache();
|
|
|
|
await reportCrowdsecSignal(signalInput());
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("registers once, caches the token and pushes one signal per IP", async () => {
|
|
routeCapi();
|
|
|
|
await reportCrowdsecSignal(signalInput("198.51.100.10"));
|
|
await reportCrowdsecSignal(signalInput("198.51.100.11"));
|
|
await reportCrowdsecSignal(signalInput("198.51.100.10"));
|
|
// Let the fire-and-forget network body land.
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
|
|
const urls = fetchMock.mock.calls.map((call) => String(call[0]));
|
|
expect(urls.filter((u) => u.endsWith("/watchers/register"))).toHaveLength(
|
|
1,
|
|
);
|
|
expect(urls.filter((u) => u.endsWith("/watchers/login"))).toHaveLength(1);
|
|
expect(urls.filter((u) => u.endsWith("/signals"))).toHaveLength(2);
|
|
// No enrollment requested without an attachment key.
|
|
expect(urls.some((u) => u.endsWith("/watchers/enroll"))).toBe(false);
|
|
});
|
|
|
|
it("builds a well-formed CrowdSec signal with a ban decision", async () => {
|
|
routeCapi();
|
|
|
|
await reportCrowdsecSignal(signalInput());
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
|
|
const signalsCall = fetchMock.mock.calls.find((call) =>
|
|
String(call[0]).endsWith("/signals"),
|
|
);
|
|
expect(signalsCall).toBeDefined();
|
|
if (!signalsCall) throw new Error("expected a /signals call");
|
|
const init = signalsCall[1] as {
|
|
body: string;
|
|
headers: Record<string, string>;
|
|
};
|
|
const body = JSON.parse(init.body) as Record<string, unknown>[];
|
|
expect(body).toHaveLength(1);
|
|
const signal = body[0] as {
|
|
machine_id: string;
|
|
scenario: string;
|
|
scenario_version: string;
|
|
source: { scope: string; value: string; ip: string };
|
|
decisions: {
|
|
scope: string;
|
|
type: string;
|
|
value: string;
|
|
duration: string;
|
|
}[];
|
|
context: { key: string; value: string }[];
|
|
created_at: string;
|
|
start_at: string;
|
|
stop_at: string;
|
|
};
|
|
expect(signal.machine_id).toBe(MACHINE);
|
|
expect(signal.scenario).toBe("community/anti-ddos-block");
|
|
expect(signal.scenario_version).toBe("1.0.0");
|
|
expect(signal.source).toEqual({
|
|
scope: "ip",
|
|
value: "198.51.100.9",
|
|
ip: "198.51.100.9",
|
|
});
|
|
expect(signal.decisions).toHaveLength(1);
|
|
expect(signal.decisions[0]).toMatchObject({
|
|
origin: "crowdsec",
|
|
scope: "ip",
|
|
type: "ban",
|
|
value: "198.51.100.9",
|
|
});
|
|
expect(String(signal.decisions[0].duration)).toMatch(/^24h0m0s$/);
|
|
for (const key of ["created_at", "start_at", "stop_at"] as const) {
|
|
expect(typeof signal[key]).toBe("string");
|
|
}
|
|
expect(
|
|
signal.context.find((c) => c.key === "crowdsec_reputation")?.value,
|
|
).toBe("malicious");
|
|
});
|
|
|
|
it("records a healthy last-report state after a successful push", async () => {
|
|
routeCapi();
|
|
await reportCrowdsecSignal(signalInput());
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
const last = await getLastCrowdsecReport();
|
|
expect(last?.ok).toBe(true);
|
|
});
|
|
|
|
it("never throws and logs the failure when the CAPI rejects the signal", async () => {
|
|
fetchMock.mockImplementation((url: string) => {
|
|
const path = String(url).replace(CAPI, "");
|
|
if (path === "/watchers/login") {
|
|
return Promise.resolve(
|
|
jsonResponse({
|
|
token: "jwt-xyz",
|
|
expire: new Date(Date.now() + 3_600_000).toISOString(),
|
|
}),
|
|
);
|
|
}
|
|
if (path === "/signals") {
|
|
return Promise.resolve(jsonResponse({ message: "boom" }, 500));
|
|
}
|
|
return Promise.resolve(jsonResponse({}));
|
|
});
|
|
|
|
await expect(reportCrowdsecSignal(signalInput())).resolves.toBeUndefined();
|
|
await tick();
|
|
const last: CrowdsecReportStatus | null = await getLastCrowdsecReport();
|
|
expect(last?.ok).toBe(false);
|
|
expect(last?.message).toContain("signal push rejected");
|
|
});
|
|
|
|
it("counts a failed push and raises a cooldown-gated ops alert", async () => {
|
|
fetchMock.mockImplementation((url: string) => {
|
|
const path = String(url).replace(CAPI, "");
|
|
if (path === "/watchers/login") {
|
|
return Promise.resolve(
|
|
jsonResponse({
|
|
token: "jwt-xyz",
|
|
expire: new Date(Date.now() + 3_600_000).toISOString(),
|
|
}),
|
|
);
|
|
}
|
|
if (path === "/signals") {
|
|
return Promise.resolve(jsonResponse({ message: "boom" }, 500));
|
|
}
|
|
return Promise.resolve(jsonResponse({}));
|
|
});
|
|
|
|
await reportCrowdsecSignal(signalInput("198.51.100.20"));
|
|
await tick();
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
expect(state.map.get(`crowdsec:stat:report_fail:${today}`)).toBe("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.context).toMatchObject({ ip: "198.51.100.20" });
|
|
|
|
// A second failed push inside the cooldown window stays silent.
|
|
await reportCrowdsecSignal(signalInput("198.51.100.21"));
|
|
await tick();
|
|
expect(state.sendAlert).toHaveBeenCalledTimes(1);
|
|
expect(state.map.get(`crowdsec:stat:report_fail:${today}`)).toBe("2");
|
|
});
|
|
|
|
it("tallies successful pushes into the daily stats histogram", async () => {
|
|
routeCapi();
|
|
await reportCrowdsecSignal(signalInput("198.51.100.30"));
|
|
await reportCrowdsecSignal(signalInput("198.51.100.31"));
|
|
await tick();
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
expect(state.map.get(`crowdsec:stat:reports:${today}`)).toBe("2");
|
|
expect(state.sendAlert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("raises an info alert the first time the channel heals after failures", async () => {
|
|
fetchMock.mockImplementation((url: string) => {
|
|
const path = String(url).replace(CAPI, "");
|
|
if (path === "/watchers/login") {
|
|
return Promise.resolve(
|
|
jsonResponse({
|
|
token: "jwt-xyz",
|
|
expire: new Date(Date.now() + 3_600_000).toISOString(),
|
|
}),
|
|
);
|
|
}
|
|
if (path === "/signals") {
|
|
return Promise.resolve(jsonResponse({ message: "boom" }, 500));
|
|
}
|
|
return Promise.resolve(jsonResponse({}));
|
|
});
|
|
|
|
await reportCrowdsecSignal(signalInput("198.51.100.40"));
|
|
await tick();
|
|
expect(state.sendAlert).toHaveBeenCalledTimes(1);
|
|
expect((await getLastCrowdsecReport())?.ok).toBe(false);
|
|
|
|
// Channel heals: the first success after a failure is worth a notice.
|
|
routeCapi();
|
|
await reportCrowdsecSignal(signalInput("198.51.100.41"));
|
|
await tick();
|
|
const last: CrowdsecReportStatus | null = await getLastCrowdsecReport();
|
|
expect(last?.ok).toBe(true);
|
|
|
|
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("recovered");
|
|
expect(alerts[1].context).toMatchObject({ ip: "198.51.100.41" });
|
|
});
|
|
|
|
it("verifies the watcher channel end to end", async () => {
|
|
routeCapi();
|
|
const status = await verifyCrowdsecReporting();
|
|
expect(status.ok).toBe(true);
|
|
expect(String(fetchMock.mock.calls[0][0])).toContain("/watchers/register");
|
|
});
|
|
|
|
it("reports a clear reason when verification is impossible", async () => {
|
|
vi.stubEnv("CROWDSEC_REPORT_ENABLED", "");
|
|
resetCrowdsecReportCache();
|
|
const status = await verifyCrowdsecReporting();
|
|
expect(status.ok).toBe(false);
|
|
expect(status.message).toContain("CROWDSEC_REPORT_ENABLED");
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|