From 137fa3175521cd84c8a11260ee2d8a584383fcf9 Mon Sep 17 00:00:00 2001 From: White-Lotus <95460219+mangyan1@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:22:44 -0600 Subject: [PATCH 1/4] add safe dashboard activation for crs observation --- .github/workflows/lint-and-format.yml | 1 + ADVANCED.md | 4 + CHANGELOG.md | 2 + README.md | 2 +- backend/.smoke/ui-driver.mjs | 2 + backend/internal/crs-control.js | 68 +++ backend/routes/crowdsec.js | 33 ++ backend/test/api.test.js | 70 ++++ backend/test/crs-control.test.js | 77 ++++ .../src/api/backend/getCrowdsecDecisions.ts | 16 + frontend/src/pages/Crowdsec/CrsControl.tsx | 113 +++++ frontend/src/pages/Crowdsec/CrsMonitoring.tsx | 8 +- frontend/translations/ui/en.json | 18 + setup-npmplus.sh | 396 +++++++++++++++++- tests/crs-control.py | 218 ++++++++++ 15 files changed, 1018 insertions(+), 10 deletions(-) create mode 100644 backend/internal/crs-control.js create mode 100644 backend/test/crs-control.test.js create mode 100644 frontend/src/pages/Crowdsec/CrsControl.tsx create mode 100644 tests/crs-control.py diff --git a/.github/workflows/lint-and-format.yml b/.github/workflows/lint-and-format.yml index 6c60f27f27..f23639a121 100644 --- a/.github/workflows/lint-and-format.yml +++ b/.github/workflows/lint-and-format.yml @@ -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 diff --git a/ADVANCED.md b/ADVANCED.md index 84a2cb5c96..294deb92a7 100644 --- a/ADVANCED.md +++ b/ADVANCED.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index a7df7e6342..398ef4d7d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 4a2e6c4db4..102d39bcb2 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/backend/.smoke/ui-driver.mjs b/backend/.smoke/ui-driver.mjs index ec5ce30e37..de6ca899fc 100644 --- a/backend/.smoke/ui-driver.mjs +++ b/backend/.smoke/ui-driver.mjs @@ -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, diff --git a/backend/internal/crs-control.js b/backend/internal/crs-control.js new file mode 100644 index 0000000000..4657bba429 --- /dev/null +++ b/backend/internal/crs-control.js @@ -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(); diff --git a/backend/routes/crowdsec.js b/backend/routes/crowdsec.js index 8711248572..5e0ca8e8e0 100644 --- a/backend/routes/crowdsec.js +++ b/backend/routes/crowdsec.js @@ -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 { @@ -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"; @@ -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() : ""; diff --git a/backend/test/api.test.js b/backend/test/api.test.js index daa0c66c78..8effb719bd 100644 --- a/backend/test/api.test.js +++ b/backend/test/api.test.js @@ -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 @@ -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); diff --git a/backend/test/crs-control.test.js b/backend/test/crs-control.test.js new file mode 100644 index 0000000000..5ad8e86115 --- /dev/null +++ b/backend/test/crs-control.test.js @@ -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); +}); diff --git a/frontend/src/api/backend/getCrowdsecDecisions.ts b/frontend/src/api/backend/getCrowdsecDecisions.ts index 401a67f8df..2f49fc41e4 100644 --- a/frontend/src/api/backend/getCrowdsecDecisions.ts +++ b/frontend/src/api/backend/getCrowdsecDecisions.ts @@ -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 { + 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 { return await api.post({ url: "/crowdsec/decisions", data }); } diff --git a/frontend/src/pages/Crowdsec/CrsControl.tsx b/frontend/src/pages/Crowdsec/CrsControl.tsx new file mode 100644 index 0000000000..56dd244f8d --- /dev/null +++ b/frontend/src/pages/Crowdsec/CrsControl.tsx @@ -0,0 +1,113 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import Alert from "react-bootstrap/Alert"; +import Button from "react-bootstrap/Button"; +import Modal from "react-bootstrap/Modal"; +import { enableCrowdsecCrs, getCrowdsecCrsControl } from "src/api/backend"; +import { T } from "src/locale"; + +const CrsControl = ({ configured }: { configured: boolean }) => { + const [confirm, setConfirm] = useState(false); + const client = useQueryClient(); + const status = useQuery({ + queryKey: ["crowdsec-crs-control"], + queryFn: ({ signal }) => getCrowdsecCrsControl(signal), + refetchInterval: 5000, + retry: false, + }); + const enable = useMutation({ + mutationFn: enableCrowdsecCrs, + onSuccess: async () => { + setConfirm(false); + await client.invalidateQueries({ queryKey: ["crowdsec-crs-control"] }); + await client.invalidateQueries({ queryKey: ["crowdsec-metrics"] }); + await client.invalidateQueries({ queryKey: ["audit-logs"] }); + }, + }); + const data = status.data; + const fresh = !status.isError && !status.isRefetchError; + const running = data?.state === "running"; + const enabled = fresh && (data?.available ? data.enabled : configured); + useEffect(() => { + if (fresh && data?.state === "enabled") void client.invalidateQueries({ queryKey: ["crowdsec-metrics"] }); + }, [client, data?.state, fresh]); + const allowed = fresh && data?.available && data.eligible && !running && !enabled && !data.retryAfter; + let help = "crowdsec.crs.control-loading"; + if (running) help = status.isError ? "crowdsec.crs.control-reconnecting" : "crowdsec.crs.control-running"; + else if (status.isError) help = "crowdsec.crs.control-unavailable"; + else if (data?.available === false) help = "crowdsec.crs.control-install"; + else if (enabled) help = "crowdsec.crs.control-enabled"; + else if (data && !data.eligible) help = "crowdsec.crs.control-unsupported"; + else if (data?.retryAfter) help = "crowdsec.crs.control-cooldown"; + else if (data) help = "crowdsec.crs.control-ready"; + return ( +
+
+ + + + +
+ {fresh && ["failed", "rollback-failed"].includes(data?.state ?? "") && ( + + + + )} + !enable.isPending && setConfirm(false)} + aria-labelledby="crs-enable-title" + centered + > + + + + + + +

+ +

+ {enable.isError && ( + + + + )} +
+ + + + +
+
+ ); +}; + +export default CrsControl; diff --git a/frontend/src/pages/Crowdsec/CrsMonitoring.tsx b/frontend/src/pages/Crowdsec/CrsMonitoring.tsx index 6ba1d19e14..6279bed38d 100644 --- a/frontend/src/pages/Crowdsec/CrsMonitoring.tsx +++ b/frontend/src/pages/Crowdsec/CrsMonitoring.tsx @@ -1,6 +1,7 @@ import Alert from "react-bootstrap/Alert"; import type { CrowdsecMetrics } from "src/api/backend"; import { intl, T } from "src/locale"; +import CrsControl from "./CrsControl"; import { crsStatus } from "./shared"; const CrsMonitoring = ({ data, stale }: { data: CrowdsecMetrics; stale: boolean }) => { @@ -20,14 +21,11 @@ const CrsMonitoring = ({ data, stale }: { data: CrowdsecMetrics; stale: boolean

- {data.crsInstallerMode === "observe" ? ( + + {data.crsInstallerMode === "observe" && (

- ) : ( -

- --update --enable-crs -

)} {reported && (data.crsInbandHits ?? 0) > 0 && ( diff --git a/frontend/translations/ui/en.json b/frontend/translations/ui/en.json index 865e84084d..616106a5d6 100644 --- a/frontend/translations/ui/en.json +++ b/frontend/translations/ui/en.json @@ -306,6 +306,24 @@ "crowdsec.collapse": "Hide alert context for {target}", "crowdsec.collapse-details": "Hide details", "crowdsec.crs.configured-help": "The installer selected observation mode. This setting is not proof that rules are running; live matches provide separate evidence below.", + "crowdsec.crs.control-busy": "Another activation is already running. Wait for it to finish.", + "crowdsec.crs.control-confirm": "Enable CRS observation? This installs the CRS rules and briefly restarts CrowdSec. Protected traffic and dashboard requests may be briefly denied while it restarts. CRS observations will not block or ban by themselves. Existing high-confidence blocking remains enabled. If activation fails, the helper attempts to restore the previous configuration.", + "crowdsec.crs.control-cooldown": "Please wait before retrying. Activation attempts are limited to once every five minutes.", + "crowdsec.crs.control-enable": "Enable CRS observation", + "crowdsec.crs.control-enabled": "The host selected observation mode. Live matches remain separate evidence that rules are running.", + "crowdsec.crs.control-enabled-label": "CRS observation configured", + "crowdsec.crs.control-enabling": "Enabling CRS observation…", + "crowdsec.crs.control-error": "The activation request failed. Refresh the status and try again.", + "crowdsec.crs.control-failed": "Activation did not finish. Review the host helper log before retrying; the helper attempts to restore changes on failure.", + "crowdsec.crs.control-install": "Install the current setup-script update once to make dashboard activation available.", + "crowdsec.crs.control-invalid": "Only CRS observation activation is supported.", + "crowdsec.crs.control-loading": "Checking whether dashboard activation is available…", + "crowdsec.crs.control-ready": "Observe additional attack patterns while keeping existing high-confidence blocking.", + "crowdsec.crs.control-reconnecting": "The last report said activation was running. CrowdSec is restarting or status is temporarily unreachable; waiting for a fresh result…", + "crowdsec.crs.control-rollback-failed": "Activation and recovery failed. Ask the host administrator to repair CrowdSec before continuing.", + "crowdsec.crs.control-running": "Installing and validating CRS. CrowdSec restarts briefly; existing protection settings are retained.", + "crowdsec.crs.control-unavailable": "The host helper is unavailable. Update the installer or check its CRS control service.", + "crowdsec.crs.control-unsupported": "Dashboard activation requires an installer-managed CrowdSec stack with AppSec enabled and the default policy. Custom policies need an operator review.", "crowdsec.crs.description": "CRS adds detection for common web attack patterns. The installer observation policy records matches without blocking or banning because of CRS alone. High-confidence AppSec rules keep their existing blocking behavior.", "crowdsec.crs.empty": "No CRS observations reported. Quiet counters do not verify rule loading or complete protection.", "crowdsec.crs.enable-help": "To enable observation on an installer-managed CrowdSec stack, run the setup script on the host with", diff --git a/setup-npmplus.sh b/setup-npmplus.sh index 7c291c6464..28c7eb5d99 100644 --- a/setup-npmplus.sh +++ b/setup-npmplus.sh @@ -11,7 +11,7 @@ set -euo pipefail # bump this on every meaningful change - the script compares it against the # copy on github at startup and tells the operator when theirs is stale -SCRIPT_VERSION="1.65" +SCRIPT_VERSION="1.66" DATA_DIR="/opt/npmplus" CROWDSEC_DIR="/opt/crowdsec" @@ -1895,6 +1895,7 @@ if [[ "${1:-}" == "--uninstall" ]]; then [[ "${answer:-}" == "uninstall" ]] || { echo "aborted - nothing removed" >&2; exit 1; } remove_strict_boot_protection remove_cloudflare_origin_lock + remove_crs_control if [[ -s "$COMPOSE_FILE" ]] && command -v docker >/dev/null && docker compose version >/dev/null 2>&1; then say "stopping and removing containers" @@ -2331,17 +2332,24 @@ crowdsec_crs_observation_configured() { } enable_crowdsec_crs_observation() { + local control="${1:-cli}" local policy="$CROWDSEC_DIR/conf/appsec-configs/npmplus-crs-observe.yaml" local acquisition="$CROWDSEC_DIR/conf/acquis.d/npmplus.yaml" local bouncer_conf="$DATA_DIR/crowdsec/crowdsec.conf" # Refuse to replace an operator-owned policy. Hub-managed files stay intact. - if [[ -e "$policy" ]] && ! grep -qx '# NPMPLUS_CRS_OBSERVE_VERSION=1' "$policy"; then + if [[ -e "$policy" || -L "$policy" ]] && ! grep -qx '# NPMPLUS_CRS_OBSERVE_VERSION=1' "$policy"; then echo "CRS policy is operator-owned; review $policy before enabling observation" >&2 return 1 fi # Install the rule package alone. The CRS collection also installs a scenario # that can ban repeated detections, which is not this observation policy. - docker exec crowdsec cscli appsec-rules install crowdsecurity/crs + if [[ "$control" == "control" ]]; then + # Bound the process inside the container too: timing out a Docker client + # alone can leave its exec process writing after rollback has started. + docker exec crowdsec timeout -s KILL 60 cscli appsec-rules install crowdsecurity/crs + else + docker exec crowdsec cscli appsec-rules install crowdsecurity/crs + fi mkdir -p "${policy%/*}" write_root_file "$policy" 644 <<'EOF' # NPMPLUS_CRS_OBSERVE_VERSION=1 @@ -2386,7 +2394,16 @@ finally: if os.path.exists(temporary): os.unlink(temporary) PYTHON - docker exec crowdsec crowdsec -t -c /etc/crowdsec/config.yaml + if [[ "$control" == "control" ]]; then + docker exec crowdsec timeout -s KILL 30 crowdsec -t -c /etc/crowdsec/config.yaml + else + docker exec crowdsec crowdsec -t -c /etc/crowdsec/config.yaml + fi + if [[ "$control" == "control" ]]; then + # The web helper never reads Compose or executes scripts from /data. + docker restart crowdsec + return + fi docker compose -f "$COMPOSE_FILE" restart crowdsec # A non-secret installer choice, deliberately separate from observed live hits. # The safe updater snapshots this file alongside the CrowdSec rules/database. @@ -2395,6 +2412,359 @@ PYTHON say "CRS observation configured; high-confidence in-band blocking remains enabled" } + +# A single-action Unix socket, deliberately outside the application's data mount. +# Only the root-installed helper can alter CrowdSec's private configuration. +remove_crs_control() { + systemctl disable --now npmplus-crs-control.socket npmplus-crs-control.service >/dev/null 2>&1 || true + rm -f /etc/systemd/system/npmplus-crs-control.socket /etc/systemd/system/npmplus-crs-control.service + rm -f /usr/local/lib/npmplus-crs-control.py /usr/local/lib/npmplus-crs-enable + rm -f /var/lib/npmplus/crs-control/state.json + rmdir /var/lib/npmplus/crs-control /run/npmplus-crs-control >/dev/null 2>&1 || true + systemctl daemon-reload >/dev/null 2>&1 || true +} + +ensure_crs_control_mount() { + install -d -m 0755 /run/npmplus-crs-control + python3 - "$COMPOSE_FILE" <<'PYTHON' +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +text = path.read_text() +mount = ' - "/run/npmplus-crs-control:/run/npmplus-crs-control:ro"' +if mount not in text.splitlines(): + lines = text.splitlines() + start = lines.index(' npmplus:') + end = next((i for i in range(start + 1, len(lines)) if lines[i].startswith(' ') and not lines[i].startswith(' ') and not lines[i].startswith(' #')), len(lines)) + position = next((i for i in range(start + 1, end) if lines[i] == ' volumes:'), None) + if position is None: + sys.exit('NPMplus volumes require review before installing CRS control') + lines.insert(position + 1, mount) + path.write_text('\n'.join(lines) + '\n') +PYTHON +} + +install_crs_control() { + grep -q 'container_name: crowdsec' "$COMPOSE_FILE" || return 0 + ensure_crs_control_mount + install -d -m 0755 /usr/local/lib + install -d -m 0700 /var/lib/npmplus/crs-control + # Capture the same reviewed policy function as the CLI, in a root-only path. + # Never execute the installer/Compose files mounted into the web container. + { + printf '%s\n' '#!/bin/bash' 'set -euo pipefail' 'CROWDSEC_DIR=/opt/crowdsec' 'DATA_DIR=/opt/npmplus' 'say() { :; }' + declare -f write_root_file enable_crowdsec_crs_observation + printf '%s\n' 'enable_crowdsec_crs_observation control' + } | write_root_file /usr/local/lib/npmplus-crs-enable 0700 + write_root_file /usr/local/lib/npmplus-crs-control.py 0700 <<'PYTHON' +"""Single-action host control. Requests never supply commands, paths, or policy.""" +import fcntl +import json +import os +from pathlib import Path +import re +import shutil +import signal +import socket +import stat +import struct +import subprocess +import tempfile +import threading +import time +import traceback + +CONF = Path('/opt/crowdsec/conf') +BOUNCER_DIR = Path('/opt/npmplus/crowdsec') +STATE = Path('/var/lib/npmplus/crs-control/state.json') +LOCK = Path('/run/lock/npmplus-maintenance.lock') +ACTION = '/usr/local/lib/npmplus-crs-enable' +DOCKER = '/usr/bin/docker' +ALLOWED_STATES = {'idle', 'running', 'enabled', 'failed', 'rollback-failed'} + + +def bouncer_fd(): + # /opt/npmplus is a bind mount; reject symlinks and foreign hardlinks. + parent = os.open(str(BOUNCER_DIR.parent), os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + directory = os.open(BOUNCER_DIR.name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent) + finally: + os.close(parent) + try: + fd = os.open('crowdsec.conf', os.O_RDWR | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory) + finally: + os.close(directory) + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_size > 65536: + os.close(fd) + raise ValueError('invalid bouncer file') + return fd + + +def read_bouncer(fd): + os.lseek(fd, 0, os.SEEK_SET) + return os.read(fd, 65537).decode('utf-8') + + +def same_bouncer(fd, expected): + current = bouncer_fd() + try: + left, right = os.fstat(fd), os.fstat(current) + return (left.st_dev, left.st_ino) == (right.st_dev, right.st_ino) and read_bouncer(current) == expected + finally: + os.close(current) + + +def managed(): + if CONF.is_symlink() or not CONF.is_dir(): + return False + if any(path.is_symlink() for path in [CONF / 'acquis.d', CONF / 'appsec-configs', CONF / 'acquis.d/npmplus.yaml', CONF / 'appsec-configs/npmplus-crs-observe.yaml']): + return False + text = (CONF / 'acquis.d/npmplus.yaml').read_text() + lists = list(re.finditer(r'(?m)^appsec_configs:\n((?:[ \t]+[^\n]*\n)+)', text)) + if len(lists) != 1: + return False + items = re.findall(r'^ - ([\w/-]+)\s*$', lists[0].group(1), re.M) + policy = CONF / 'appsec-configs/npmplus-crs-observe.yaml' + return (len(items) == len(lists[0].group(1).splitlines()) + and 'crowdsecurity/appsec-default' in items + and not set(items) - {'crowdsecurity/appsec-default', 'npmplus/crs-observe'} + and (not policy.exists() or '# NPMPLUS_CRS_OBSERVE_VERSION=1' in policy.read_text().splitlines())) + + +def configured(text): + return ('# NPMPLUS_CRS_MODE=observe' in text.splitlines() + and ' - npmplus/crs-observe' in (CONF / 'acquis.d/npmplus.yaml').read_text().splitlines() + and (CONF / 'appsec-configs/npmplus-crs-observe.yaml').is_file()) + + +def command(*args): + # No shell, caller-controlled environment, output, or Compose input. + subprocess.run(args, check=True, timeout=150, stdout=subprocess.DEVNULL, + env={'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'LANG': 'C.UTF-8'}) + + +def healthy(): + deadline = time.monotonic() + 120 + while time.monotonic() < deadline: + result = subprocess.run([DOCKER, 'inspect', '--format', '{{.State.Health.Status}}', 'crowdsec'], + check=True, timeout=10, capture_output=True, text=True) + if result.stdout.strip() == 'healthy': + return + time.sleep(2) + raise RuntimeError('CrowdSec health timeout') + + +def restore_tree(source, target): + # Preserve the config directory inode bound into the running container. + for item in target.iterdir(): + if item.is_dir() and not item.is_symlink(): + shutil.rmtree(item) + else: + item.unlink() + command('/bin/cp', '-a', str(source) + '/.', str(target)) + + +class Control: + def __init__(self): + self.guard = threading.Lock() + self.worker = None + self.state = 'idle' + self.last_attempt = 0 + try: + saved = json.loads(STATE.read_text()) + self.last_attempt = int(saved['last_attempt']) + self.state = saved['state'] if saved['state'] in ALLOWED_STATES else 'failed' + if self.state == 'running': + self.state = 'failed' + except (OSError, ValueError, KeyError, TypeError): + pass + + def save(self): + fd, temporary = tempfile.mkstemp(dir=STATE.parent) + try: + with os.fdopen(fd, 'w') as output: + json.dump({'state': self.state, 'last_attempt': self.last_attempt}, output) + os.replace(temporary, STATE) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + def status(self): + with self.guard: + eligible = False + enabled = False + try: + fd = bouncer_fd() + try: + text = read_bouncer(fd) + finally: + os.close(fd) + eligible = bool(re.search(r'^APPSEC_URL=\S+', text, re.M)) and managed() + enabled = eligible and configured(text) + except (OSError, ValueError): + pass + return {'version': 1, 'available': True, 'eligible': eligible, 'enabled': enabled, + 'state': self.state, 'retry_after': max(0, 300 - int(time.time() - self.last_attempt))} + + def enable(self): + status = self.status() + with self.guard: + if self.state == 'running': + return {'accepted': False, 'reason': 'busy'} + if status['enabled']: + return {'accepted': True, 'state': 'enabled'} + if not status['eligible']: + return {'accepted': False, 'reason': 'unsupported'} + if time.time() - self.last_attempt < 300: + return {'accepted': False, 'reason': 'cooldown'} + self.state = 'running' + self.last_attempt = int(time.time()) + self.save() + self.worker = threading.Thread(target=self.activate) + self.worker.start() + return {'accepted': True, 'state': 'running'} + + def activate(self): + state = 'failed' + fd = None + try: + with LOCK.open('a') as maintenance: + fcntl.flock(maintenance, fcntl.LOCK_EX | fcntl.LOCK_NB) + fd = bouncer_fd() + original = read_bouncer(fd) + healthy() + if not managed() or not re.search(r'^APPSEC_URL=\S+', original, re.M): + raise ValueError('configuration changed') + with tempfile.TemporaryDirectory(prefix='snapshot-', dir=STATE.parent) as snapshot: + backup = Path(snapshot) / 'conf' + command('/bin/cp', '-a', str(CONF), str(backup)) + marker_written = False + try: + command('/bin/bash', ACTION) + healthy() + updated = re.sub(r'^# NPMPLUS_CRS_MODE=.*\n?', '', original, flags=re.M) + updated = updated.rstrip('\n') + '\n# NPMPLUS_CRS_MODE=observe\n' + if not same_bouncer(fd, original): + raise ValueError('bouncer changed during activation') + marker_written = True + os.lseek(fd, 0, os.SEEK_SET) + os.write(fd, updated.encode('utf-8')) + os.ftruncate(fd, len(updated.encode('utf-8'))) + os.fsync(fd) + if not same_bouncer(fd, updated): + raise ValueError('bouncer replaced during activation') + state = 'enabled' + except Exception: + traceback.print_exc() + try: + restore_tree(backup, CONF) + if marker_written and same_bouncer(fd, updated): + os.lseek(fd, 0, os.SEEK_SET) + os.write(fd, original.encode('utf-8')) + os.ftruncate(fd, len(original.encode('utf-8'))) + os.fsync(fd) + command(DOCKER, 'restart', 'crowdsec') + healthy() + except Exception: + state = 'rollback-failed' + traceback.print_exc() + except Exception: + traceback.print_exc() + finally: + if fd is not None: + os.close(fd) + with self.guard: + self.state = state + self.save() + + +def respond(connection, control): + connection.settimeout(2) + uid = struct.unpack('3i', connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))[1] + if uid not in {0, 1000}: + return + request = b'' + while b'\n' not in request and len(request) < 16: + part = connection.recv(16 - len(request)) + if not part: + return + request += part + if request == b'STATUS\n': + result = control.status() + elif request == b'ENABLE\n': + result = control.enable() + else: + result = {'accepted': False, 'reason': 'invalid'} + connection.sendall(json.dumps(result).encode('utf-8') + b'\n') + + +def serve(): + if int(os.environ.get('LISTEN_PID', '0')) != os.getpid() or os.environ.get('LISTEN_FDS') != '1': + raise RuntimeError('systemd socket activation required') + listener = socket.socket(fileno=3) + listener.settimeout(1) + stopped = threading.Event() + signal.signal(signal.SIGTERM, lambda *_: stopped.set()) + signal.signal(signal.SIGINT, lambda *_: stopped.set()) + control = Control() + while not stopped.is_set(): + try: + connection, _ = listener.accept() + except socket.timeout: + continue + with connection: + try: + respond(connection, control) + except (OSError, ValueError): + pass + if control.worker: + control.worker.join() + + +if __name__ == '__main__': + serve() + +PYTHON + write_root_file /etc/systemd/system/npmplus-crs-control.socket 0644 <<'EOF' +[Unit] +Description=NPMplus CRS observation control socket +Before=docker.service + +[Socket] +ListenStream=/run/npmplus-crs-control/control.sock +SocketMode=0660 +SocketGroup=1000 +DirectoryMode=0755 +RemoveOnStop=true + +[Install] +WantedBy=sockets.target +EOF + write_root_file /etc/systemd/system/npmplus-crs-control.service 0644 <<'EOF' +[Unit] +Description=NPMplus single-action CRS observation helper +Requires=npmplus-crs-control.socket +After=docker.service + +[Service] +ExecStart=/usr/bin/python3 /usr/local/lib/npmplus-crs-control.py +Environment=PATH=/usr/sbin:/usr/bin:/sbin:/bin +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectSystem=strict +ReadWritePaths=/opt/crowdsec /opt/npmplus/crowdsec /var/lib/npmplus/crs-control /run/lock +RestrictAddressFamilies=AF_UNIX +TimeoutStopSec=600 +EOF + systemctl daemon-reload + systemctl enable --now npmplus-crs-control.socket + # Socket activation starts a fresh process after code replacement. + systemctl stop npmplus-crs-control.service +} + # The official Anubis image is non-root. A root-owned bind mount lets it read # the policy but makes the bbolt database and honeypot log unwritable, which # sends the container into a restart loop. Discover the numeric image user so @@ -2579,6 +2949,7 @@ seed_crowdsec_datafiles() { # seed_crowdsec_datafiles # the generated host tooling (safe-update, backup, key heal + their crons): # one definition, installed by both the interactive setup and --update install_host_tooling() { + install_crs_control # Read-only packet observations, delivered through the existing data mount. write_root_file /usr/local/bin/npmplus-collect-enforcement 755 <<'EOF' #!/bin/bash @@ -2696,8 +3067,14 @@ revert() { /var/lib/npmplus/cloudflare-origin-lock \ /var/lib/npmplus/cloudflare-ips-v4 \ /var/lib/npmplus/cloudflare-ips-v6 + systemctl disable --now npmplus-crs-control.socket npmplus-crs-control.service >/dev/null 2>&1 || true + rm -f /etc/systemd/system/npmplus-crs-control.socket /etc/systemd/system/npmplus-crs-control.service \ + /usr/local/lib/npmplus-crs-control.py /usr/local/lib/npmplus-crs-enable [[ ! -s "$BACKUP/host-security.tar.gz" ]] || tar -xzf "$BACKUP/host-security.tar.gz" -C / systemctl daemon-reload >/dev/null 2>&1 || true + if [[ -f /etc/systemd/system/npmplus-crs-control.socket ]]; then + systemctl enable --now npmplus-crs-control.socket >/dev/null || true + fi if [[ -f /var/lib/npmplus/cloudflare-origin-lock ]]; then systemctl enable --now npmplus-cloudflare-origin-lock.service >/dev/null || true fi @@ -2743,6 +3120,10 @@ for f in npmplus-safe-update npmplus-backup npmplus-crowdsec-heal npmplus-collec cp -a "/etc/cron.d/$f" "$BACKUP/cron-$f" 2>/dev/null || true done host_security_paths=( + etc/systemd/system/npmplus-crs-control.socket + etc/systemd/system/npmplus-crs-control.service + usr/local/lib/npmplus-crs-control.py + usr/local/lib/npmplus-crs-enable etc/systemd/system/npmplus-public.service etc/systemd/system/npmplus-cloudflare-origin-lock.service etc/systemd/system/npmplus-boot-guard.service @@ -3966,6 +4347,12 @@ $ENV_TZ EOF fi +CRS_CONTROL_MOUNT="" +if [[ "$USE_CROWDSEC" == "y" ]]; then + install -d -m 0755 /run/npmplus-crs-control + CRS_CONTROL_MOUNT=' - "/run/npmplus-crs-control:/run/npmplus-crs-control:ro"' +fi + cat >"$COMPOSE_FILE" < Date: Fri, 2 Oct 2026 21:31:12 -0600 Subject: [PATCH 2/4] load crs cleanup before uninstall dispatch --- setup-npmplus.sh | 18 +++++++++--------- tests/crs-control.py | 27 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/setup-npmplus.sh b/setup-npmplus.sh index 28c7eb5d99..0509f10dbf 100644 --- a/setup-npmplus.sh +++ b/setup-npmplus.sh @@ -117,6 +117,15 @@ write_root_file() { # write_root_file target mode < content mv -f -- "$tmp" "$target" } +remove_crs_control() { + systemctl disable --now npmplus-crs-control.socket npmplus-crs-control.service >/dev/null 2>&1 || true + rm -f /etc/systemd/system/npmplus-crs-control.socket /etc/systemd/system/npmplus-crs-control.service + rm -f /usr/local/lib/npmplus-crs-control.py /usr/local/lib/npmplus-crs-enable + rm -f /var/lib/npmplus/crs-control/state.json + rmdir /var/lib/npmplus/crs-control /run/npmplus-crs-control >/dev/null 2>&1 || true + systemctl daemon-reload >/dev/null 2>&1 || true +} + remove_admin_lan_proxy() { command -v systemctl >/dev/null || return 0 systemctl disable --now npmplus-admin-lan.socket npmplus-admin-lan.service >/dev/null 2>&1 || true @@ -2415,15 +2424,6 @@ PYTHON # A single-action Unix socket, deliberately outside the application's data mount. # Only the root-installed helper can alter CrowdSec's private configuration. -remove_crs_control() { - systemctl disable --now npmplus-crs-control.socket npmplus-crs-control.service >/dev/null 2>&1 || true - rm -f /etc/systemd/system/npmplus-crs-control.socket /etc/systemd/system/npmplus-crs-control.service - rm -f /usr/local/lib/npmplus-crs-control.py /usr/local/lib/npmplus-crs-enable - rm -f /var/lib/npmplus/crs-control/state.json - rmdir /var/lib/npmplus/crs-control /run/npmplus-crs-control >/dev/null 2>&1 || true - systemctl daemon-reload >/dev/null 2>&1 || true -} - ensure_crs_control_mount() { install -d -m 0755 /run/npmplus-crs-control python3 - "$COMPOSE_FILE" <<'PYTHON' diff --git a/tests/crs-control.py b/tests/crs-control.py index 471d7f9123..be2dec124d 100644 --- a/tests/crs-control.py +++ b/tests/crs-control.py @@ -205,6 +205,7 @@ def test_helper_generation_mount_rollback_and_uninstall_are_pinned(self): self.assertIn('/run/npmplus-crs-control:/run/npmplus-crs-control:ro', SOURCE) self.assertIn('etc/systemd/system/npmplus-crs-control.socket', SOURCE[SOURCE.index('host_security_paths=('):]) self.assertIn('\tremove_crs_control\n', SOURCE) + self.assertLess(SOURCE.index('remove_crs_control() {'), SOURCE.index('\tremove_crs_control\n')) function = re.search(r'^enable_crowdsec_crs_observation\(\).*?^}\n', SOURCE, re.M | re.S).group() branch = function[function.index('if [[ "$control" == "control"') : function.index('# A non-secret installer choice')] self.assertIn('docker restart crowdsec\n\t\treturn', branch) @@ -213,6 +214,32 @@ def test_helper_generation_mount_rollback_and_uninstall_are_pinned(self): self.assertIn('timeout -s KILL 60 cscli appsec-rules install crowdsecurity/crs', branch) self.assertIn('timeout -s KILL 30 crowdsec -t', branch) + def test_uninstall_dispatch_has_cleanup_loaded_and_removes_only_owned_files(self): + prefix = SOURCE.split('if [[ "${1:-}" == "--uninstall" ]]; then', 1)[0] + # Evaluate only definitions and argument parsing, with no uninstall body. + prefix = re.sub(r'^\[\[ \$EUID.*$', '', prefix, flags=re.M) + result = subprocess.run(['bash', '-c', prefix + '\ndeclare -F remove_crs_control\n', 'fixture', '--uninstall', '--no-backup'], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + cleanup = re.search(r'^remove_crs_control\(\).*?^}\n', SOURCE, re.M | re.S).group() + paths = ['/etc/systemd/system', '/usr/local/lib', '/var/lib/npmplus/crs-control', '/run/npmplus-crs-control'] + for index, path in enumerate(paths): + directory = self.root / ('cleanup-' + str(index)) + directory.mkdir() + cleanup = cleanup.replace(path, str(directory)) + units = self.root / 'cleanup-0' + tools = self.root / 'cleanup-1' + for path in [units / 'npmplus-crs-control.socket', units / 'npmplus-crs-control.service', tools / 'npmplus-crs-control.py', tools / 'npmplus-crs-enable', self.root / 'cleanup-2/state.json']: + path.write_text('owned fixture') + unrelated = units / 'operator-owned.service' + unrelated.write_text('keep') + result = subprocess.run(['bash', '-c', 'set -euo pipefail\nsystemctl() { :; }\n' + cleanup + '\nremove_crs_control\n'], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(unrelated.read_text(), 'keep') + self.assertEqual(sorted(path.name for path in units.iterdir()), ['operator-owned.service']) + self.assertEqual(list(tools.iterdir()), []) + self.assertFalse((self.root / 'cleanup-2').exists()) + self.assertFalse((self.root / 'cleanup-3').exists()) + if __name__ == '__main__': unittest.main(verbosity=2) From 5f38e53a4f2d996edadb7839b4678f171d305894 Mon Sep 17 00:00:00 2001 From: White-Lotus <95460219+mangyan1@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:31:59 -0600 Subject: [PATCH 3/4] pin complete cli definitions before uninstall --- tests/crs-control.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/crs-control.py b/tests/crs-control.py index be2dec124d..a656f25611 100644 --- a/tests/crs-control.py +++ b/tests/crs-control.py @@ -215,7 +215,7 @@ def test_helper_generation_mount_rollback_and_uninstall_are_pinned(self): self.assertIn('timeout -s KILL 30 crowdsec -t', branch) def test_uninstall_dispatch_has_cleanup_loaded_and_removes_only_owned_files(self): - prefix = SOURCE.split('if [[ "${1:-}" == "--uninstall" ]]; then', 1)[0] + prefix = SOURCE.split('# --uninstall is deliberately handled before dependency installation', 1)[0] # Evaluate only definitions and argument parsing, with no uninstall body. prefix = re.sub(r'^\[\[ \$EUID.*$', '', prefix, flags=re.M) result = subprocess.run(['bash', '-c', prefix + '\ndeclare -F remove_crs_control\n', 'fixture', '--uninstall', '--no-backup'], capture_output=True, text=True) From c0a0634978d9102f35bb71d6a23fc6c0f6f3408d Mon Sep 17 00:00:00 2001 From: White-Lotus <95460219+mangyan1@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:06:15 -0600 Subject: [PATCH 4/4] verify private appsec listener during activation --- setup-npmplus.sh | 7 ++++++- tests/crs-control.py | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/setup-npmplus.sh b/setup-npmplus.sh index 0509f10dbf..2a3bc29acf 100644 --- a/setup-npmplus.sh +++ b/setup-npmplus.sh @@ -2551,7 +2551,12 @@ def healthy(): result = subprocess.run([DOCKER, 'inspect', '--format', '{{.State.Health.Status}}', 'crowdsec'], check=True, timeout=10, capture_output=True, text=True) if result.stdout.strip() == 'healthy': - return + listener = subprocess.run([DOCKER, 'exec', 'crowdsec', 'awk', + '$2 ~ /:1CFE$/ && $4 == "0A" { found=1 } END { exit !found }', + '/proc/net/tcp', '/proc/net/tcp6'], timeout=10, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if listener.returncode == 0: + return time.sleep(2) raise RuntimeError('CrowdSec health timeout') diff --git a/tests/crs-control.py b/tests/crs-control.py index a656f25611..6f431cc1db 100644 --- a/tests/crs-control.py +++ b/tests/crs-control.py @@ -31,6 +31,7 @@ def setUp(self): self.acquisition.write_text('appsec_configs:\n - crowdsecurity/appsec-default\nsource: appsec\n') self.bouncer = m.BOUNCER_DIR / 'crowdsec.conf' self.bouncer.write_text('APPSEC_URL=http://private:7422\nAPI_KEY=fixture-key\n') + self.health_check = m.healthy m.healthy = lambda: None self.calls = [] @@ -115,6 +116,25 @@ def test_symlinked_operator_configuration_is_not_modified(self): self.assertEqual(control.enable(), {'accepted': False, 'reason': 'unsupported'}) self.assertEqual(self.calls, []) + def test_lapi_health_alone_cannot_report_appsec_ready(self): + for listening in [False, True]: + calls = [] + def run(args, **_): + calls.append(args) + if args[1] == 'inspect': + return types.SimpleNamespace(stdout='healthy\n', returncode=0) + return types.SimpleNamespace(returncode=0 if listening else 1) + self.module.subprocess = types.SimpleNamespace(run=run, DEVNULL=subprocess.DEVNULL) + ticks = iter([0, 1, 121]) + self.module.time = types.SimpleNamespace(monotonic=lambda: next(ticks), sleep=lambda _: None) + if listening: + self.health_check() + else: + with self.assertRaises(RuntimeError): + self.health_check() + self.assertEqual(calls[1][1:4], ['exec', 'crowdsec', 'awk']) + self.assertIn(':1CFE$', calls[1][4]) + def test_other_maintenance_prevents_any_configuration_change(self): with self.module.LOCK.open('a') as locked: fcntl.flock(locked, fcntl.LOCK_EX | fcntl.LOCK_NB)