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
@@ -45,7 +45,7 @@ import {
|
||||
crowdsecReportEnabled,
|
||||
getLastCrowdsecReport,
|
||||
} from "@/lib/crowdsec-report";
|
||||
import { getCrowdsecStats } from "@/lib/crowdsec-stats";
|
||||
import { type CrowdsecDailyStat, getCrowdsecStats } from "@/lib/crowdsec-stats";
|
||||
import { db, WebsiteSetting } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { redis } from "@/lib/redis";
|
||||
@@ -58,6 +58,40 @@ function seconds(ttlMs: number): string {
|
||||
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
|
||||
}
|
||||
|
||||
function BarSparkline({ values }: { values: number[] }) {
|
||||
if (values.length === 0) return null;
|
||||
const max = Math.max(...values, 1);
|
||||
return (
|
||||
<div className="flex items-end gap-[3px] h-10" aria-hidden="true">
|
||||
{values.map((v, i) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static timeline position is the bar's identity
|
||||
key={i}
|
||||
className="w-full rounded-sm bg-primary/60"
|
||||
style={{
|
||||
height: `${Math.max(v > 0 ? 6 : 2, (v / max) * 100)}%`,
|
||||
opacity: v === 0 ? 0.15 : 0.6 + (v / max) * 0.4,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Merge per-day breakdown maps (categories / reputations) into range totals. */
|
||||
function mergeBreakdowns(
|
||||
rows: CrowdsecDailyStat[],
|
||||
kind: keyof Pick<CrowdsecDailyStat, "categories" | "reputations">,
|
||||
): Record<string, number> {
|
||||
const totals: Record<string, number> = {};
|
||||
for (const row of rows) {
|
||||
for (const [k, v] of Object.entries(row[kind])) {
|
||||
totals[k] = (totals[k] ?? 0) + v;
|
||||
}
|
||||
}
|
||||
return totals;
|
||||
}
|
||||
|
||||
export default async function AdminAntiDdosPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
|
||||
@@ -727,10 +761,42 @@ export default async function AdminAntiDdosPage() {
|
||||
|
||||
{crowdsecStats.length > 0 && (
|
||||
<div className="rounded-md border p-3">
|
||||
<p className="text-xs font-medium mb-2">
|
||||
Daily activity (last {crowdsecStats.length} days)
|
||||
</p>
|
||||
<div className="max-h-40 overflow-y-auto">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-xs font-medium">
|
||||
Daily activity (last {crowdsecStats.length} days)
|
||||
</p>
|
||||
<a
|
||||
href="/admin/alerts"
|
||||
className="text-xs text-muted-foreground underline-offset-2 hover:underline"
|
||||
>
|
||||
Ops alert history →
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-2 grid gap-4 sm:grid-cols-3">
|
||||
<BarSparkline values={crowdsecStats.map((row) => row.blocks)} />
|
||||
<div className="col-span-2 flex flex-wrap items-center gap-1.5">
|
||||
{Object.entries(
|
||||
mergeBreakdowns(crowdsecStats, "categories"),
|
||||
).map(([category, count]) => (
|
||||
<Badge key={category} variant="secondary">
|
||||
{category} · {count.toLocaleString()}
|
||||
</Badge>
|
||||
))}
|
||||
{Object.entries(
|
||||
mergeBreakdowns(crowdsecStats, "reputations"),
|
||||
).map(([reputation, count]) => (
|
||||
<Badge
|
||||
key={reputation}
|
||||
variant={
|
||||
reputation === "malicious" ? "destructive" : "secondary"
|
||||
}
|
||||
>
|
||||
{reputation} · {count.toLocaleString()}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 max-h-40 overflow-y-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-muted-foreground">
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+52
-11
File diff suppressed because it is too large.
Load diff
@@ -323,6 +323,43 @@ describe("crowdsec-report", () => {
|
||||
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();
|
||||
|
||||
@@ -348,7 +348,9 @@ async function pushSignal(input: {
|
||||
body: [
|
||||
{
|
||||
machine_id: input.credentials.machineId,
|
||||
message: "atomcms-next anti-DDoS gate blocked a community-flagged IP",
|
||||
message: input.verdict.reputation
|
||||
? "atomcms-next anti-DDoS gate blocked a community-flagged IP"
|
||||
: "atomcms-next anti-DDoS gate blocked a repeat rate-limit offender",
|
||||
scenario: SCENARIO,
|
||||
scenario_version: SCENARIO_VERSION,
|
||||
scenario_hash: scenarioHash(),
|
||||
@@ -390,9 +392,21 @@ async function pushSignal(input: {
|
||||
`signal push rejected (HTTP ${response.status}): ${await errorDetail(response)}`,
|
||||
);
|
||||
}
|
||||
// Was the channel down before this? A first success after failures
|
||||
// deserves a recovery notice (separate cooldown key from the failure).
|
||||
const before = await getLastCrowdsecReport();
|
||||
const status: CrowdsecReportStatus = { ok: true, at: Date.now() };
|
||||
await setLastCrowdsecReport(status);
|
||||
void bumpCrowdsecStat("reports");
|
||||
if (before && !before.ok) {
|
||||
void raiseCrowdsecAlert("report-recovered", {
|
||||
type: "ddos",
|
||||
severity: "info",
|
||||
message:
|
||||
"CrowdSec signal push recovered — detections are reaching the community again.",
|
||||
context: { ip: input.ip },
|
||||
});
|
||||
}
|
||||
logger.info("[crowdsec-report] Detection shared with the community", {
|
||||
ip: input.ip,
|
||||
category: input.category,
|
||||
|
||||
+106
-47
File diff suppressed because it is too large.
Load diff
@@ -6,7 +6,16 @@ import { enforceDdosRateLimit } from "@/lib/ddos-guard";
|
||||
|
||||
// The gate's block escalation (and thus the CrowdSec hook) only runs when
|
||||
// Redis is reachable, so the integration test drives a small in-memory fake.
|
||||
const state = vi.hoisted(() => ({ map: new Map<string, string>() }));
|
||||
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: {
|
||||
@@ -31,8 +40,24 @@ vi.mock("@/lib/redis", () => ({
|
||||
state.map.set(key, String(next));
|
||||
return next;
|
||||
},
|
||||
expire: async () => 1,
|
||||
pexpire: 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,
|
||||
sadd: async (key: string, member: string) => {
|
||||
const members = new Set(
|
||||
(state.map.get(key) ?? "").split("\u0001").filter(Boolean),
|
||||
@@ -95,6 +120,8 @@ describe("anti-DDoS automatic CrowdSec blocks", () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
state.map.clear();
|
||||
state.z.clear();
|
||||
state.sendAlert.mockReset();
|
||||
resetCrowdsecCache();
|
||||
invalidateAntiddosConfig();
|
||||
fetchMock = vi.fn();
|
||||
|
||||
@@ -8,6 +8,11 @@ import { resolveClientIp } from "@/lib/client-ip";
|
||||
import { isCloudflareProxied } from "@/lib/cloudflare";
|
||||
import { maybeAutoBlockCloudflare } from "@/lib/cloudflare-api";
|
||||
import { maybeAutoBlockCrowdsec } from "@/lib/crowdsec-api";
|
||||
import { reportCrowdsecSignal } from "@/lib/crowdsec-report";
|
||||
import {
|
||||
bumpCrowdsecBreakdownStat,
|
||||
bumpCrowdsecStat,
|
||||
} from "@/lib/crowdsec-stats";
|
||||
import { classifyDdos, isSuspiciousPath } from "@/lib/ddos";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { redis } from "@/lib/redis";
|
||||
@@ -113,6 +118,36 @@ export async function enforceDdosRateLimit(
|
||||
const ttl = blockTtlForViolations(violations, config.blockTiers);
|
||||
if (violations >= config.maxViolations) {
|
||||
await redis.set(blockKey, "1", "EX", ttl);
|
||||
// Share the block in the daily activity histogram + breakdown.
|
||||
void bumpCrowdsecStat("blocks");
|
||||
void bumpCrowdsecBreakdownStat("category", category);
|
||||
// Opt-in: also push our OWN detection (not just CrowdSec-
|
||||
// reputation blocks) into the community blocklist via the same
|
||||
// CAPI channel. Fire-and-forget; deduped per IP internally.
|
||||
void reportCrowdsecSignal({
|
||||
ip,
|
||||
category,
|
||||
ttlSeconds: ttl,
|
||||
verdict: {
|
||||
ip,
|
||||
reputation: null,
|
||||
score: 0,
|
||||
aggressiveness: 0,
|
||||
confidence: null,
|
||||
behaviors: [`gate:${category}`],
|
||||
falsePositive: false,
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
meta: {
|
||||
source: "gate",
|
||||
category,
|
||||
reputation: null,
|
||||
score: 0,
|
||||
behaviors: [`gate:${category}`],
|
||||
ttlSeconds: ttl,
|
||||
blockedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
// Mirror the host-level block to the Cloudflare edge (IP Access
|
||||
// Rules) so a repeat offender is shed before it reaches the
|
||||
// origin. Only when this request demonstrably transited
|
||||
|
||||
Reference in new issue
Block a user