feat(security): give back to CrowdSec and harden the CTI budget
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 29s
CI / tests-integration (push) Successful in 1m34s
CI / tests-unit (push) Successful in 1m36s
CI / tests-ui (push) Successful in 2m22s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 1m53s
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 29s
CI / tests-integration (push) Successful in 1m34s
CI / tests-unit (push) Successful in 1m36s
CI / tests-ui (push) Successful in 2m22s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 1m53s
- Bound the in-process verdict cache (FIFO eviction at 2000 entries) so a
flood of distinct bucket-tripping IPs cannot grow it without limit.
- Record block metadata (reputation, score, behaviors, category, TTL) in
antiddos:block:meta:{ip}, surfaced as the reason in the admin block list;
unban now also clears the metadata and report locks.
- Track daily CTI enrichment usage in Redis (crowdsec:usage:{date}); warn
once at 80% and pause lookups until tomorrow at CROWDSEC_CTI_DAILY_QUOTA
(default 10000, 0 = unlimited) so a via-spread DDoS cannot burn the plan.
- Add opt-in signal push to the CrowdSec community (CAPI watcher): stable
auto-generated 48-char machine_id/password pair persisted in Redis (or via
env), one-time registration, cached JWT login, optional Console enrollment,
and POST /v3/signals with a ban decision, deduped per IP. Never throws and
reports last status to the admin panel with a verify action.
- Admin page: quota usage bar, reporting status/verify channel, and CrowdSec
block reasons in the active-blocks list.
This commit is contained in:
1 parent
ee25545b7f
commit
5e4fc9ab59
8 files changed
+1323
-37
No files matched your search
@@ -0,0 +1,280 @@
|
||||
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>() }));
|
||||
|
||||
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;
|
||||
},
|
||||
},
|
||||
__esModule: true,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logger", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
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();
|
||||
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 new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const last: CrowdsecReportStatus | null = await getLastCrowdsecReport();
|
||||
expect(last?.ok).toBe(false);
|
||||
expect(last?.message).toContain("signal push rejected");
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user