Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/lint-and-format.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ jobs:
pnpm test
python3 ../tests/installer-recovery.py
python3 ../tests/crs-observation.py
python3 ../tests/crs-control.py
python3 ../tests/heal-migration.py
- name: security invariants
run: node tests/security-invariants.mjs
Expand Down
4 changes: 4 additions & 0 deletions ADVANCED.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ Fresh v1.19 and later installations recommend AppSec by default. Ordinary update

CRS observation is an explicit v1.65 opt-in on an installer-managed CrowdSec stack: `sudo bash setup-npmplus.sh --update --enable-crs`. It also enables AppSec if necessary and uses the normal transactional snapshot and rollback. Fresh installs keep the baseline high-confidence rules until this option is applied. Ordinary updates preserve the choice.

Starting with installer v1.66, an ordinary update also installs the dashboard activation helper. In **CrowdSec → WAF → OWASP CRS observations**, choose **Enable CRS observation**, review the confirmation, and watch its progress. The button requires an installer-managed CrowdSec stack with AppSec already enabled and the default AppSec acquisition list. It stays unavailable on older installations, standalone Compose deployments, and custom policies; the CLI opt-in above remains available and can enable AppSec too. Updating only the application image does not install the host helper. No SSH access from the application is required.

Dashboard activation is a single-purpose host operation, not an image update. The admin-only API audits the request and sends one fixed verb over a private Unix socket mounted read-only into NPMplus; Docker socket access, command arguments, arbitrary paths, policy uploads, and disabling protections are not exposed. The root-installed helper shares the CLI observation policy, locks against updates/backups/restore, snapshots the private CrowdSec configuration, validates it, restarts only CrowdSec, and waits for health before recording the installer marker. During either restart, fail-closed protection may briefly deny proxied traffic and dashboard API requests; the dashboard retains the last activation progress and retries status automatically. CrowdSec may regenerate its own local machine credential at startup. Failure restores the previous configuration and restarts CrowdSec; a separate recovery-failed state needs host repair. Attempts have a persistent five-minute cooldown. Helper diagnostics remain in `journalctl -u npmplus-crs-control.service`; API responses contain only bounded status values. Standard PUID/PGID 1000 deployments are supported; custom user mappings require reviewing Unix-socket access. Backup creation and restore remain CLI-only. Ordinary updates roll back helper files with host tooling; uninstall removes its units and executables. Reboot creates the socket before Docker starts.

The installer adds `npmplus/crs-observe` beside `crowdsecurity/appsec-default`, installs only the `crowdsecurity/crs` rule package, and keeps Hub-managed policies unchanged. CRS runs out of band: matches produce alerts and metrics, with `allow` scoped to CRS out-of-band matches. Native observation alerts are retained while CRS scenario events are suppressed, preventing automatic-ban scenarios from consuming these observations. The CRS collection's automatic-ban scenario is not installed. Existing virtual patches still block, and the firewall bouncer and Anubis remain enabled. This is observation, not full CRS blocking; requests that bypass AppSec per host/location also bypass CRS. Custom AppSec acquisition lists or an operator-owned observation file require manual review and are rejected rather than replaced.

In **CrowdSec → WAF**, **OWASP CRS observations** shows the installer setting separately from observed live matches, the top ten rule IDs with attack families, and out-of-band attack-pattern match counts since CrowdSec restart. CRS initialization, anomaly-score evaluation, and correlation counters are excluded from those totals. A request can match several rules; these totals are neither unique requests nor confirmed exploits or bans. Quiet counters do not establish rule loading. Missing, disabled, unavailable, stale, and in-band-match states remain distinct. The other-rules list includes both evaluation bands, with CRS out-of-band rules shown only in the dedicated observation panel. The installer marker in the bouncer config describes its last configured choice; remove `# NPMPLUS_CRS_MODE=observe` if manually removing the observation policy. Review actual alerts and tune false positives before selectively enabling blocking; do not use an unreviewed blanket CRS blocking switch.
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ All notable changes to the NPMplus Security Fork are documented here. The fork u

## Unreleased

- Installer v1.66 adds an admin-only **Enable CRS observation** button under CrowdSec → WAF for managed stacks with AppSec enabled. A private, single-action host helper validates the shared observation policy, snapshots configuration, waits for CrowdSec health, and rolls back failures without exposing Docker or arbitrary host commands to the application. Ordinary installer updates install the helper; existing deployments require that update once.

- Added installer v1.65 `--update --enable-crs` for transactional CRS observation beside existing high-confidence AppSec blocking. The WAF dashboard separates installer configuration, observed CRS matches, and in-band warnings; match counts stay distinct from blocked requests. Hub-managed policies and ordinary-update choices remain intact. Safe-update wrapper v6 also snapshots and restores the bouncer configuration.

- Reorganize the CrowdSec overview around service health, a scoped security snapshot, and separate WAF request outcomes. Add service shortcuts and keyboard-accessible investigation filters, with compact phone summaries and clearer chart grouping.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Review the script before running it on a production host. Select **Install NPMpl

Develop installer v1.62 uses fork-maintained [CrowdSec](security-images/crowdsec/README.md) and [Anubis](security-images/anubis/README.md) builds, tested and scanned on both architectures. It pins images and matching Anubis policies. Existing RC installers retain their original behavior; the build guides explain maintenance responsibilities.

Optional [CRS observation](ADVANCED.md) appears in **CrowdSec → WAF**.
Enable and monitor [CRS](ADVANCED.md) in **CrowdSec → WAF**.

For the easiest first login, provide an administrator email and password when the installer asks. The password is handled as a temporary secret and is not saved in `compose.yaml`.

Expand Down
2 changes: 2 additions & 0 deletions backend/.smoke/ui-driver.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,8 @@ const api = async (route) => {
windowHours: 24,
truncated: false,
});
if (apiPath === "/crowdsec/crs")
return respond({ available: true, eligible: true, enabled: true, state: "enabled", retryAfter: 0 });
if (apiPath === "/crowdsec/metrics")
return respond({
available: true,
Expand Down
68 changes: 68 additions & 0 deletions backend/internal/crs-control.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import net from "node:net";
import { publicError } from "./crowdsec.js";

const STATES = new Set(["idle", "running", "enabled", "failed", "rollback-failed"]);
const REASONS = new Set(["busy", "unsupported", "cooldown"]);

// Only fixed verbs cross this boundary. The host never receives a command,
// filename, URL, credentials, or user-controlled policy from the web app.
export const createCrsControl = (socketPath = "/run/npmplus-crs-control/control.sock") => {
const exchange = (verb) =>
new Promise((resolve, reject) => {
const socket = net.createConnection(socketPath);
let body = "";
const finish = (error, result) => {
clearTimeout(timer);
socket.destroy();
if (error) reject(publicError("crowdsec.crs.control-unavailable", 503));
else resolve(result);
};
const timer = setTimeout(() => finish(true), 3000);
socket.on("error", () => finish(true));
socket.on("connect", () => socket.write(`${verb}\n`));
socket.on("data", (chunk) => {
body += chunk.toString("utf8");
if (Buffer.byteLength(body) > 1024) return finish(true);
if (body.endsWith("\n")) {
try {
finish(false, JSON.parse(body));
} catch {
finish(true);
}
}
});
socket.on("end", () => {
if (!body.endsWith("\n")) finish(true);
});
});
return {
status: async () => {
const data = await exchange("STATUS").catch(() => null);
if (
data?.version !== 1 ||
data.available !== true ||
typeof data.eligible !== "boolean" ||
typeof data.enabled !== "boolean" ||
!STATES.has(data.state)
)
return { available: false, eligible: false, enabled: false, state: "unavailable" };
return {
available: true,
eligible: data.eligible,
enabled: data.enabled,
state: data.state,
retry_after: Number.isSafeInteger(data.retry_after) ? Math.min(300, Math.max(0, data.retry_after)) : 0,
};
},
enable: async () => {
const data = await exchange("ENABLE");
if (data?.accepted === true && ["running", "enabled"].includes(data.state))
return { accepted: true, state: data.state };
if (data?.accepted === false && REASONS.has(data.reason))
throw publicError(`crowdsec.crs.control-${data.reason}`, data.reason === "cooldown" ? 429 : 409);
throw publicError("crowdsec.crs.control-unavailable", 503);
},
};
};

export default createCrsControl();
33 changes: 33 additions & 0 deletions backend/routes/crowdsec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import process from "node:process";
import express from "express";
import { rateLimit } from "express-rate-limit";
import { canonicalIp, readReport } from "../internal/anubis-reporting.js";
import internalAuditLog from "../internal/audit-log.js";
import {
Expand All @@ -15,6 +16,7 @@ import {
} from "../internal/crowdsec.js";
import { readAttackerCatalog } from "../internal/crowdsec-attackers.js";
import { scanAlertHistory } from "../internal/crowdsec-history.js";
import crsControl from "../internal/crs-control.js";
import { getHomeLocation } from "../internal/home-location.js";
import { readTelemetry } from "../internal/security-telemetry.js";
import { fetchWithTimeout, readBoundedText } from "../lib/bounded-fetch.js";
Expand Down Expand Up @@ -135,6 +137,37 @@ const requireAdmin = (res) => {
}
};

const crsEnableLimit = rateLimit({
windowMs: 5 * 60 * 1000,
limit: 5,
standardHeaders: "draft-8",
legacyHeaders: false,
skip: (_, res) => !requireAdmin(res),
handler: (_, res) => res.status(429).send({ error: { message: "crowdsec.crs.control-cooldown" } }),
});

router
.route("/crs")
.options((_, res) => res.sendStatus(204))
.all(jwtdecode())
.get(async (_, res) => {
if (!(await requireAdmin(res))) return res.status(403).send({ error: { message: "access.denied" } });
res.set("Cache-Control", "no-store");
return res.status(200).send(await crsControl.status());
})
.post(crsEnableLimit, async (req, res) => {
if (!(await requireAdmin(res))) return res.status(403).send({ error: { message: "access.denied" } });
if (!req.is("application/json") || req.body?.mode !== "observe" || Object.keys(req.body).length !== 1)
return res.status(400).send({ error: { message: "crowdsec.crs.control-invalid" } });
// Persist intent before asking the host to change its configuration.
await internalAuditLog.add(res.locals.access, {
action: "enable-requested",
object_type: "crowdsec-crs",
meta: { mode: "observe", status: "requested" },
});
return res.status(202).send(await crsControl.enable());
});

const queryString = (value, maxLength = 256) =>
typeof value === "string" && value.length <= maxLength ? value.trim() : "";

Expand Down
70 changes: 70 additions & 0 deletions backend/test/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import crypto from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import net from "node:net";
import { after, test } from "node:test";
import internalAuditLog from "../internal/audit-log.js";
import crsControl from "../internal/crs-control.js";

// the container image creates these before the app boots; the nginx config
// generation and access-list handling expect them to exist
Expand Down Expand Up @@ -708,6 +710,74 @@ test("proxy host without a tcp destination clears the reachability probe state",
// CrowdSec routes: no LAPI is wired in the test environment, so these pin the
// permission wall and the input-validation guards that fire before the LAPI
// is ever contacted
test("CRS control requires admin, fixed observation input, same origin and a prior audit", async () => {
const originalStatus = crsControl.status;
const originalEnable = crsControl.enable;
const originalAudit = internalAuditLog.add;
let calls = 0;
crsControl.status = async () => ({ available: true, eligible: true, enabled: false, state: "idle" });
crsControl.enable = async () => {
calls += 1;
return { accepted: true, state: "running" };
};
try {
for (const cookie of [undefined, peonCookie]) {
assert.equal((await api("GET", "/api/crowdsec/crs", { cookie })).status, 403);
assert.equal((await api("POST", "/api/crowdsec/crs", { cookie, body: { mode: "observe" } })).status, 403);
}
const invalidMac = crypto
.createHmac("sha256", "api-test-cookie-secret")
.update("invalid")
.digest("base64")
.replace(/[=]+$/, "");
assert.equal(
(
await api("POST", "/api/crowdsec/crs", {
cookie: `__Host-Http-token=s:invalid.${invalidMac}`,
body: { mode: "observe" },
})
).status,
401,
);
for (const body of [{ mode: "block" }, { mode: "observe", command: "unused" }, {}])
assert.equal((await api("POST", "/api/crowdsec/crs", { cookie: adminCookie, body })).status, 400);
assert.equal(
(
await api("POST", "/api/crowdsec/crs", {
cookie: adminCookie,
body: { mode: "observe" },
headers: { origin: "https://attacker.example" },
})
).status,
403,
);
assert.equal(calls, 0);
const accepted = await api("POST", "/api/crowdsec/crs", { cookie: adminCookie, body: { mode: "observe" } });
assert.equal(accepted.status, 202);
assert.equal(calls, 1);
const logs = await api("GET", "/api/audit-log", { cookie: adminCookie });
assert.ok(
logs.body.some((entry) => entry.object_type === "crowdsec-crs" && entry.action === "enable-requested"),
);
internalAuditLog.add = async () => {
throw new Error("fixture audit failure");
};
assert.equal(
(await api("POST", "/api/crowdsec/crs", { cookie: adminCookie, body: { mode: "observe" } })).status,
500,
);
assert.equal(calls, 1);
assert.equal(
(await api("POST", "/api/crowdsec/crs", { cookie: adminCookie, body: { mode: "observe" } })).status,
429,
);
} finally {
crsControl.status = originalStatus;
crsControl.enable = originalEnable;
internalAuditLog.add = originalAudit;
}
});

test("peon is refused on crowdsec routes", async () => {
const res = await api("GET", "/api/crowdsec/decisions", { cookie: peonCookie });
assert.equal(res.status, 403);
Expand Down
77 changes: 77 additions & 0 deletions backend/test/crs-control.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import "./helpers/environment.js";
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { createCrsControl } from "../internal/crs-control.js";

const fixture = async (t, reply) => {
const directory = await mkdtemp(path.join(os.tmpdir(), "npmplus-crs-"));
const socketPath = path.join(directory, "control.sock");
const verbs = [];
const connections = [];
const server = net.createServer((socket) => {
connections.push(socket);
socket.on("error", () => {});
socket.once("data", (data) => {
verbs.push(data.toString());
if (reply === null) return;
socket.end(reply);
});
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(socketPath, resolve);
});
t.after(async () => {
for (const connection of connections) connection.destroy();
await new Promise((resolve) => server.close(resolve));
await rm(directory, { recursive: true, force: true });
});
return { client: createCrsControl(socketPath), verbs };
};

test("CRS control uses fixed verbs and sanitizes host status", async (t) => {
const { client, verbs } = await fixture(
t,
`${JSON.stringify({ version: 1, available: true, eligible: true, enabled: false, state: "idle", retry_after: 999, key: "fixture-secret", path: "/root" })}\n`,
);
assert.deepEqual(await client.status(), {
available: true,
eligible: true,
enabled: false,
state: "idle",
retry_after: 300,
});
assert.deepEqual(verbs, ["STATUS\n"]);
});

test("CRS control activation cannot forward caller arguments", async (t) => {
const { client, verbs } = await fixture(t, '{"accepted":true,"state":"running","output":"not-public"}\n');
assert.deepEqual(await client.enable({ command: "not-a-command" }), { accepted: true, state: "running" });
assert.deepEqual(verbs, ["ENABLE\n"]);
});

test("CRS control rejects oversized, partial and malformed responses", async (t) => {
for (const reply of ["x".repeat(1025), '{"available":true}', "invalid\n", '{"accepted":true,"state":"shell"}\n']) {
await t.test(reply.slice(0, 30), async (sub) => {
const { client } = await fixture(sub, reply);
assert.equal((await client.status()).available, false);
await assert.rejects(client.enable(), { status: 503, message: "crowdsec.crs.control-unavailable" });
});
}
});

test("CRS control reports persistent host cooldown without exposing output", async (t) => {
const { client } = await fixture(t, '{"accepted":false,"reason":"cooldown","output":"not-public"}\n');
await assert.rejects(client.enable(), { status: 429, message: "crowdsec.crs.control-cooldown" });
});

test("a hung CRS helper becomes unavailable within the request budget", { timeout: 5000 }, async (t) => {
const { client } = await fixture(t, null);
const start = Date.now();
assert.equal((await client.status()).available, false);
assert.ok(Date.now() - start < 4500);
});
16 changes: 16 additions & 0 deletions frontend/src/api/backend/getCrowdsecDecisions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,22 @@ export interface CrowdsecMetrics {
averageParsingMs?: number | null;
}

export interface CrowdsecCrsControl {
available: boolean;
eligible: boolean;
enabled: boolean;
state: "idle" | "running" | "enabled" | "failed" | "rollback-failed" | "unavailable";
retryAfter?: number;
}

export async function getCrowdsecCrsControl(signal?: AbortSignal): Promise<CrowdsecCrsControl> {
return await api.get({ url: "/crowdsec/crs" }, signal);
}

export async function enableCrowdsecCrs(): Promise<{ accepted: true; state: "running" | "enabled" }> {
return await api.post({ url: "/crowdsec/crs", data: { mode: "observe" } });
}

export async function createCrowdsecBan(data: CrowdsecBanInput): Promise<CrowdsecBanResult> {
return await api.post({ url: "/crowdsec/decisions", data });
}
Expand Down
Loading
Loading