From bf372d7db2933db25f7c0c3cc7b2c063b76e44c9 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Fri, 9 Oct 2026 20:51:05 +0000 Subject: [PATCH 01/22] docs: plan the next CrowdSec follow-up batch --- ...owdsec_preset_apply_hardening_1525_spec.md | 286 ++++++++++++ docs/plans/current_spec.md | 439 +++++++----------- 2 files changed, 457 insertions(+), 268 deletions(-) create mode 100644 docs/plans/archive/crowdsec_preset_apply_hardening_1525_spec.md diff --git a/docs/plans/archive/crowdsec_preset_apply_hardening_1525_spec.md b/docs/plans/archive/crowdsec_preset_apply_hardening_1525_spec.md new file mode 100644 index 000000000..5be618358 --- /dev/null +++ b/docs/plans/archive/crowdsec_preset_apply_hardening_1525_spec.md @@ -0,0 +1,286 @@ +# Plan: CrowdSec preset-apply and data/hub persistence hardening (follow-ups of PR #1513) + +Type: ONE branch, ONE PR into `development`: `fix/crowdsec-preset-apply-hardening`. Medium/large fix (touches Go backend, React frontend, Dockerfile, entrypoint, E2E, docs), so per CLAUDE.md it gets a dedicated branch and PR, never a direct commit to `development`. +Closes: #1514, #1515, #1516, #1517, #1518, #1523, #1524. +Status: rev 2 (addresses Supervisor CHANGES REQUESTED: 7 blocking items), open questions Q1-Q5 resolved by the user. +Predecessor: the #1513 plan (curated preset apply) that previously lived in this file; it merged as `3a76804b`. + +## 1. Introduction + +PR #1513 made curated CrowdSec presets server-authoritative. While verifying it in a rebuilt container, seven follow-ups were filed. They share one theme: **the CrowdSec preset/hub apply path and the persistent CrowdSec directory (`/app/data/crowdsec`) are not safe or durable**. Backups are taken by renaming the live directory, a generic file-write endpoint also renames the live directory, hub data files live on an ephemeral path, the bundled binary does not know its own version, and the tests that should guard this are tolerant skips. + +Goal: after this PR, applying/editing CrowdSec config can never destroy the live config, hub data files survive container recreation, `cscli version` is truthful, backups are bounded, and E2E/unit tests fail when any of this regresses. + +## 2. Research Findings (root cause per issue, CLAUDE.md "Context First") + +Key facts established from code (all paths verified in this repo): + +- `crowdsecDataDir = cfg.Security.CrowdSecConfigDir` (`backend/internal/api/routes/routes.go:1030`), set to `/app/data/crowdsec` by `Dockerfile:1142` (`CHARON_CROWDSEC_CONFIG_DIR`). The same directory is `CS_PERSIST_DIR` in `.docker/docker-entrypoint.sh`. It therefore contains the **whole persistent CrowdSec tree**: `config/` (the target of the runtime symlink `/etc/crowdsec -> /app/data/crowdsec/config`), `data/` (LAPI db), `hub_cache/`, `bouncer_key`. +- That same path is `HubService.DataDir` and `CrowdsecHandler.DataDir` (`crowdsec_handler.go:374-397`). Every "backup the DataDir" in the code is a backup directory next to it, `/app/data/crowdsec.backup.`. + +### 2.1 #1514 applyPresetLocally writes unvalidated client content (root cause is wider than the issue says) + +- Entry: `frontend/src/pages/CrowdSecConfig.tsx:429` `applyPresetLocally`; reached only when apply returns HTTP 501 for a non-curated (hub) preset (line ~508). It writes `presetPreview` (text from the pull preview, held in client state) to `selectedPath ?? files[0]` through `writeCrowdsecFile` (`frontend/src/api/crowdsec.ts`). +- Confirmed: the backend **never returns 501** for apply any more (grep of `crowdsec_handler.go` shows no `StatusNotImplemented`). The fallback is dead code in practice and can only be triggered by a proxy/misconfig, which makes removing it risk-free. +- Transformation/persistence: `POST /admin/crowdsec/file` -> `CrowdsecHandler.WriteFile` (`crowdsec_handler.go:950`, registered under `managementAdmin`, so admin-only). Verified defects in the handler, independent of the frontend: + 1. **Destructive backup.** It `os.Rename(h.DataDir, backupDir)` (moves the *entire* persistent CrowdSec tree away, config symlink target included), then `MkdirAll(filepath.Dir(p))` and writes just the one file. Everything else (config.yaml, hub, bouncer key, data) is left only in the backup dir. The live tree becomes a single file. There is no restore. This is the same defect class as #1515 and is a medium-severity data-loss bug, not just "unvalidated content". + 2. **The file endpoints get a validation pass.** The fix is described at policy level in 3.2. +- Exit: `{status:"written", backup}`; the UI toasts "File saved" / "Preset applied locally". +- Severity/impact: low-medium, authenticated admin only (not an unauthenticated vulnerability). See section 4. + +### 2.2 #1515 HubService.Apply renames the whole data dir (CONFIRMED in code; end-to-end not executed) + +- Entry: `ApplyPreset` -> `Hub.Apply` for non-curated presets (`hub_sync.go:602`). +- Transformation: `backupExisting` first tries `os.Rename(DataDir, .backup.)`; only if rename fails (EBUSY on a mount point, cross-device) does it fall back to `copyDir`. Then `runCSCLI` (`cscli hub update` + `cscli hub install `). +- Why it is wrong, confirmed by reading the code and by the behavior seen in a throwaway container (below): after the rename `/etc/crowdsec -> /app/data/crowdsec/config` dangles, so `cscli` cannot read `config.yaml` (observed error: `open /etc/crowdsec/config.yaml: no such file or directory`). The cscli path therefore fails, the code falls to `extractTarGz` into a freshly created `DataDir`, and on success the live dir holds only the extracted archive while config, hub, `bouncer_key`, LAPI db and `hub_cache` sit in the backup. CrowdSec is running while its db directory is moved. Rollback (`rollback`) does `RemoveAll(DataDir)` then renames the backup back, so it is also only valid for that directory and cannot undo anything `cscli` wrote outside it. +- Not confirmed: that a *real* hub-preset apply on a running container hits exactly this sequence (needs a hub preset pull + a running agent). The verification step is part of the gate in Commit 3. Whether the extract fallback ever "succeeds" with a usable tree is irrelevant; the rename itself is the defect. +- Fix direction (matches the issue): reuse the copy-based, symlink-preserving, db-excluding `backupCopy`/`restoreCopy` added for `ApplyCurated`. + +### 2.3 #1516 backup directories accumulate (CONFIRMED) + +Creators of `.backup.*`: `HubService.Apply` (rename, second-resolution timestamp), `ApplyCurated` (copy, microsecond timestamp), `CrowdsecHandler.ImportConfig` (`crowdsec_handler.go:696`, rename), `WriteFile` (rename). Nothing ever deletes any of them. grep for prune/retention in `backend/internal/crowdsec` returns nothing. Because the curated backup is a full copy of the persistent tree (minus the db) and the legacy ones are the whole tree, growth is unbounded on a volume the user sizes for a home server. + +### 2.4 #1517 handler file size (CONFIRMED) + +`crowdsec_handler.go` is 2,906 lines. Preset handlers live at lines ~990-1325 (`ListPresets`, `PullPreset`, `ApplyPreset`, `applyCuratedPreset`, `curatedPresetPreview`, `recordPresetEvent`, `applyFailureBody`, `respondApplySuccess`, `GetCachedPreset` at ~1426). Pure move, same package. + +### 2.5 #1518 tolerant Playwright tests (CONFIRMED) + +`tests/security/crowdsec-config.spec.ts` "Preset Management" block (lines ~73-145) and "Configuration Files" block are `isVisible().catch(() => false)` guarded and annotate "feature may not be implemented"; they cannot fail on a regression. `tests/security/crowdsec-preset-apply.spec.ts` (from #1513) is the model: route-stubs `**/api/v1/admin/crowdsec/presets/apply` and the file-write route and asserts toasts and write counts. Missing deterministic coverage: hub (non-curated) preset apply success/failure surfaced, no client-side write on any apply failure, file editor save/failure, file-list error cases. + +### 2.6 #1523 hub data files missing after container recreation (ROOT CAUSE CONFIRMED by experiment) + +- Config: CrowdSec's default `config_paths.data_dir` is `/var/lib/crowdsec/data/` and `hub_dir` is `/etc/crowdsec/hub/` (upstream `config/config.yaml`). The entrypoint (`.docker/docker-entrypoint.sh:310-319`) redirects **only** `db_path` to the volume. `Dockerfile:1089` creates `/var/lib/crowdsec/data` inside the image, so on a recreated container it is an empty directory (confirmed: the issue shows only `trace/`). `/etc/crowdsec` is a symlink into the volume so the **hub items persist but their data files do not**. +- `configs/crowdsec/install_hub_items.sh` skips items for which `cscli inspect ` exits 0 ("already installed, skipping"). +- Experiment (throwaway `charon:local` container run with `--entrypoint sh`, no volumes, no live host touched): + - Install `crowdsecurity/http-backdoors-attempts` -> `backdoors.txt` appears in `data_dir`. + - Wipe `data_dir` (simulating recreate), keep the hub: `cscli scenarios install ` -> "Nothing to install or remove", data dir stays empty. `cscli scenarios install --force ` -> same, still empty. **`cscli hub upgrade` repopulates `backdoors.txt`, and so does `cscli hub upgrade --force`.** The issue's suggestion "`install --force` or `hub upgrade`" is therefore half right: only `hub upgrade` works. +- **Additional defect found (CONFIRMED, not in the issue):** on a hub with an index but nothing installed, `cscli scenarios inspect crowdsecurity/http-backdoors-attempts` exits **0** and prints `installed:false` metadata. `install_if_missing` uses the exit code, so on a fresh volume it may print "already installed, skipping" and install nothing. (In the experiment inspect exited 1 when the item was installed but its data file was missing, so the exit code is not a reliable installed/absent signal in either direction.) #1513's `ApplyCurated` already uses the correct signal: `inspect -o json` -> top-level `installed`. +- Not confirmed: whether the missing-install path is masked in real startups by something else (e.g. the collections installing their children). Needs the recreate test in Commit 6 on a clean volume. + +### 2.7 #1524 empty version (ROOT CAUSE CONFIRMED; the fix has a gotcha) + +- Confirmed: the injected symbol is wrong. In CrowdSec v1.8.1, `pkg/cwversion/version.go` declares only `Codename` and `Libre2` (and no `Version`); the version comes from `github.com/crowdsecurity/go-cs-lib/version` (`Version`, `BuildDate`, `Tag`, `System`), pinned at `go-cs-lib v0.0.25` by CrowdSec v1.8.1's `go.mod`. Upstream's own Makefile uses `-X 'github.com/crowdsecurity/go-cs-lib/version.Version=$(BUILD_VERSION)'` (plus `BuildDate`, `Tag`, and `cwversion.Codename`). `-X` on a nonexistent symbol is silently ignored, so both `Dockerfile:915` and `:923` are no-ops. Observed in the built image: `cscli version` prints empty `version:`, `Codename:`, `BuildDate:`, and `cscli` logs `Crowdsec version is not set, using hub branch 'master'`. +- The existing app-stage sanity check (`Dockerfile` ~L1054) says "CrowdSec 1.8.x prints an empty `version:` field here regardless of the -X ldflag". That comment is wrong and hides the bug; it must be corrected. +- **Gotcha, verified in `cmd/crowdsec-cli/core/require/branch.go` (v1.8.1):** with a real version cscli calls `https://version.crowdsec.net/latest` on every hub command and returns a hard error if it cannot reach it; if the running version equals latest it uses `master` (identical to today), if it is older than latest it uses the branch named after the version. `https://hub-cdn.crowdsec.net/v1.8.1/.index.json` returns **404** today (v1.8.0, v1.7.8 and master return 200). So a correct version could (a) make `cscli hub *` fail offline/air-gapped and (b) break hub updates once 1.8.1 is no longer the latest and the CDN has no `v1.8.1` branch. The issue's "hub items may be newer than the engine supports" claim is also overstated: while 1.8.1 is the latest release the behavior is already `master`. The fix therefore needs an explicit hub-branch decision (see D1) rather than a bare `-X` correction. +- The toolchain delivery path: the default Dockerfile build consumes the prebuilt `ghcr.io/wikid82/charon-toolchain` image (content-hash tag `CHARON_TOOLCHAIN_TAG`, `Dockerfile:22`). Editing the `crowdsec-inline` stage changes the key, `.github/workflows/toolchain-image.yml` builds the new image and its bot commits the new TAG/DIGEST onto the PR head branch (workflow lines ~365-420) so `verify-toolchain-pin` goes green. Until that happens the app image still contains the old binary, so the app-side assertion cannot be added in the same commit as the ldflags change. + +### 2.8 Reproduction/evidence commands run for this plan (all foreground, repo-local or throwaway container) + +`gh issue view` for each issue, `gh pr view 1513`; code reads listed above; `curl` of upstream v1.8.1 source files and `hub-cdn.crowdsec.net//.index.json`; shallow clone of upstream v1.8.1 into the session scratchpad; throwaway `docker run --rm --entrypoint sh charon:local` experiments (data-dir wipe, inspect exit codes, `hub upgrade`). Nothing on live hosts or volumes was touched. + +## 3. Technical Specifications + +### 3.1 Shared backup helper, Apply flow, ImportConfig (#1515, #1516) + +New file `backend/internal/crowdsec/backup.go` (move `backupCopy`, `restoreCopy`, `isLiveDBFile`, `emptyDirExcept` out of `curated_apply.go`; the move itself changes no behavior): + +- **Two namespaces, two caps, so they cannot evict each other (blocking item 3).** + - Full-tree snapshots: backup dirs next to `DataDir`: `.backup.`, `const DefaultBackupRetention = 5`. + - Single-file edit backups (from `WriteFile`, section 3.2): backup dirs next to `DataDir`: `.filebackup./`, `const DefaultFileBackupRetention = 10`. The `.backup.*` glob never matches `.filebackup.*`. +- **Snapshot exclusions (blocking item 4).** `snapshot` copies `DataDir` (symlinks preserved) but **excludes**: the live `crowdsec.db`, `-wal`, `-shm` (anywhere), and the top-level `data/` directory (LAPI db plus hub data files and the GeoLite2 mmdb, which after #1523 live there; hub data files are recoverable with the gated `cscli hub upgrade`, the db is owned by the running engine) and the top-level `hub_cache/` (regenerable download cache). `restore` empties `DataDir` except those same excluded entries and copies the snapshot back, so it never touches `data/` or `hub_cache/`. Only the *top-level* `data/` and `hub_cache/` are excluded; a nested directory with the same name inside `config/` is still backed up. Result: a snapshot is essentially `config/` plus `bouncer_key` (small), x5 retained. +- `func (s *HubService) snapshot() (path string, err error)`: microsecond timestamp name, created `0o700` with exclusive `os.Mkdir`. +- `func (s *HubService) restore(path string) error`. +- `func PruneBackups(dataDir, kind string, keep int) (removed []string, err error)` where `kind` is `backup` or `filebackup`. Lists siblings matching `..*`, `Lstat`-checks each is a real directory (never follows or deletes symlinks or plain files), **parses the timestamp from the directory name** (both the legacy `20060102-150405` and the microsecond `20060102-150405.000000` layouts; names that do not parse are ignored, never deleted), sorts newest first, deletes everything past `keep`. `keep < 1` is clamped to 1 so the newest is always kept. Never touches `dataDir`. Single-removal errors are logged and aggregated, never failing the caller. +- **Prune runs after every attempt, successful or failed**, always preserving the newest `keep` snapshots. A failed apply's own snapshot is the newest, so it is always retained for manual recovery. + +**`HubService.Apply` new control flow (blocking item 2).** Hold `HubService.mu` and the handler-level lock (section 6). +1. Validate slug; load cache meta (non-fatal). +2. `snapshot()`; any error -> return failure, nothing changed. `BackupPath` is set only after success. The old "read the archive into memory before the backup because the cache lives in `DataDir`" workaround is **removed**: nothing is moved any more, and `hub_cache/` is excluded from snapshots and from restore, so the archive stays valid for the whole call. +3. If `cscli` is available: `cscli hub update` (non-fatal), `cscli hub install `. Success -> status `applied`, `UsedCSCLI=true`, reload, prune, return. +4. **A cscli failure still falls back to the cached-archive extract** (this keeps the offline/hub-unreachable behavior that exists today). Before extracting, `restore(snapshot)` so the extract starts from a clean pre-apply tree and any partial files `cscli` wrote are removed (this closes the partial-overlay risk of overlaying an archive on a half-installed state). +5. Extract `extractTarGz` onto `DataDir` (cache refresh from the hub if the cache is missing, as today). **Residual risk, documented:** extraction overlays archive files on top of the live tree rather than replacing it, so files in the archive replace same-named live files; mitigation is the snapshot. Any extract/refresh failure -> `restore(snapshot)` and return the error with `BackupPath`. +6. Success -> status `applied`, `UsedCSCLI=false`, reload, prune. +`backupExisting`, `rollback`, `emptyDir`, and `copyDir` (if now unused) are deleted. + +**`CrowdsecHandler.ImportConfig` (blocking item 6): converted to snapshot/restore semantics, never renaming `DataDir`.** Flow: handler lock -> `snapshot()` -> empty `DataDir` except the snapshot-excluded entries (so the live db, `data/`, `hub_cache/` survive the import) -> extract the uploaded archive, **skipping any archive entry that is a live db file (`isLiveDBFile`) or lies under the top-level `data/` or `hub_cache/`** so an upload can never overwrite engine-owned state -> on any failure `restore(snapshot)` and return the error -> prune. Net behavior difference: the live db and `data/` are no longer moved or lost by an import; everything else is replaced exactly as before. Existing `ImportConfig` tests are updated, plus the new tests listed in Commit 3. + +### 3.2 File endpoints hardening (#1514) + +Details of the validation defects and their fixes are intentionally not written here (see section 4). Public-safe design: + +- New file `backend/internal/api/handlers/crowdsec_files.go` with a single shared path-resolution helper used by `ReadFile`, `WriteFile` and `ListFiles`, so all three apply one policy ("harden file-endpoint path validation"). +- Write policy: bounded body size (1 MiB); allowed file types (Q3); YAML files must parse; protected files and trees (secrets, databases, caches, engine data) are never writable; `.yaml/.yml` parsing uses `gopkg.in/yaml.v3` if already a dependency (verify). +- **Symlink policy (blocking item 7):** the editor never writes through or replaces a symlink (HTTP 400), so symlinked hub items are read-only from the editor. Reads stay confined to `DataDir`, and symlinked hub items that resolve inside it stay readable. Test in Commit 4. +- **Two predicates, not one.** The viewer today lists and reads any file under `DataDir` (hub items, `config.yaml`, etc.), and filtering the list by the write predicate would hide files users can currently read. So: `isReadable(rel)` governs `ListFiles` and `ReadFile` (secrets and databases such as `bouncer_key`, `*.db*`, and the `hub_cache/` and `data/` trees are neither listed nor readable; everything else stays visible, including symlinked hub items), and the stricter `isWritable(rel)` governs `WriteFile`. A file that is readable but not writable (symlinked hub items, non-allowlisted types) stays viewable and its save returns the server's 400 message, which the existing `writeMutation` error path already shows; no frontend contract change. `ExportConfig` is unaffected. Commit 4 tests: `ReadFile` refuses `bouncer_key` and db files, reads a symlinked hub item that resolves inside `DataDir`, and refuses a symlink resolving outside it; `ListFiles` omits them but still lists a symlinked hub item and `config.yaml`; every file `WriteFile` accepts is also listed. +- `WriteFile` algorithm: lock -> validate -> if the target exists, copy just that file to `.filebackup./` -> atomic write (temp file in the same directory, `fsync`, `rename`, preserve mode, default `0o600`) -> `PruneBackups(kind=filebackup, 10)` -> 200 `{status:"written", backup}`. `DataDir` is never renamed. +- Error contract (JSON `{"error": ...}`): 400 invalid request / path / type / YAML / symlink, 413 too large, 500 write failure. +- No new endpoints, no schema changes, no frontend contract changes. + +### 3.3 Frontend (#1514) + +`frontend/src/pages/CrowdSecConfig.tsx`: delete `applyPresetLocally`, the `501` branch in `handleApplyPreset`, the `applied-locally` apply-info status, and anything left unused (`backupMutation` if only used there, imports, `presetPreview` write path). Non-2xx apply for hub presets is surfaced like curated: server error text, backup path if present, no write. The generic file editor (`writeMutation`) is unchanged but now shows server 400/413 messages. Update Vitest files: `CrowdSecConfig.test.tsx`, `.coverage.test.tsx`, `.spec.tsx`, `.curatedApply.test.tsx` (remove tests that assert the local fallback; add "501 for a hub preset shows an error and performs no file write"). + +### 3.4 Persistent CrowdSec data dir and hub data (#1523) + +`.docker/docker-entrypoint.sh` (next to the existing `db_path` redirect, ~L310-320): + +- `sed` `data_dir: /var/lib/crowdsec/data/` -> `data_dir: ${CS_DATA_DIR}/` in `$CS_CONFIG_DIR/config.yaml` (idempotent; works for both fresh and already-initialized volumes), with the same verify/echo pattern as the db redirect. `CS_DATA_DIR` is `/app/data/crowdsec/data` and is already `mkdir`ed and chowned. +- **Gated, never unconditional (blocking item 5).** After `install_hub_items.sh`, the entrypoint runs `cscli hub upgrade` **only if** at least one installed item's data files are missing. Detection: for each installed item (`cscli list -o json`), check that every file the item declares is present under `data_dir`; the exact JSON field for declared data files is confirmed by the implementer against `cscli inspect -o json` before coding (open implementation detail, verified in the Commit 6 gate). Fallback if the field is not exposed: treat an empty `data_dir` (ignoring `trace/` and the db) with at least one installed item as "missing". The call is bounded (`timeout 120s`), failure is a logged warning and never fatal, and it never uses `--force`. A normal restart with intact data files performs no upgrade, so items are never silently upgraded on a healthy start. Note `hub upgrade` upgrades every installed item; this is acceptable only in the recovery case it is gated on, and the log line must say so. +- It is skipped in the same condition as the rest of hub init (`CHARON_SECURITY_TESTS_ENABLED=false`). + +`configs/crowdsec/install_hub_items.sh`: change `install_if_missing` to decide on `cscli inspect -o json` reporting `"installed": true`, matching `ApplyCurated.verifyItem`. **Verified:** `jq` is not in the `charon:local` image, so use `grep -q '"installed": *true'` (not jq). **Verified:** `inspect -o json` still prints full JSON and exits 0 when an item is installed but its data file is missing, so the JSON signal is reliable in exactly the recreate scenario (the plain-text `inspect` exit code was not). Keep the "failed to install" warnings non-fatal. + +`Dockerfile`: keep creating `/var/lib/crowdsec/data` (a stale `data_dir` on an un-migrated config must still exist); no other change. +`.docker/compose/docker-compose.playwright-ci.yml:137` currently mounts a volume at `/var/lib/crowdsec/data`; repoint or remove it so E2E exercises the same persistent path as production. `docs/security.md` hardened-deployment text that tmpfs-mounts `/var/lib/crowdsec` stays valid (the db and data now live under `/app/data/crowdsec`) but its description must be updated. +`scripts/diagnose-crowdsec.sh` default `DATA_DIR` updated to the new location. + +Optional passive health signal (Q4 resolved: log line only, no UI): log a startup summary line listing installed-but-missing data files. No banner or badge (Passive UI, no nudges). + +### 3.5 Version injection (#1524) + +`Dockerfile` `crowdsec-inline` stage (both `xx-go build` commands, ~L915 and ~L923): replace the ldflags with `-s -w -X github.com/crowdsecurity/go-cs-lib/version.Version=v${CROWDSEC_VERSION} -X github.com/crowdsecurity/go-cs-lib/version.BuildDate=`. Do not set `Tag` (empty `Tag` leaves `version.String()` equal to `Version`). Do not add `BuildDate` from the wall clock: that would break the content-addressed, reproducible toolchain key; prefer the release date derived from the pinned tag or omit it. + +App-side assertion (second commit, after the toolchain bot has pushed the new TAG/DIGEST to the PR branch): in the existing N5 `RUN` block (~L1054) replace the wrong comment and add `grep -q "^version: v${CROWDSEC_VERSION}\$"` against `cscli version`. The final stage must redeclare `ARG CROWDSEC_VERSION` (currently declared only globally at L41 and in the caddy/crowdsec-inline stages; a global ARG is not visible inside a later stage without redeclaring). The build fails if the symbol ever silently no-ops again. + +Hub branch (decision D1, recommended): set `cscli.hub_branch` explicitly in the entrypoint-managed `config.yaml` so behavior is deterministic and does not depend on `version.crowdsec.net` reachability or on `hub-cdn` having a `v1.8.1` branch. Verified in upstream v1.8.1 `chooseBranch`: a non-empty `cscli.hub_branch` is returned immediately, before the `version.crowdsec.net/latest` lookup, so setting it removes both the network dependency and the CDN-404 risk. The online/offline test result is recorded in the PR description (Commit 8 gate). + +### 3.6 Handler split (#1517) + +Move to `backend/internal/api/handlers/crowdsec_preset_handler.go` (same package, no behavior change, no export changes): `ListPresets`, `PullPreset`, `ApplyPreset`, `applyCuratedPreset`, `curatedPresetPreview`, `recordPresetEvent`, `applyFailureBody`, `respondApplySuccess`, `GetCachedPreset`, and preset-only helpers. Move the matching unit tests into `crowdsec_preset_handler_test.go` files where they are clearly preset-only (leave shared fixtures where they are). `RegisterRoutes` stays in `crowdsec_handler.go`. The new file must be < 700 lines. + +## 4. Priorities & Ordering (CLAUDE.md "Findings Triage") + +None of the seven issues is user-submitted (all filed by the maintainer from PR #1513). Rules applied: bug fixes before new implementation (there is no `feat` here); critical/high ships as its own PR first; medium/low is scheduled after. + +Severity after root-causing (some differ from the issue labels): + +| Rank | Issue | Severity | Why | +|------|-------|----------|-----| +| 1 | #1523 data files missing after recreate | **medium** (issue says medium) | Silent degraded detection and no GeoIP on every recreated container; install guard defect makes fresh volumes suspect. Highest user impact. | +| 2 | #1514 + `WriteFile` destructive rename | **medium** (raised from low-medium) | Admin action can wipe the live CrowdSec tree; weak input validation. Security-sensitive (below). | +| 3 | #1515 `HubService.Apply` rename | **low-medium, confirmed** | Same defect class and same helper as #1514/#1516; fixed together. | +| 4 | #1524 empty version / hub branch | **low-medium** | Fix is easy but changes runtime hub behavior (offline failure, CDN 404 risk); needs the D1 decision and the toolchain bot. Scheduled after the fixes it must not destabilize. | +| 5 | #1516 backup accumulation | low | Piggybacks on the shared helper. | +| 6 | #1518 tolerant E2E | low | Test hardening; first in commit order (as `fixme` specs) and enabled last. | +| 7 | #1517 handler split | low | `refactor`, no behavior change; sequenced first in the *commit* order (mechanical, makes later diffs reviewable) but lowest in *priority*. | + +No finding is critical or high, so none must ship as its own PR ahead of this work. Priority above governs attention and what to cut first under contingency; commit order in section 5 is dependency-driven. + +**Security handling (#1514).** It is admin-only and already public as an issue. Assessment: low severity (authenticated admin only; the endpoint grants nothing beyond what an admin already has). **Q2 resolved by the user: #1514 is already public, so no private advisory is opened unless root-causing shows real impact beyond what an admin already has; it does not, so the default is NO advisory.** Handling: +- The existing public issue #1514 stays as is. Keep PR text, commit bodies and code comments free of exploit specifics anyway (no value in handing detail to un-upgraded installs). +- Exactly one commit in the PR carries the `(security)` scope, and its subject must stay in the vague category-only form: `fix(security): harden CrowdSec configuration file handling`. Keep the subject category-only, and Commit 4's body and the PR description stay equally vague (for example "tighten validation in the CrowdSec configuration file endpoints"). Every commit subject in a feature PR merge-commit appears verbatim in the public changelog, so the other commits use plain scopes (`fix:`, `refactor:`, `test:`, `docs:`, `chore:`) and describe user-visible behavior only. The destructive-rename data-loss fix is a plain `fix:` (it is a reliability bug, not an exploit). + +**Out-of-scope findings discovered while planning:** none left open. Items found beyond the issue text are folded in (install guard, wrong N5 comment, `ImportConfig` conversion, playwright-ci compose mount). If implementation finds anything else, file it per the triage rule at that time. + +## 5. Branch/PR Decision and Commit Slicing Strategy + +### 5.1 Branch/PR decision: ONE branch, ONE PR + +Single branch `fix/crowdsec-preset-apply-hardening` -> PR into `development`, titled e.g. `fix: harden CrowdSec preset apply and keep hub data across container recreation`. + +Why one PR (cohesion): all seven issues are one defect family, "the CrowdSec persistent tree is mutated unsafely and its hub/data state is not durable", and they share code: #1514, #1515 and #1516 all consume the same new `backup.go` helper (a single fix for rename-based backups, the file endpoint, and pruning); #1517 is the file those changes land in; #1518 is the test net for #1514/#1515; #1523 and #1524 are the same persistent-dir/hub-state story at the container layer and are both verified by the same rebuilt-container gate. Splitting would leave the helper without its callers, the E2E net without the behavior, or a half-fixed persistence story. + +Considered splits and why rejected: +- Docker/entrypoint (#1523, #1524) vs Go/frontend: they can build independently, but they do not conflict, share a single verification environment (rebuilt `charon:local`), and splitting would double the merge-soak the maintainer applies before `main`. The #1524 toolchain bot is explicitly designed to push the pin bump onto the PR head branch, so it works inside this PR. +- `(security)` fix alone: not required; severity is low (authenticated admin), and the rule "own PR" applies to critical/high only. + +**Contingency without splitting:** if Commits 7-8 (#1524) cannot be validated (hub-branch decision unresolved, toolchain build blocked), drop those commits from the PR and leave #1524 open rather than opening a second PR for it; the remaining six issues still form a complete, coherent PR. Rollback of the PR as a whole is a revert of the merge commit; a revert leaves a valid `data_dir` in `config.yaml` (the old image still creates the default directory), so nothing breaks. + +### 5.2 Ordered commits (all on the one branch) + +Every command run by implementers is foreground/blocking per CLAUDE.md. Each commit must build and pass its gate; the PR as a whole passes the full Definition of Done (section 8). + +**Commit 1 - `test: add deterministic E2E specs for CrowdSec preset and file flows (fixme)`** (#1518, step 1 of the suggested sequence) +- Files: new `tests/security/crowdsec-hub-preset-apply.spec.ts`, new `tests/security/crowdsec-file-editor.spec.ts`. +- Content, all stubbed with `page.route` like `crowdsec-preset-apply.spec.ts`, marked `test.fixme` until their feature commits: hub preset apply success shows success only when body `status==='applied'`; hub preset apply 500/503/504 shows an error and **zero** `POST /admin/crowdsec/file` calls; hub preset apply 501 shows an error and zero writes; file editor save success posts exactly one write for exactly the selected path; file editor shows the server message for 400 (disallowed type / invalid YAML) and 413. +- Depends on: none. Gate: `npx playwright test tests/security/crowdsec-hub-preset-apply.spec.ts tests/security/crowdsec-file-editor.spec.ts --project=security-tests` runs and reports the tests as fixme/skipped with no failures; lint/type-check clean. + +**Commit 2 - `refactor: move CrowdSec preset handlers into their own file`** (#1517) +- Files: new `backend/internal/api/handlers/crowdsec_preset_handler.go`; edit `crowdsec_handler.go`; move/split preset-only tests into `crowdsec_preset_handler_test.go`. +- Pure move: no signature, route, or behavior changes. Depends on: none. +- Gate: `cd backend && go build ./... && go test ./internal/api/handlers/... -race` all green with the same test count; `git diff --stat -M` shows moves, not rewrites; `make lint-fast`. + +**Commit 3 - `fix: keep CrowdSec backups bounded and copy-based for preset apply`** (#1515, #1516) +- Files: new `backend/internal/crowdsec/backup.go` + `backup_test.go`; edit `hub_sync.go` (`Apply` per the flow in 3.1; remove `backupExisting`/`rollback`/`emptyDir`/dead `copyDir`), `curated_apply.go` (move helpers, add prune call), `crowdsec_handler.go` `ImportConfig` (snapshot/restore conversion, handler lock), `hub_pull_apply_test.go`, `device_busy_test.go` (the rename/EBUSY tests are obsolete and are replaced by copy-path tests). +- Depends on: Commit 2 (preset code already in its own file; `ImportConfig` stays in the main handler). +- Unit tests (new, all with `t.TempDir()`): snapshot excludes live db files, top-level `data/` and `hub_cache/`, preserves symlinks, and still copies a nested directory named `data` under `config/`; **size test:** a large file under `data/` and under `hub_cache/` is not copied while a small `config/` file is (assert snapshot size); restore leaves live db, `data/` and `hub_cache/` untouched; `PruneBackups` orders by the timestamp parsed from the name (not mtime; touching an old dir does not save it), handles both name layouts, ignores unparsable names, non-matching names, symlinks and plain files, never deletes `dataDir`, clamps `keep<1` to 1, always keeps the newest, handles a missing parent, and `backup` and `filebackup` namespaces never evict each other; `HubService.Apply` flows: (a) cscli success leaves `DataDir` in place (inode unchanged) and prunes; (b) **cscli failure -> snapshot restored -> cached-archive extract succeeds -> `applied`, `UsedCSCLI=false`, no cscli leftovers**; (c) cscli failure and extract failure -> snapshot restored, error returned with `BackupPath`; (d) cscli failure with cache miss and hub refresh failure -> restored; (e) prune also runs after a failed apply and keeps the failed apply's snapshot; `ImportConfig`: success replaces config but preserves live db/`data/`/`hub_cache/` and never renames `DataDir`; extract failure restores the previous config; prune runs; concurrent `ImportConfig` + apply serialize on the handler lock; an uploaded archive containing `crowdsec.db`, `data/x` and `hub_cache/y` entries extracts none of them. +- Gate: `go test ./internal/crowdsec/... ./internal/api/handlers/... -race`; package coverage not below the current level; **rebuilt-container verification of #1515**: pull + apply one hub preset against a running agent, confirm `/app/data/crowdsec` is never renamed (inode unchanged), the apply result is correct, and at most 5 full-snapshot dirs remain after 7 applies (this also settles the "not confirmed" item in 2.2). + +**Commit 4 - `fix(security): harden CrowdSec configuration file handling`** (#1514 backend; subject intentionally vague, see section 4) +- Files: new `backend/internal/api/handlers/crowdsec_files.go` + `crowdsec_files_test.go`; edit `crowdsec_handler.go` (`ListFiles`, `ReadFile`, `WriteFile` delegate to the shared helper); update existing `TestCrowdsec_WriteFile_*` tests in `crowdsec_handler_coverage_test.go`. +- Depends on: Commit 3 (per-file backups use the `filebackup` namespace and handler lock). Commit body and PR text stay vague (section 4). +- Unit tests (categories only here): path validation rejection cases, symlink policy (write through or replacement of a symlink is refused, a regular file under `DataDir` is written), type/content/size validation (disallowed type, invalid YAML, oversize -> 413, protected files), atomic write leaves no temp file on failure, `DataDir` and unrelated files untouched after a write (regression test for the destructive rename), `filebackup` contains only the previous version of the target and is capped independently of full snapshots, the read/list predicate and the stricter write predicate behave as in 3.2, `ReadFile` shares the validation. +- Gate: `go test ./internal/api/handlers/... -race`, `make lint-fast`; GORM scan not required (no models); the Playwright file-editor spec enabled in Commit 9 depends on this behavior. + +**Commit 5 - `fix: remove the dead local-write fallback from CrowdSec preset apply`** (#1514 frontend) +- Files: `frontend/src/pages/CrowdSecConfig.tsx`, its four test files under `frontend/src/pages/__tests__/`, `frontend/src/api/crowdsec.ts` only if a now-unused export remains. +- Depends on: none technically (the backend never returns 501); ordered after Commit 4 so the whole #1514 story is reviewable in sequence. +- Gate: `cd frontend && npm run type-check && npx vitest run src/pages/__tests__/CrowdSecConfig*` green; no unused-export/lint findings; `npm run build`. + +**Commit 6 - `fix: persist CrowdSec hub data files across container recreation`** (#1523) +- Files: `.docker/docker-entrypoint.sh`, `configs/crowdsec/install_hub_items.sh`, `.docker/compose/docker-compose.playwright-ci.yml`, `scripts/diagnose-crowdsec.sh`, `docs/security.md` (wording), new integration test `scripts/crowdsec_data_persistence_test.sh` (follows the pattern of `scripts/crowdsec_startup_test.sh`). +- Depends on: none (independent of the Go commits; ordered after so a failing container gate does not block the Go work from being reviewed). +- Tests: shell-level test (start container with a named volume, wait for hub init, assert data files and `GeoLite2-City.mmdb` exist under `/app/data/crowdsec/data`, `docker rm` and recreate with the same volume, assert files are present and `crowdsec.log` has no `unable to init data for file` / `unable to initialize GeoIP` lines after enabling CrowdSec; also run it on a volume initialized by the old image to prove the sed migration). Add `shellcheck` cleanliness for changed scripts. +- Gate: rebuild `charon:local` (skill `docker-rebuild-e2e` / `.github/skills/scripts/skill-runner.sh docker-rebuild-e2e`), run `scripts/crowdsec_data_persistence_test.sh` and `scripts/crowdsec_startup_test.sh`; the fresh-volume case must show the install guard now really installing. +- Additional tests: the gate must prove `hub upgrade` is **not** run on a restart with intact data files (log assertion) and **is** run (once, bounded) when data files are missing; `shellcheck` is a required gate for every changed or new shell script (`.docker/docker-entrypoint.sh`, `install_hub_items.sh`, the new test script). +- CI wiring and ignore files: wire `scripts/crowdsec_data_persistence_test.sh` into the existing CrowdSec integration job via a skill wrapper like `.github/skills/integration-test-crowdsec-startup-scripts` and the `integration-tests.yml` workflow (rather than a new workflow). Check `.dockerignore` (it already excludes `scripts/tests/` and the toolchain scripts; the new script must be excluded from the image the same way if it lives under `scripts/`), `.gitignore`, and `.codecov.yml` (shell scripts are not coverage-measured; confirm no new exclusion needed). +- Snapshot interaction (item 4 sequencing): Commit 3's snapshot already excludes top-level `data/` and `hub_cache/`, so Commit 6's relocation of `data_dir` into `/app/data/crowdsec/data` does not enlarge any backup; the Commit 3 size test is the regression guard. + +**Commit 7 - `fix: report the real CrowdSec version in the bundled binaries`** (#1524, step A: ldflags) +- Files: `Dockerfile` (`crowdsec-inline`, both `xx-go build` lines). Bot-authored follow-up commit on the branch updates `CHARON_TOOLCHAIN_TAG` / `CHARON_TOOLCHAIN_DIGEST` (do not hand-edit those lines; the workflow owns them). +- Depends on: none. **Wait here until the toolchain-image workflow has built and pushed the pin bump to the PR branch** (this is the only asynchronous step in the plan). +- Gate: `toolchain-image.yml` green on the PR (build + Trivy gate), `verify-toolchain-pin` green, and a local `make build-offline`-style build (`--build-arg CROWDSEC_BUILDER_SRC=crowdsec-inline`) shows `cscli version` -> `version: v1.8.1`. +- Expected CI state: after this commit `verify-toolchain-pin` (and any check that compares TAG/DIGEST to the recipe key) is **red until the bot pushes the new pin**. Implementers must `git pull` the bot's commit before starting Commit 8 and must not hand-edit the two ARG lines. + +**Commit 8 - `fix: fail the image build if CrowdSec reports no version and make the hub branch deterministic`** (#1524, step B) +- Files: `Dockerfile` (N5 block: correct the comment, add the version assertion, add `ARG CROWDSEC_VERSION` to the final stage), `.docker/docker-entrypoint.sh` (hub branch setting per D1). +- Depends on: Commit 7 and the bot pin bump. +- Tests / build-time assertion: the Dockerfile `RUN` fails with a clear message if `cscli version` does not print `version: v${CROWDSEC_VERSION}`. Prove it fails by temporarily building with a mismatched `--build-arg CROWDSEC_VERSION` against the pinned toolchain (expected hard failure), then with the correct value (pass). Runtime check in the rebuilt container: `cscli version` truthful; `cscli hub update` succeeds online; with the network blocked (`--network none`) hub commands behave as the D1 decision specifies (no hard failure when `hub_branch` is set). +- Gate: image builds; `scripts/crowdsec_startup_test.sh` green; both stderr lines `Crowdsec version is not set` and `unable to retrieve latest crowdsec version` absent from `docker logs` in the online and offline runs. +- Record in the PR description: `cscli.hub_branch` is honoured by v1.8.1 and skips the `version.crowdsec.net` lookup (source-verified in `chooseBranch`), plus the measured online and offline (`--network none`) results. + +**Commit 9 - `test: enable CrowdSec preset and file-editor E2E specs and drop tolerant ones`** (#1518, final step) +- Files: the two specs from Commit 1 (remove `fixme`), `tests/security/crowdsec-config.spec.ts` (delete the tolerant "Preset Management" assertions that are now covered by deterministic specs, or convert each remaining case to a deterministic stub; no `isVisible().catch(() => false)` guards, no "feature may not be implemented" annotations remain in `tests/security/crowdsec-*.spec.ts`). +- Depends on: Commits 4 and 5. +- Gate: `npx playwright test tests/security/crowdsec-hub-preset-apply.spec.ts tests/security/crowdsec-file-editor.spec.ts tests/security/crowdsec-preset-apply.spec.ts tests/security/crowdsec-config.spec.ts --project=security-tests` green. Mutation check: temporarily re-add the local-write fallback (or revert the success-status check) and confirm at least one of the new specs fails, then undo. Full-suite and cross-browser runs are CI-only. + +**Commit 10 - `docs: document CrowdSec preset apply safety and data persistence`** +- Files: `ARCHITECTURE.md`, `docs/features/crowdsec.md`, `docs/troubleshooting/` entry if appropriate (see 7). +- Depends on: all. Gate: markdown lint/pre-commit; `docs-site` manifest unchanged (new content goes into already-manifested `docs/features/` / `docs/troubleshooting/`). + +## 6. Decisions and Edge Cases + +- **D1 hub branch (Q1 resolved: option A).** Options: (A, recommended) set `cscli.hub_branch: master` explicitly: identical to today's effective behavior while 1.8.1 is the latest, no dependency on `version.crowdsec.net`, no 404 risk, truthful `cscli version`. (B) Leave auto-selection with the fixed version: matches upstream's intent but can hard-fail hub commands offline and break hub updates when 1.8.1 stops being latest and the CDN has no `v1.8.1` branch (404 today). (C) Pin to the version branch: broken today (404). Commit 8 gate must run the empirical online/offline test either way. +- **Locking (one mechanism).** A single handler-level `sync.Mutex` on `CrowdsecHandler` (e.g. `dataMu`) is held for the whole of `ApplyPreset` (both curated and hub paths), `ImportConfig` and `WriteFile`. `HubService.mu` remains as an inner guard for direct service callers; lock order is always handler mutex first, then `HubService.mu`, so no deadlock. No other lock is introduced. +- Edge cases to cover in tests: snapshot dir name collisions (microsecond timestamp plus exclusive `os.Mkdir`), parent dir not writable, `DataDir` being a mount point (the copy path must work where rename used to fall back), symlinks inside `DataDir` never followed by restore/prune/write, concurrent apply/import/file write serialized by the handler mutex (held for the full `ApplyTimeout`, so file saves block during a long apply; `ReadFile` and `ListFiles` stay lock-free), CrowdSec stopped, existing volumes whose `config.yaml` still points at `/var/lib/crowdsec/data` (sed migration, then the gated `hub upgrade` repopulates), tmpfs `/var/lib/crowdsec` deployments (no longer relevant for data after migration). +- Reload behavior from #1513 (SIGHUP via `reloadCrowdSec`) is unchanged and should also be triggered, best-effort, after the entrypoint-independent hub changes that happen at runtime only (not applicable to startup). + +## 7. Docs Impact + +- `ARCHITECTURE.md`: update the Layer 2 CrowdSec section (~L868-890): CrowdSec `data_dir` and LAPI db live under `/app/data/crowdsec/data` (persistent), hub data files are re-synced when missing via a gated `cscli hub upgrade`, preset applies and config edits are copy-based/atomic with bounded backup retention, `cscli version` reports the pinned version and the hub branch is explicit. The file layout table around L288-294 gets the data subdirectory note. +- `docs/features/crowdsec.md`: extend "Curated Presets" (L57-59) / Hub Presets with the backup retention (last N kept), that hub presets are also applied on the server with a rollback, and that the config editor only accepts specific file types with validation. Add a short troubleshooting entry for the `unable to init data for file` log line (what it meant, that it self-heals on restart after this release). Keep it novice-friendly. +- `docs/features.md`: no new capability; no change expected (confirm during Commit 10). +- `docs/security.md`: tmpfs `/var/lib/crowdsec` guidance wording (Commit 6). +- Changelog is generated from commit subjects; subjects in section 5.2 are written for that. + +## 8. Test Plan Summary and Definition of Done + +- **Backend unit:** new tests in Commits 3 and 4 (list above). Coverage: package and patch coverage must not drop; overall >= 85% (`scripts/go-test-coverage.sh`); `bash scripts/local-patch-report.sh` produces `test-results/local-patch-report.md/.json`. +- **Frontend unit:** updated Vitest in Commit 5; `scripts/frontend-test-coverage.sh` >= 85%; `npm run type-check`; `npm run build`. +- **Playwright (#1518):** deterministic stubbed specs per Commit 1/9, role-based locators, no `waitForTimeout`, no visibility-guard skips. Locally only the targeted spec files with `--project=security-tests` (security specs are excluded from the browser projects; other specs use `--project=firefox`); CI runs the full matrix. +- **Build-time assertion (#1524):** Dockerfile `RUN` check in Commit 8 with the negative (mismatched version) and positive proof recorded in the PR description. +- **Container integration:** `scripts/crowdsec_data_persistence_test.sh` and `scripts/crowdsec_startup_test.sh` (Commit 6/8). +- **Security scans:** this is a `fix:` with no new endpoints; CodeQL/Trivy are deferred to CI per CLAUDE.md, except that Commit 7's toolchain Trivy gate runs in CI by design. GORM scan not required (no model changes); run it only if a model is touched. +- **Full DoD checklist before merge (in order):** targeted Playwright (firefox) -> patch coverage preflight -> `lefthook run pre-commit` -> staticcheck (`make lint-fast`) -> backend/frontend coverage -> type-check -> `go build ./...` and `npm run build` -> clean-up (no debug prints, dead code, unused imports). Any failing test, even pre-existing, is fixed in this PR. +- **Pipeline after the plan:** Supervisor plan review -> user approval -> implement commit by commit (`backend-dev` for 2-4, `frontend-dev` for 5, `devops` for 6-8, `playwright-dev` for 1 and 9, `docs-writer` for 10) -> Supervisor implementation review -> `qa-security` last -> `docs-writer` pass. +- **Dispatch instructions (every subagent prompt must include these, verbatim in spirit):** run all tests, builds, coverage scripts, Playwright, linters, Docker builds and `lefthook` as **foreground, blocking** calls with the maximum timeout; never use `run_in_background`, `&`, `nohup` or end the turn waiting for a notification; if a call auto-backgrounds, immediately re-attach and poll until a real result; no worktrees; stay inside the repo (no live hosts); do not commit unless the orchestrator says so; Commit 4's text (body, comments, PR text) must stay vague per section 4; and the Commit 7 to Commit 8 hand-off requires pulling the toolchain bot's commit first. + +## 9. Acceptance Criteria + +1. Applying a hub preset or editing a CrowdSec file never renames, empties or removes `/app/data/crowdsec`; the live tree is intact after success and after any failure (tests prove it). +2. Full-tree snapshot dirs (`.backup.*`) are bounded to 5 and single-file edit backups (`.filebackup.*`) to 10, independently, after any apply/import/write attempt; snapshots exclude `data/` and `hub_cache/` so they stay small. +3. `POST /admin/crowdsec/file` validates paths, file types, YAML and size, refuses symlink writes, never renames `DataDir`, and `ReadFile`/`ListFiles` share the same validation; the frontend has no local-write fallback. +4. After `docker rm` + recreate with the same volume, the 12 listed hub data files and `GeoLite2-City.mmdb` exist and `crowdsec.log` has no `unable to init data` / GeoIP lines; fresh volumes actually install the items in `install_hub_items.sh`. +5. `cscli version` and `crowdsec -version` print `v1.8.1`; the image build fails if they do not; hub commands work online and offline per D1. +6. `crowdsec_handler.go` no longer contains the preset handlers; no behavior change from the move. +7. No tolerant "may not be implemented" Playwright tests remain for CrowdSec presets, and the mutation check in Commit 9 shows the new specs fail on regression. +8. Only one commit uses the `(security)` scope and its subject is the vague form above. + +## 10. Resolved Questions (all decided by the user) + +- Q1 (D1): RESOLVED: explicit `cscli.hub_branch: master`. +- Q2: RESOLVED by the user: no private advisory (issue is public; severity low, admin-only). Revisit only if implementation finds impact beyond admin privileges. +- Q3: RESOLVED: write allowlist, protected-file list, 1 MiB cap and the refuse-symlinks rule as specced in 3.2, with a separate looser read/list predicate. +- Q4: RESOLVED: startup log line only (no diagnostics field, no UI). +- Q5: RESOLVED: constants (5 full snapshots, 10 file backups), not configurable. diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index 5be618358..ef4d3446b 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -1,286 +1,189 @@ -# Plan: CrowdSec preset-apply and data/hub persistence hardening (follow-ups of PR #1513) +# CrowdSec Follow-ups Batch: #1479, #1481, #1526, #1528 -Type: ONE branch, ONE PR into `development`: `fix/crowdsec-preset-apply-hardening`. Medium/large fix (touches Go backend, React frontend, Dockerfile, entrypoint, E2E, docs), so per CLAUDE.md it gets a dedicated branch and PR, never a direct commit to `development`. -Closes: #1514, #1515, #1516, #1517, #1518, #1523, #1524. -Status: rev 2 (addresses Supervisor CHANGES REQUESTED: 7 blocking items), open questions Q1-Q5 resolved by the user. -Predecessor: the #1513 plan (curated preset apply) that previously lived in this file; it merged as `3a76804b`. +Status: DRAFT rev 2 (supervisor changes applied) for re-review. Uncommitted. Separate from `docs/plans/current_spec.md`. +Base: `origin/development`, which already includes #1525 (113f646c). All line numbers below were re-verified against it; re-verify again immediately before implementation. ## 1. Introduction -PR #1513 made curated CrowdSec presets server-authoritative. While verifying it in a rebuilt container, seven follow-ups were filed. They share one theme: **the CrowdSec preset/hub apply path and the persistent CrowdSec directory (`/app/data/crowdsec`) are not safe or durable**. Backups are taken by renaming the live directory, a generic file-write endpoint also renames the live directory, hub data files live on an ephemeral path, the bundled binary does not know its own version, and the tests that should guard this are tolerant skips. +Four open issues, all authored by the maintainer (none is user-submitted), all low severity: + +| Issue | Type | Area | Summary | +|---|---|---|---| +| #1528 | fix / product decision | backend (+ small frontend) | F1 archive entry check too broad, F3 absolute server paths in CrowdSec responses, F5 credentials YAML files still listed/readable in the editor | +| #1479 | hardening | backend `network` pkg | `WithAllowedDomains` option stores a list nothing reads | +| #1481 | cleanup | backend `crowdsec` pkg | production hub URL validator carries test-only host allowances | +| #1526 | test | Playwright | tolerant CrowdSec specs can pass without asserting the feature works | -Goal: after this PR, applying/editing CrowdSec config can never destroy the live config, hub data files survive container recreation, `cscli version` is truthful, backups are bounded, and E2E/unit tests fail when any of this regresses. +### Is #1479 CrowdSec-specific? +No. The option lives in the generic `backend/internal/network/safeclient.go` (`ClientOptions.AllowedDomains`, `WithAllowedDomains`). The `crowdsec` label is there only because its single production caller is `newHubHTTPClient` in `backend/internal/crowdsec/hub_sync.go`. It is included because it was found in the same investigation as #1481 (#1476) and the right fix for #1481 (one validator, no test allowances in production) determines the right fix for #1479 (remove the unused option so `validateHubURL` is the only allowlist). -## 2. Research Findings (root cause per issue, CLAUDE.md "Context First") +## 2. Research Findings -Key facts established from code (all paths verified in this repo): +### 2.1 #1479 `WithAllowedDomains` +- Stored in `ClientOptions.AllowedDomains`; `safeDialer` and `validateRedirectTarget` never read it. Its doc comment ("only these domains will be allowed") is false: a latent footgun. +- Only production caller: `newHubHTTPClient` (hub_sync.go). That client uses default `MaxRedirects` 0, so redirect enforcement is moot. `validateHubURL` already holds its own copy of the same three hosts, so two lists exist and neither is authoritative. +- **Three test references** in `backend/internal/network/safeclient_test.go` must change, otherwise compilation breaks when the field is removed: + 1. `TestSafeDialer_AllowedDomains` (~L153-175): sets `AllowedDomains` on options and asserts nothing about them. Dead test. **Delete.** + 2. `TestNewSafeHTTPClient_WithAllowedDomains` (~L291): **Delete.** + 3. ~L849 (`WithAllowedDomains("example.com", "api.example.com")` inside a larger test): **Edit** to drop the option only; keep the rest of the test. +- Redirect/option parity (the issue's second ask): `safeDialer` and `validateRedirectTarget` both go through `opts.policy()` / `AddressPolicy.Blocked`, so `AllowLocalhost`/`AllowRFC1918`/`AllowCGNAT` already behave identically. Pin that with one table test running the same host/option matrix through both rather than changing behavior. + +### 2.2 #1481 test-only allowances +- `validateHubURL` (hub_sync.go ~L106-160) accepts at any scheme (http included): `localhost`, `127.0.0.1`, `::1`, `*.example.com`, `*.example`, `example.com`, `*.local`, `test.hub`, before the HTTPS + three-host allowlist check (`hub-data.crowdsec.net`, `hub.crowdsec.net`, `raw.githubusercontent.com`). +- Production impact (for a docs/changelog note): an operator-set `HUB_BASE_URL`/`HUB_MIRROR_BASE_URL` using http, localhost or `.local` already fails at the dial layer (`network.NewSafeHTTPClient` blocks loopback/private), so tightening the validator changes the error text and where it is raised, not what works. No supported configuration breaks. Mention in release notes in one sentence. +- Existing seam: package var `hubAllowLoopback` (~L95), toggled by `setHubAllowLoopbackForTest` (hub_sync_test.go ~L2252) and exercised by `TestNewHubHTTPClient_LoopbackSeam` (~L2258). +- **Only 2 `httptest.NewServer` uses** exist in the crowdsec package tests: `hub_sync_test.go` ~L106 and ~L2259 (the latter is inside `TestNewHubHTTPClient_LoopbackSeam`). Everything else uses a stub `RoundTripper` with fake hostnames. +- Test reference sizing: roughly 100-110 hits of `test.hub`/`example.com`/`.local`/`hub.example` across `hub_pull_apply_test.go`, `hub_sync_test.go`, `hub_sync_raw_index_test.go`, and the handler package (`crowdsec_preset_handler_test.go`, `crowdsec_pull_apply_integration_test.go`); re-grep before starting and do not rely on exact figures. Handler tests are in `package handlers`, cannot reach an unexported field, and embed hosts in index JSON fixtures such as `download_url`/`preview_url`. +- `hubBaseCandidates()` (~L215) builds `[HubBaseURL, MirrorBaseURL, defaultHubMirrorBaseURL, defaultHubBaseURL]` and de-duplicates with `uniqueStrings`. The defaults are `https://hub-data.crowdsec.net` and `https://raw.githubusercontent.com/crowdsecurity/hub/master`. If migrated tests reuse those same hosts for the primary/mirror roles, candidates collapse via `uniqueStrings` and fallback-order tests silently change meaning. + +### 2.3 #1528 +- **F1**: `extractTarGz` (hub_sync.go ~L946) uses `strings.HasPrefix(cleanName, "..")`, which also rejects legitimate names like `..hidden.yaml`. The containment check below it (`filepath.Rel`, ~L955) already uses `".."+separator`, so the first check is redundant-but-stricter. +- **F5**: `isReadable` (crowdsec_files.go ~L79) hides `bouncer_key`, `*.db*` and engine-owned trees, but `local_api_credentials.yaml` and `online_api_credentials.yaml` are only in `protectedWriteNames` (write-protected; still listed and readable). Real error messages today: reads of a hidden path return `invalid path` (`resolveReadable` first check ~L128, and the resolved-path re-check ~L149); writes of a hidden path return `file cannot be edited` (`resolveWritable` ~L167-169, `errFileCannotBeEdited`). Mixed-case names: `isReadable` lowercases the base only for the `bouncer_key` and `*.db*` checks, so once the credentials names are added to its deny rules (using the same lowercase comparison) mixed-case variants become hidden too, a deliberate tightening. Nothing in `frontend/src` or the Playwright specs references the credentials files; `docs/troubleshooting/crowdsec.md` (~L644) stays valid. +- **F5 scope limit: `ExportConfig` is not covered.** `ExportConfig` (crowdsec_handler.go ~L810-880) walks the whole `DataDir` and tars it with no `isReadable` filter (it only skips symlinks), so credentials files and `bouncer_key` remain downloadable by an authenticated admin through the export endpoint. The "consistent with `bouncer_key`" argument therefore applies to the editor list/read surface only, not to export. +- **F3 leak sites** (all admin-authenticated; absolute server paths or archive entry names reach the client): + 1. File write `backup` field: `crowdsec_files.go` ~L415 (from `crowdsec.BackupFile`, kind `filebackup`, a directory). + 2. Import `backup` field: `crowdsec_handler.go` ~L727 (from `Snapshot`, kind `backup`, a directory). + 3. Preset apply: `crowdsec_preset_handler.go` ~L353 and ~L365 (`res.BackupPath`). + 4. Acquisition update: `crowdsec_handler.go` ~L2030 (`backupPath`, a **file** named `acquis.yaml.backup.`, not a directory). + 5. `rollbackFailure` (crowdsec/backup.go ~L291-293): message `"... (rollback failed: %v; backup retained at %s)"` flows via `err.Error()` into the preset-apply and curated-apply JSON error responses. + 6. `extractTarGz` errors (hub_sync.go ~L947, ~L956 and the mkdir/`destPath` errors in the loop): reach the client through import's `extraction failed: %v` (crowdsec_handler.go ~L715). + 7. `ExportConfig` error path: `gin.H{"error": err.Error()}` (~L877) can carry filesystem paths from the walk. + 8. Frontend print sites in `frontend/src/pages/CrowdSecConfig.tsx`: ~L474 (`Backup stored at ...`), ~L493 (`Backup created at ...`), ~L1116 (`t('crowdsecConfig.presets.backup'): {applyInfo.backup}`). Existing vitest `frontend/src/pages/__tests__/CrowdSecConfig.curatedApply.test.tsx` (~L130 stubs `/tmp/backup.tar.gz`, ~L170 asserts the absolute path) must change. + 9. Playwright stub fixtures carrying absolute paths: `tests/security/crowdsec-hub-preset-apply.spec.ts` ~L110, `tests/security/crowdsec-file-editor.spec.ts` ~L89, and `tests/security/crowdsec-preset-apply.spec.ts` (`/app/data/backups/...`). + Backup directory kinds are `.backup.` (Snapshot) and `.filebackup.` (single-file edits); the acquisition backup is a plain file next to `acquis.yaml`. No client uses the value programmatically. +- Docs: `docs/api` has no mention of the `backup` field (checked); `ARCHITECTURE.md` mentions CrowdSec only as directory layout, so it needs no change (confirm no-op at implementation). Locale files carry `crowdsecConfig.presets.backup` ("Backup") in each language under `frontend/src/locales/*/translation.json`; any new UI string needs all locale files updated (en plus the existing languages). + +### 2.4 #1526 tolerant specs +Files under `tests/security/`: `crowdsec-dashboard.spec.ts`, `crowdsec-decisions.spec.ts`, `crowdsec-diagnostics.spec.ts`, `crowdsec-console-enrollment.spec.ts`, `crowdsec-import.spec.ts`, and the non-preset cases of `crowdsec-config.spec.ts` (back navigation, config file list/content, export, console enrollment, status indicators). +Counts depend on the pattern; for exactness use two named patterns on `origin/development`: (a) `catch(() => false)` only: dashboard 10, decisions 15, diagnostics 3, console-enrollment 9, import 0, config 11; (b) the broader "tolerance" pattern (adds "may not be", "not implemented", annotation pushes) gave higher numbers in my first pass and 13/15/18/17/1/12 in the supervisor's pass. Do not rely on either number; the gate in section 6 (Bz) defines the final, exact pattern set. +Two styles: +1. UI specs (`dashboard`, `decisions`, `config`, parts of `console-enrollment`) that `goto('/security/crowdsec')` against the real backend and guard on visibility. Convert to stubbed-API specs following `crowdsec-preset-apply.spec.ts` (`page.route` + `route.fulfill({ json })`, `loginUser`, `waitForLoadingComplete`). +2. API-contract specs (`diagnostics`, most of `import`, parts of `console-enrollment`) that call `request.get/post('/api/v1/admin/crowdsec/...')` against the live backend and accept multiple statuses because cscli/LAPI are absent in the E2E container (`crowdsec-import` hides this behind a multi-status `expect([...]).toContain(status)` assertion). Convert to (a) strict assertions on a status the E2E container deterministically produces, or (b) stubbed UI tests plus Go handler tests for the real status mapping. +Strict assertions may expose real bugs (see section 8). + +## 3. Decisions + +### 3.1 F5: recommendation (needs user confirmation) +**Hide `local_api_credentials.yaml` and `online_api_credentials.yaml` from editor list and read** by adding them to the `isReadable` deny rules next to `bouncer_key` (same lowercase comparison), then delete `protectedWriteNames` and its branch in `isWritable` (redundant: `isWritable` is a subset of `isReadable`). + +Behavior change to state in docs: previously readable but write-protected, now hidden entirely (list omits them; read returns `invalid path`; write returns `file cannot be edited`, because `resolveWritable` checks `isReadable` first; new tests must assert these two real messages, not one generic message). Mixed-case names are now hidden too. + +Rationale (reworded; this is information-level hardening, not a closed exposure): the editor is a browser surface where plaintext secrets leak through screen shares, screenshots/support captures and the client-side query cache; novices have no need to read or edit these generated files; advanced operators still have disk access. Scope limit, stated plainly: this protects the editor surface only. Admin config export still includes these files and `bouncer_key` (section 2.3), so F5 does not remove the secrets from anything an admin can download. See Open Question on export. + +Alternative rejected: list but mask values (extra UI/parser complexity and a new leak path if masking misses a field). + +### 3.2 F3: recommendation +Return a **backup name**, not a path: a single helper `backupID(path string) string` (in `backend/internal/crowdsec/backup.go`) returning `filepath.Base`, used at every site in 2.3 items 1-4. It is a name, not an opaque token: the three kinds are `*.backup.*` (directory), `*.filebackup.*` (directory) and `acquis.yaml.backup.*` (file). The absolute path stays in server logs only (already `SanitizeForLog`ged). +- Errors: `rollbackFailure` keeps the cause but replaces the path with the backup name plus the hint "see server logs for the backup location"; `extractTarGz`/import errors become fixed messages (for example `archive contains an unsafe path`, `archive extraction failed`) with the entry name/dest path logged sanitized; `ExportConfig` returns a fixed `failed to export configuration` and logs the error. +- UI keeps manual recovery possible: text such as "Backup created: (next to the CrowdSec data directory)" in the three print sites, via a new i18n key (all locales) rather than hard-coded English; docs pointer in `docs/troubleshooting/crowdsec.md` explains where backups live. +- Field name `backup` is kept. Acceptance criterion: **no absolute path appears in any CrowdSec API response, including error text** (asserted by handler tests with a regex over response bodies for a leading `/` path or the data-dir prefix). + +### 3.3 F1 +Reject an archive entry only if any path component equals `..` or the path is absolute; keep the `Rel`-based containment check as backstop. Tests: allowed `..hidden.yaml`, `a/..b/c.yaml`, `foo..bar`; rejected `../x`, `a/../../x`, `/etc/x`, NUL. No exploit detail in public text. + +### 3.4 #1479: remove vs enforce +Remove `WithAllowedDomains`, `ClientOptions.AllowedDomains` (and its default), the caller in `newHubHTTPClient`, and the three test references in 2.1. Rationale: the only caller has a better-fitting validator, redirects are disabled for that client, and a second list would drift. + +### 3.5 #1481: seam design and migration +- Production `validateHubURL`: https only, host in the three-host allowlist. +- Test seam: unexported `validateURL func(string) error` on `HubService` (nil means `validateHubURL`), both call sites (`fetchIndexHTTPFromURL` ~L453, `fetchWithLimitFromURL` ~L754) go through one `s.checkURL(target)`. +- Only the `hub_sync_test.go` ~L106 `httptest.NewServer` test needs the `validateURL` seam (permissive validator) plus a `network.WithAllowLocalhost()` `HTTPClient`. The ~L2259 server lives inside `TestNewHubHTTPClient_LoopbackSeam`, which is deleted together with the var. +- **`hubAllowLoopback` and `setHubAllowLoopbackForTest` are deleted. `TestNewHubHTTPClient_LoopbackSeam` is deleted** (it only tests the removed var); replace with a small test that `newHubHTTPClient` rejects loopback (the first half of the old test, `require.Error`). +- `validateHubURL` tests flip to assert rejection: `localhostURLs` loop (~L996-1010), the other table tests in the range ~L969-1044, and the table at ~L1645; add acceptance cases for the three https hosts and rejection for http on an allowlisted host. +- Stub-transport migration: the roughly 100-110 hits above (including `download_url`/`preview_url` inside index JSON fixtures in `crowdsec_pull_apply_integration_test.go`, ~L40-50, and `crowdsec_preset_handler_test.go`) become allowlisted https hosts. Use one distinct allowlisted host per role so `uniqueStrings` in `hubBaseCandidates()` does not collapse candidates: primary `https://hub-data.crowdsec.net` (default), mirror `https://raw.githubusercontent.com/crowdsecurity/hub/master` (default), and `https://hub.crowdsec.net` for tests that need an extra distinct base. Tests that previously set `HubBaseURL = "http://test.hub"` use `https://hub.crowdsec.net`; tests whose assertions depend on candidate ordering get an explicit check. +- Estimated effort: mechanical host mapping across roughly 100-110 hits (re-grep first) plus one seam-using test; a single commit. Re-grep all line numbers in this spec right before implementation starts. + +## 4. Priorities & Ordering + +Classification: all four are the maintainer's own findings (not user-submitted), none critical or high, no `feat` in the batch. Order: +1. **#1528**: decide F5 first (as the issue says), then F5, F3, F1 (user-visible and information-exposure items first). +2. **#1479**, then **#1481** (latent hardening of the hub URL policy; no active bypass). +3. **#1526** last: largest and open-ended; it should assert the final contracts from 1 and 2. + +Findings rules: a critical/high finding discovered in #1526 ships first as its own PR. **Medium/low findings from #1526 are filed as GitHub issues by default**; they are fixed in-PR only when needed to make a converted test pass or to unblock CI (CLAUDE.md DoD). Security-sensitive findings go to a private advisory per SECURITY.md. + +Advisory screening: no private advisory needed for any of the four. #1479/#1481 are public, low, latent, no demonstrated bypass; F3/F5 are admin-authenticated information-level hardening with no known exploit chain; the export limitation is recorded as a known limitation, not a vulnerability (admin already has full control). If any item proves reachable by a lower-privileged actor, stop and convert to a private advisory before further public wording. All public wording (issue comments, commit subjects, PR bodies, docs) stays vague. + +## 5. Branch / PR Slicing Decision + +**Default: one PR with ordered commits** (PR A commits first, then PR B commits). This respects "one feature = one PR" and the user's dislike of PR slicing. + +**Exception, only with the user's approval before work starts: two PRs.** PR A (`fix/crowdsec-followups-hardening`: #1528, #1479, #1481) and PR B (`test/crowdsec-deterministic-e2e`: #1526, branched from `development` after A merges, no stacking). Justification if approved: (1) B is about 2,000 lines of test rewrite with an open-ended findings tail and would hold the small hardening work hostage; (2) a secrets-visibility change cannot be meaningfully reviewed beside that churn; (3) B must assert A's final contracts. Cost of the default: larger review and A blocked on B. This is flagged as an open question; no slicing beyond commits happens unless approved. + +## 6. Commit Slicing Strategy + +Conventional commits; no session ID/link anywhere. All subagent dispatch prompts must state explicitly: run every test, build, E2E and lint command in the foreground and block until it finishes (no background/detached runs, per CLAUDE.md). Playwright security specs run only with `--project=security-tests` (single project, targeted files only, never the full suite). Backend lint must use the full config (`cd backend && golangci-lint run --config .golangci.yml ./...` or `make lint-backend`), not only the fast hook. GORM scan: N/A (no model/query changes; state this at the gate). + +### Part A (hardening) + +| # | Commit | Scope | Files | Depends on | Validation gate | +|---|---|---|---|---|---| +| A0 | `test(e2e): add fixme specs for editor secrets and response paths` | `test.fixme` specs for F5 (credentials not listed; read rejected) and F3 (`backup` and error bodies contain no absolute path); update fixture stubs to name-only values | `tests/security/crowdsec-file-editor.spec.ts` (~L89 stub + new cases), `tests/security/crowdsec-hub-preset-apply.spec.ts` (~L110), `tests/security/crowdsec-preset-apply.spec.ts` | none | `npx playwright test tests/security/crowdsec-file-editor.spec.ts tests/security/crowdsec-preset-apply.spec.ts tests/security/crowdsec-hub-preset-apply.spec.ts --project=security-tests` | +| A1 | `fix(security): harden CrowdSec editor access` | F5 per 3.1; tests assert real messages: list omits both names, read -> `invalid path`, write -> `file cannot be edited`, mixed-case and symlink-to-credentials cases (symlink caught by the resolved-path re-check ~L149) | `backend/internal/api/handlers/crowdsec_files.go`, `crowdsec_files_test.go` | A0 | `cd backend && go test ./internal/api/handlers/... ./internal/crowdsec/...` | +| A2 | `fix: stop returning server paths in CrowdSec responses` (see Q5 on whether this deserves `(security)`; if the user says yes use `fix(security): harden CrowdSec API responses`) | F3 per 3.2 across all nine items in 2.3 (`backupID` helper, `rollbackFailure`, extract/export/import error text, three UI print sites + i18n key in all locales, vitest and Playwright fixtures) | `backend/internal/crowdsec/backup.go` (+test), `hub_sync.go`, `crowdsec_files.go`, `crowdsec_handler.go`, `crowdsec_preset_handler.go`, `frontend/src/pages/CrowdSecConfig.tsx`, `frontend/src/locales/*/translation.json`, `frontend/src/pages/__tests__/CrowdSecConfig.curatedApply.test.tsx` and any other test asserting the old value, `docs/troubleshooting/crowdsec.md` | A1 | backend `go test` for touched packages; `cd frontend && npm run type-check && npx vitest run src/pages/__tests__/CrowdSecConfig*`; response-body regex test shows no absolute path | +| A3 | `fix: accept archive entries that merely start with two dots` | F1 | `hub_sync.go` (~L946), `hub_sync_test.go` | none | `go test ./internal/crowdsec/...` | +| A4 | `refactor: remove the unenforced allowed-domains client option` | #1479 incl. the three test references and option-parity table test | `backend/internal/network/safeclient.go`, `safeclient_test.go`, `hub_sync.go` | none | `go test ./internal/network/... ./internal/crowdsec/...` | +| A5 | `refactor: restrict the hub URL check to production hosts` | #1481 per 3.5 | `hub_sync.go`, `hub_sync_test.go`, `hub_pull_apply_test.go`, `hub_sync_raw_index_test.go`, `backend/internal/api/handlers/crowdsec_pull_apply_integration_test.go`, `crowdsec_preset_handler_test.go` | A4 | `go test ./internal/crowdsec/... ./internal/api/handlers/...`; grep shows no `test.hub`/`example.com`/`hubAllowLoopback`/`AllowedDomains` outside intentional places | +| A6 | `docs/test: enable e2e specs and document the changes` | un-fixme A0; docs: credentials not shown in editor, backup names, one-line production note for #1481; confirm ARCHITECTURE.md and docs/api need no change | `tests/security/*.spec.ts`, `docs/features.md`, `docs/troubleshooting/crowdsec.md` (authored under `docs/`, never `docs-site/docs/`) | A1-A5 | targeted `npx playwright test tests/security/crowdsec-file-editor.spec.ts tests/security/crowdsec-preset-apply.spec.ts tests/security/crowdsec-hub-preset-apply.spec.ts --project=security-tests` | + +### Part B (#1526), after A + +| # | Commit | Scope | Files | Validation gate | +|---|---|---|---|---| +| B1 | `test(e2e): shared CrowdSec stub helpers` | extract repeated route stubs and fixtures (status, decisions, enrollment, file list/content, diagnostics) | new helper under `tests/utils/` or `tests/fixtures/` | one spec run | +| B2 | `test(e2e): deterministic crowdsec-config non-preset cases` | back nav, file list/content, export, enrollment, status indicators | `crowdsec-config.spec.ts` | `npx playwright test tests/security/crowdsec-config.spec.ts --project=security-tests` | +| B3 | `test(e2e): deterministic crowdsec dashboard and decisions` | | `crowdsec-dashboard.spec.ts`, `crowdsec-decisions.spec.ts` | same per file | +| B4 | `test(e2e): deterministic crowdsec console enrollment` | stubbed states: not enrolled / pending / enrolled / failed / heartbeat | `crowdsec-console-enrollment.spec.ts` | same | +| B5 | `test(e2e): deterministic crowdsec diagnostics and import` | strict deterministic statuses or stubbed UI + Go handler tests for status mapping | `crowdsec-diagnostics.spec.ts`, `crowdsec-import.spec.ts` (+ Go tests if gaps) | same | +| B6 | `fix: ...` only when required | in-PR fix only if needed to make a converted test pass or to unblock CI, with a regression test; every other medium/low finding is filed as an issue instead (section 4); high/critical goes to its own PR | as needed | the relevant spec + unit tests | +| Bz | `test(e2e): enforce no tolerant patterns in CrowdSec specs` | gate commit: no tolerance residue in the six files | none (verification only; optionally a lint script) | the command in the code block below (must return nothing, except reviewed legitimate `test.skip` hits); also review by hand for `console.log`-only branches and annotation pushes | + +Bz gate command (plain `|` alternation; ripgrep treats `\|` as a literal pipe). `test\.skip` hits need manual review because conditional skips can be legitimate: + +```bash +rg -n "catch\(\(\) => false\)|catch\(\(\) => \{\}\)|may not be|not implemented|test\.skip|expect\(\[[0-9, ]+\]\)\.toContain" \ + tests/security/crowdsec-config.spec.ts tests/security/crowdsec-dashboard.spec.ts \ + tests/security/crowdsec-decisions.spec.ts tests/security/crowdsec-diagnostics.spec.ts \ + tests/security/crowdsec-console-enrollment.spec.ts tests/security/crowdsec-import.spec.ts +``` -- `crowdsecDataDir = cfg.Security.CrowdSecConfigDir` (`backend/internal/api/routes/routes.go:1030`), set to `/app/data/crowdsec` by `Dockerfile:1142` (`CHARON_CROWDSEC_CONFIG_DIR`). The same directory is `CS_PERSIST_DIR` in `.docker/docker-entrypoint.sh`. It therefore contains the **whole persistent CrowdSec tree**: `config/` (the target of the runtime symlink `/etc/crowdsec -> /app/data/crowdsec/config`), `data/` (LAPI db), `hub_cache/`, `bouncer_key`. -- That same path is `HubService.DataDir` and `CrowdsecHandler.DataDir` (`crowdsec_handler.go:374-397`). Every "backup the DataDir" in the code is a backup directory next to it, `/app/data/crowdsec.backup.`. +Full Definition of Done for the PR(s): targeted Playwright as above; full-config backend lint; `scripts/go-test-coverage.sh` >= 85%; `scripts/frontend-test-coverage.sh`; `bash scripts/local-patch-report.sh`; `npm run type-check` and `npm run build` in `frontend/`; `go build ./...`; `lefthook run pre-commit`. CodeQL/Trivy are CI-deferred (fix/refactor, no new surface); a local CodeQL Go run is optional for A1/A2. -### 2.1 #1514 applyPresetLocally writes unvalidated client content (root cause is wider than the issue says) +## 7. Test Plan -- Entry: `frontend/src/pages/CrowdSecConfig.tsx:429` `applyPresetLocally`; reached only when apply returns HTTP 501 for a non-curated (hub) preset (line ~508). It writes `presetPreview` (text from the pull preview, held in client state) to `selectedPath ?? files[0]` through `writeCrowdsecFile` (`frontend/src/api/crowdsec.ts`). -- Confirmed: the backend **never returns 501** for apply any more (grep of `crowdsec_handler.go` shows no `StatusNotImplemented`). The fallback is dead code in practice and can only be triggered by a proxy/misconfig, which makes removing it risk-free. -- Transformation/persistence: `POST /admin/crowdsec/file` -> `CrowdsecHandler.WriteFile` (`crowdsec_handler.go:950`, registered under `managementAdmin`, so admin-only). Verified defects in the handler, independent of the frontend: - 1. **Destructive backup.** It `os.Rename(h.DataDir, backupDir)` (moves the *entire* persistent CrowdSec tree away, config symlink target included), then `MkdirAll(filepath.Dir(p))` and writes just the one file. Everything else (config.yaml, hub, bouncer key, data) is left only in the backup dir. The live tree becomes a single file. There is no restore. This is the same defect class as #1515 and is a medium-severity data-loss bug, not just "unvalidated content". - 2. **The file endpoints get a validation pass.** The fix is described at policy level in 3.2. -- Exit: `{status:"written", backup}`; the UI toasts "File saved" / "Preset applied locally". -- Severity/impact: low-medium, authenticated admin only (not an unauthenticated vulnerability). See section 4. +- Backend: `isReadable`/`isWritable`/`resolveReadable`/`resolveWritable` table tests asserting real messages (`invalid path` for reads, `file cannot be edited` for writes) for both credentials names, mixed case, nested, symlink target; `backupID` table; handler tests with a response-body regex for absolute paths on success and error paths (file write, import including extraction failure, preset apply, curated apply with a forced rollback failure, acquisition update, export error); `validateHubURL` rejection/acceptance tables; safeclient dialer-vs-redirect parity table; tar entry-name table. +- Frontend: update `CrowdSecConfig.curatedApply.test.tsx` and add tests for the three print sites and the new i18n key. +- E2E: per commit tables. +- Grep gates: no `WithAllowedDomains`/`AllowedDomains`/`hubAllowLoopback` remnants; no test hosts in non-test code. -### 2.2 #1515 HubService.Apply renames the whole data dir (CONFIRMED in code; end-to-end not executed) +## 8. Risks and Mitigations -- Entry: `ApplyPreset` -> `Hub.Apply` for non-curated presets (`hub_sync.go:602`). -- Transformation: `backupExisting` first tries `os.Rename(DataDir, .backup.)`; only if rename fails (EBUSY on a mount point, cross-device) does it fall back to `copyDir`. Then `runCSCLI` (`cscli hub update` + `cscli hub install `). -- Why it is wrong, confirmed by reading the code and by the behavior seen in a throwaway container (below): after the rename `/etc/crowdsec -> /app/data/crowdsec/config` dangles, so `cscli` cannot read `config.yaml` (observed error: `open /etc/crowdsec/config.yaml: no such file or directory`). The cscli path therefore fails, the code falls to `extractTarGz` into a freshly created `DataDir`, and on success the live dir holds only the extracted archive while config, hub, `bouncer_key`, LAPI db and `hub_cache` sit in the backup. CrowdSec is running while its db directory is moved. Rollback (`rollback`) does `RemoveAll(DataDir)` then renames the backup back, so it is also only valid for that directory and cannot undo anything `cscli` wrote outside it. -- Not confirmed: that a *real* hub-preset apply on a running container hits exactly this sequence (needs a hub preset pull + a running agent). The verification step is part of the gate in Commit 3. Whether the extract fallback ever "succeeds" with a usable tree is irrelevant; the rename itself is the defect. -- Fix direction (matches the issue): reuse the copy-based, symlink-preserving, db-excluding `backupCopy`/`restoreCopy` added for `ApplyCurated`. +| Risk | Mitigation | +|---|---| +| ~100-110-hit test migration (A5) breaks many tests | Single commit, package-by-package runs, mechanical mapping with distinct hosts per role (3.5) | +| Migration weakens tests via `uniqueStrings` collapse | Distinct hosts per role; ordering assertions where relevant | +| F5 hides a file someone relies on viewing | Documented as a behavior change; disk access unchanged; masked read-only view is the fallback | +| F5 gives false assurance because export still includes secrets | Stated as known limitation; export question below | +| F3 breaks manual-recovery UX | Name plus location hint in UI, docs pointer, server log keeps full path | +| #1526 exposes real bugs | Stop rule: if more than N=6 real bugs surface, stop and re-plan with the user; medium/low are filed as issues by default (section 4) | +| Live-backend E2E flake | Prefer stubs; where live, assert only deterministic states | +| Specs silently not run without `--project=security-tests` | Every gate names the project; confirm test count in output | -### 2.3 #1516 backup directories accumulate (CONFIRMED) - -Creators of `.backup.*`: `HubService.Apply` (rename, second-resolution timestamp), `ApplyCurated` (copy, microsecond timestamp), `CrowdsecHandler.ImportConfig` (`crowdsec_handler.go:696`, rename), `WriteFile` (rename). Nothing ever deletes any of them. grep for prune/retention in `backend/internal/crowdsec` returns nothing. Because the curated backup is a full copy of the persistent tree (minus the db) and the legacy ones are the whole tree, growth is unbounded on a volume the user sizes for a home server. - -### 2.4 #1517 handler file size (CONFIRMED) - -`crowdsec_handler.go` is 2,906 lines. Preset handlers live at lines ~990-1325 (`ListPresets`, `PullPreset`, `ApplyPreset`, `applyCuratedPreset`, `curatedPresetPreview`, `recordPresetEvent`, `applyFailureBody`, `respondApplySuccess`, `GetCachedPreset` at ~1426). Pure move, same package. - -### 2.5 #1518 tolerant Playwright tests (CONFIRMED) - -`tests/security/crowdsec-config.spec.ts` "Preset Management" block (lines ~73-145) and "Configuration Files" block are `isVisible().catch(() => false)` guarded and annotate "feature may not be implemented"; they cannot fail on a regression. `tests/security/crowdsec-preset-apply.spec.ts` (from #1513) is the model: route-stubs `**/api/v1/admin/crowdsec/presets/apply` and the file-write route and asserts toasts and write counts. Missing deterministic coverage: hub (non-curated) preset apply success/failure surfaced, no client-side write on any apply failure, file editor save/failure, file-list error cases. - -### 2.6 #1523 hub data files missing after container recreation (ROOT CAUSE CONFIRMED by experiment) - -- Config: CrowdSec's default `config_paths.data_dir` is `/var/lib/crowdsec/data/` and `hub_dir` is `/etc/crowdsec/hub/` (upstream `config/config.yaml`). The entrypoint (`.docker/docker-entrypoint.sh:310-319`) redirects **only** `db_path` to the volume. `Dockerfile:1089` creates `/var/lib/crowdsec/data` inside the image, so on a recreated container it is an empty directory (confirmed: the issue shows only `trace/`). `/etc/crowdsec` is a symlink into the volume so the **hub items persist but their data files do not**. -- `configs/crowdsec/install_hub_items.sh` skips items for which `cscli inspect ` exits 0 ("already installed, skipping"). -- Experiment (throwaway `charon:local` container run with `--entrypoint sh`, no volumes, no live host touched): - - Install `crowdsecurity/http-backdoors-attempts` -> `backdoors.txt` appears in `data_dir`. - - Wipe `data_dir` (simulating recreate), keep the hub: `cscli scenarios install ` -> "Nothing to install or remove", data dir stays empty. `cscli scenarios install --force ` -> same, still empty. **`cscli hub upgrade` repopulates `backdoors.txt`, and so does `cscli hub upgrade --force`.** The issue's suggestion "`install --force` or `hub upgrade`" is therefore half right: only `hub upgrade` works. -- **Additional defect found (CONFIRMED, not in the issue):** on a hub with an index but nothing installed, `cscli scenarios inspect crowdsecurity/http-backdoors-attempts` exits **0** and prints `installed:false` metadata. `install_if_missing` uses the exit code, so on a fresh volume it may print "already installed, skipping" and install nothing. (In the experiment inspect exited 1 when the item was installed but its data file was missing, so the exit code is not a reliable installed/absent signal in either direction.) #1513's `ApplyCurated` already uses the correct signal: `inspect -o json` -> top-level `installed`. -- Not confirmed: whether the missing-install path is masked in real startups by something else (e.g. the collections installing their children). Needs the recreate test in Commit 6 on a clean volume. - -### 2.7 #1524 empty version (ROOT CAUSE CONFIRMED; the fix has a gotcha) - -- Confirmed: the injected symbol is wrong. In CrowdSec v1.8.1, `pkg/cwversion/version.go` declares only `Codename` and `Libre2` (and no `Version`); the version comes from `github.com/crowdsecurity/go-cs-lib/version` (`Version`, `BuildDate`, `Tag`, `System`), pinned at `go-cs-lib v0.0.25` by CrowdSec v1.8.1's `go.mod`. Upstream's own Makefile uses `-X 'github.com/crowdsecurity/go-cs-lib/version.Version=$(BUILD_VERSION)'` (plus `BuildDate`, `Tag`, and `cwversion.Codename`). `-X` on a nonexistent symbol is silently ignored, so both `Dockerfile:915` and `:923` are no-ops. Observed in the built image: `cscli version` prints empty `version:`, `Codename:`, `BuildDate:`, and `cscli` logs `Crowdsec version is not set, using hub branch 'master'`. -- The existing app-stage sanity check (`Dockerfile` ~L1054) says "CrowdSec 1.8.x prints an empty `version:` field here regardless of the -X ldflag". That comment is wrong and hides the bug; it must be corrected. -- **Gotcha, verified in `cmd/crowdsec-cli/core/require/branch.go` (v1.8.1):** with a real version cscli calls `https://version.crowdsec.net/latest` on every hub command and returns a hard error if it cannot reach it; if the running version equals latest it uses `master` (identical to today), if it is older than latest it uses the branch named after the version. `https://hub-cdn.crowdsec.net/v1.8.1/.index.json` returns **404** today (v1.8.0, v1.7.8 and master return 200). So a correct version could (a) make `cscli hub *` fail offline/air-gapped and (b) break hub updates once 1.8.1 is no longer the latest and the CDN has no `v1.8.1` branch. The issue's "hub items may be newer than the engine supports" claim is also overstated: while 1.8.1 is the latest release the behavior is already `master`. The fix therefore needs an explicit hub-branch decision (see D1) rather than a bare `-X` correction. -- The toolchain delivery path: the default Dockerfile build consumes the prebuilt `ghcr.io/wikid82/charon-toolchain` image (content-hash tag `CHARON_TOOLCHAIN_TAG`, `Dockerfile:22`). Editing the `crowdsec-inline` stage changes the key, `.github/workflows/toolchain-image.yml` builds the new image and its bot commits the new TAG/DIGEST onto the PR head branch (workflow lines ~365-420) so `verify-toolchain-pin` goes green. Until that happens the app image still contains the old binary, so the app-side assertion cannot be added in the same commit as the ldflags change. - -### 2.8 Reproduction/evidence commands run for this plan (all foreground, repo-local or throwaway container) - -`gh issue view` for each issue, `gh pr view 1513`; code reads listed above; `curl` of upstream v1.8.1 source files and `hub-cdn.crowdsec.net//.index.json`; shallow clone of upstream v1.8.1 into the session scratchpad; throwaway `docker run --rm --entrypoint sh charon:local` experiments (data-dir wipe, inspect exit codes, `hub upgrade`). Nothing on live hosts or volumes was touched. - -## 3. Technical Specifications - -### 3.1 Shared backup helper, Apply flow, ImportConfig (#1515, #1516) - -New file `backend/internal/crowdsec/backup.go` (move `backupCopy`, `restoreCopy`, `isLiveDBFile`, `emptyDirExcept` out of `curated_apply.go`; the move itself changes no behavior): - -- **Two namespaces, two caps, so they cannot evict each other (blocking item 3).** - - Full-tree snapshots: backup dirs next to `DataDir`: `.backup.`, `const DefaultBackupRetention = 5`. - - Single-file edit backups (from `WriteFile`, section 3.2): backup dirs next to `DataDir`: `.filebackup./`, `const DefaultFileBackupRetention = 10`. The `.backup.*` glob never matches `.filebackup.*`. -- **Snapshot exclusions (blocking item 4).** `snapshot` copies `DataDir` (symlinks preserved) but **excludes**: the live `crowdsec.db`, `-wal`, `-shm` (anywhere), and the top-level `data/` directory (LAPI db plus hub data files and the GeoLite2 mmdb, which after #1523 live there; hub data files are recoverable with the gated `cscli hub upgrade`, the db is owned by the running engine) and the top-level `hub_cache/` (regenerable download cache). `restore` empties `DataDir` except those same excluded entries and copies the snapshot back, so it never touches `data/` or `hub_cache/`. Only the *top-level* `data/` and `hub_cache/` are excluded; a nested directory with the same name inside `config/` is still backed up. Result: a snapshot is essentially `config/` plus `bouncer_key` (small), x5 retained. -- `func (s *HubService) snapshot() (path string, err error)`: microsecond timestamp name, created `0o700` with exclusive `os.Mkdir`. -- `func (s *HubService) restore(path string) error`. -- `func PruneBackups(dataDir, kind string, keep int) (removed []string, err error)` where `kind` is `backup` or `filebackup`. Lists siblings matching `..*`, `Lstat`-checks each is a real directory (never follows or deletes symlinks or plain files), **parses the timestamp from the directory name** (both the legacy `20060102-150405` and the microsecond `20060102-150405.000000` layouts; names that do not parse are ignored, never deleted), sorts newest first, deletes everything past `keep`. `keep < 1` is clamped to 1 so the newest is always kept. Never touches `dataDir`. Single-removal errors are logged and aggregated, never failing the caller. -- **Prune runs after every attempt, successful or failed**, always preserving the newest `keep` snapshots. A failed apply's own snapshot is the newest, so it is always retained for manual recovery. - -**`HubService.Apply` new control flow (blocking item 2).** Hold `HubService.mu` and the handler-level lock (section 6). -1. Validate slug; load cache meta (non-fatal). -2. `snapshot()`; any error -> return failure, nothing changed. `BackupPath` is set only after success. The old "read the archive into memory before the backup because the cache lives in `DataDir`" workaround is **removed**: nothing is moved any more, and `hub_cache/` is excluded from snapshots and from restore, so the archive stays valid for the whole call. -3. If `cscli` is available: `cscli hub update` (non-fatal), `cscli hub install `. Success -> status `applied`, `UsedCSCLI=true`, reload, prune, return. -4. **A cscli failure still falls back to the cached-archive extract** (this keeps the offline/hub-unreachable behavior that exists today). Before extracting, `restore(snapshot)` so the extract starts from a clean pre-apply tree and any partial files `cscli` wrote are removed (this closes the partial-overlay risk of overlaying an archive on a half-installed state). -5. Extract `extractTarGz` onto `DataDir` (cache refresh from the hub if the cache is missing, as today). **Residual risk, documented:** extraction overlays archive files on top of the live tree rather than replacing it, so files in the archive replace same-named live files; mitigation is the snapshot. Any extract/refresh failure -> `restore(snapshot)` and return the error with `BackupPath`. -6. Success -> status `applied`, `UsedCSCLI=false`, reload, prune. -`backupExisting`, `rollback`, `emptyDir`, and `copyDir` (if now unused) are deleted. - -**`CrowdsecHandler.ImportConfig` (blocking item 6): converted to snapshot/restore semantics, never renaming `DataDir`.** Flow: handler lock -> `snapshot()` -> empty `DataDir` except the snapshot-excluded entries (so the live db, `data/`, `hub_cache/` survive the import) -> extract the uploaded archive, **skipping any archive entry that is a live db file (`isLiveDBFile`) or lies under the top-level `data/` or `hub_cache/`** so an upload can never overwrite engine-owned state -> on any failure `restore(snapshot)` and return the error -> prune. Net behavior difference: the live db and `data/` are no longer moved or lost by an import; everything else is replaced exactly as before. Existing `ImportConfig` tests are updated, plus the new tests listed in Commit 3. - -### 3.2 File endpoints hardening (#1514) - -Details of the validation defects and their fixes are intentionally not written here (see section 4). Public-safe design: - -- New file `backend/internal/api/handlers/crowdsec_files.go` with a single shared path-resolution helper used by `ReadFile`, `WriteFile` and `ListFiles`, so all three apply one policy ("harden file-endpoint path validation"). -- Write policy: bounded body size (1 MiB); allowed file types (Q3); YAML files must parse; protected files and trees (secrets, databases, caches, engine data) are never writable; `.yaml/.yml` parsing uses `gopkg.in/yaml.v3` if already a dependency (verify). -- **Symlink policy (blocking item 7):** the editor never writes through or replaces a symlink (HTTP 400), so symlinked hub items are read-only from the editor. Reads stay confined to `DataDir`, and symlinked hub items that resolve inside it stay readable. Test in Commit 4. -- **Two predicates, not one.** The viewer today lists and reads any file under `DataDir` (hub items, `config.yaml`, etc.), and filtering the list by the write predicate would hide files users can currently read. So: `isReadable(rel)` governs `ListFiles` and `ReadFile` (secrets and databases such as `bouncer_key`, `*.db*`, and the `hub_cache/` and `data/` trees are neither listed nor readable; everything else stays visible, including symlinked hub items), and the stricter `isWritable(rel)` governs `WriteFile`. A file that is readable but not writable (symlinked hub items, non-allowlisted types) stays viewable and its save returns the server's 400 message, which the existing `writeMutation` error path already shows; no frontend contract change. `ExportConfig` is unaffected. Commit 4 tests: `ReadFile` refuses `bouncer_key` and db files, reads a symlinked hub item that resolves inside `DataDir`, and refuses a symlink resolving outside it; `ListFiles` omits them but still lists a symlinked hub item and `config.yaml`; every file `WriteFile` accepts is also listed. -- `WriteFile` algorithm: lock -> validate -> if the target exists, copy just that file to `.filebackup./` -> atomic write (temp file in the same directory, `fsync`, `rename`, preserve mode, default `0o600`) -> `PruneBackups(kind=filebackup, 10)` -> 200 `{status:"written", backup}`. `DataDir` is never renamed. -- Error contract (JSON `{"error": ...}`): 400 invalid request / path / type / YAML / symlink, 413 too large, 500 write failure. -- No new endpoints, no schema changes, no frontend contract changes. - -### 3.3 Frontend (#1514) - -`frontend/src/pages/CrowdSecConfig.tsx`: delete `applyPresetLocally`, the `501` branch in `handleApplyPreset`, the `applied-locally` apply-info status, and anything left unused (`backupMutation` if only used there, imports, `presetPreview` write path). Non-2xx apply for hub presets is surfaced like curated: server error text, backup path if present, no write. The generic file editor (`writeMutation`) is unchanged but now shows server 400/413 messages. Update Vitest files: `CrowdSecConfig.test.tsx`, `.coverage.test.tsx`, `.spec.tsx`, `.curatedApply.test.tsx` (remove tests that assert the local fallback; add "501 for a hub preset shows an error and performs no file write"). - -### 3.4 Persistent CrowdSec data dir and hub data (#1523) - -`.docker/docker-entrypoint.sh` (next to the existing `db_path` redirect, ~L310-320): - -- `sed` `data_dir: /var/lib/crowdsec/data/` -> `data_dir: ${CS_DATA_DIR}/` in `$CS_CONFIG_DIR/config.yaml` (idempotent; works for both fresh and already-initialized volumes), with the same verify/echo pattern as the db redirect. `CS_DATA_DIR` is `/app/data/crowdsec/data` and is already `mkdir`ed and chowned. -- **Gated, never unconditional (blocking item 5).** After `install_hub_items.sh`, the entrypoint runs `cscli hub upgrade` **only if** at least one installed item's data files are missing. Detection: for each installed item (`cscli list -o json`), check that every file the item declares is present under `data_dir`; the exact JSON field for declared data files is confirmed by the implementer against `cscli inspect -o json` before coding (open implementation detail, verified in the Commit 6 gate). Fallback if the field is not exposed: treat an empty `data_dir` (ignoring `trace/` and the db) with at least one installed item as "missing". The call is bounded (`timeout 120s`), failure is a logged warning and never fatal, and it never uses `--force`. A normal restart with intact data files performs no upgrade, so items are never silently upgraded on a healthy start. Note `hub upgrade` upgrades every installed item; this is acceptable only in the recovery case it is gated on, and the log line must say so. -- It is skipped in the same condition as the rest of hub init (`CHARON_SECURITY_TESTS_ENABLED=false`). - -`configs/crowdsec/install_hub_items.sh`: change `install_if_missing` to decide on `cscli inspect -o json` reporting `"installed": true`, matching `ApplyCurated.verifyItem`. **Verified:** `jq` is not in the `charon:local` image, so use `grep -q '"installed": *true'` (not jq). **Verified:** `inspect -o json` still prints full JSON and exits 0 when an item is installed but its data file is missing, so the JSON signal is reliable in exactly the recreate scenario (the plain-text `inspect` exit code was not). Keep the "failed to install" warnings non-fatal. - -`Dockerfile`: keep creating `/var/lib/crowdsec/data` (a stale `data_dir` on an un-migrated config must still exist); no other change. -`.docker/compose/docker-compose.playwright-ci.yml:137` currently mounts a volume at `/var/lib/crowdsec/data`; repoint or remove it so E2E exercises the same persistent path as production. `docs/security.md` hardened-deployment text that tmpfs-mounts `/var/lib/crowdsec` stays valid (the db and data now live under `/app/data/crowdsec`) but its description must be updated. -`scripts/diagnose-crowdsec.sh` default `DATA_DIR` updated to the new location. - -Optional passive health signal (Q4 resolved: log line only, no UI): log a startup summary line listing installed-but-missing data files. No banner or badge (Passive UI, no nudges). - -### 3.5 Version injection (#1524) - -`Dockerfile` `crowdsec-inline` stage (both `xx-go build` commands, ~L915 and ~L923): replace the ldflags with `-s -w -X github.com/crowdsecurity/go-cs-lib/version.Version=v${CROWDSEC_VERSION} -X github.com/crowdsecurity/go-cs-lib/version.BuildDate=`. Do not set `Tag` (empty `Tag` leaves `version.String()` equal to `Version`). Do not add `BuildDate` from the wall clock: that would break the content-addressed, reproducible toolchain key; prefer the release date derived from the pinned tag or omit it. - -App-side assertion (second commit, after the toolchain bot has pushed the new TAG/DIGEST to the PR branch): in the existing N5 `RUN` block (~L1054) replace the wrong comment and add `grep -q "^version: v${CROWDSEC_VERSION}\$"` against `cscli version`. The final stage must redeclare `ARG CROWDSEC_VERSION` (currently declared only globally at L41 and in the caddy/crowdsec-inline stages; a global ARG is not visible inside a later stage without redeclaring). The build fails if the symbol ever silently no-ops again. - -Hub branch (decision D1, recommended): set `cscli.hub_branch` explicitly in the entrypoint-managed `config.yaml` so behavior is deterministic and does not depend on `version.crowdsec.net` reachability or on `hub-cdn` having a `v1.8.1` branch. Verified in upstream v1.8.1 `chooseBranch`: a non-empty `cscli.hub_branch` is returned immediately, before the `version.crowdsec.net/latest` lookup, so setting it removes both the network dependency and the CDN-404 risk. The online/offline test result is recorded in the PR description (Commit 8 gate). - -### 3.6 Handler split (#1517) - -Move to `backend/internal/api/handlers/crowdsec_preset_handler.go` (same package, no behavior change, no export changes): `ListPresets`, `PullPreset`, `ApplyPreset`, `applyCuratedPreset`, `curatedPresetPreview`, `recordPresetEvent`, `applyFailureBody`, `respondApplySuccess`, `GetCachedPreset`, and preset-only helpers. Move the matching unit tests into `crowdsec_preset_handler_test.go` files where they are clearly preset-only (leave shared fixtures where they are). `RegisterRoutes` stays in `crowdsec_handler.go`. The new file must be < 700 lines. - -## 4. Priorities & Ordering (CLAUDE.md "Findings Triage") - -None of the seven issues is user-submitted (all filed by the maintainer from PR #1513). Rules applied: bug fixes before new implementation (there is no `feat` here); critical/high ships as its own PR first; medium/low is scheduled after. - -Severity after root-causing (some differ from the issue labels): - -| Rank | Issue | Severity | Why | -|------|-------|----------|-----| -| 1 | #1523 data files missing after recreate | **medium** (issue says medium) | Silent degraded detection and no GeoIP on every recreated container; install guard defect makes fresh volumes suspect. Highest user impact. | -| 2 | #1514 + `WriteFile` destructive rename | **medium** (raised from low-medium) | Admin action can wipe the live CrowdSec tree; weak input validation. Security-sensitive (below). | -| 3 | #1515 `HubService.Apply` rename | **low-medium, confirmed** | Same defect class and same helper as #1514/#1516; fixed together. | -| 4 | #1524 empty version / hub branch | **low-medium** | Fix is easy but changes runtime hub behavior (offline failure, CDN 404 risk); needs the D1 decision and the toolchain bot. Scheduled after the fixes it must not destabilize. | -| 5 | #1516 backup accumulation | low | Piggybacks on the shared helper. | -| 6 | #1518 tolerant E2E | low | Test hardening; first in commit order (as `fixme` specs) and enabled last. | -| 7 | #1517 handler split | low | `refactor`, no behavior change; sequenced first in the *commit* order (mechanical, makes later diffs reviewable) but lowest in *priority*. | - -No finding is critical or high, so none must ship as its own PR ahead of this work. Priority above governs attention and what to cut first under contingency; commit order in section 5 is dependency-driven. - -**Security handling (#1514).** It is admin-only and already public as an issue. Assessment: low severity (authenticated admin only; the endpoint grants nothing beyond what an admin already has). **Q2 resolved by the user: #1514 is already public, so no private advisory is opened unless root-causing shows real impact beyond what an admin already has; it does not, so the default is NO advisory.** Handling: -- The existing public issue #1514 stays as is. Keep PR text, commit bodies and code comments free of exploit specifics anyway (no value in handing detail to un-upgraded installs). -- Exactly one commit in the PR carries the `(security)` scope, and its subject must stay in the vague category-only form: `fix(security): harden CrowdSec configuration file handling`. Keep the subject category-only, and Commit 4's body and the PR description stay equally vague (for example "tighten validation in the CrowdSec configuration file endpoints"). Every commit subject in a feature PR merge-commit appears verbatim in the public changelog, so the other commits use plain scopes (`fix:`, `refactor:`, `test:`, `docs:`, `chore:`) and describe user-visible behavior only. The destructive-rename data-loss fix is a plain `fix:` (it is a reliability bug, not an exploit). - -**Out-of-scope findings discovered while planning:** none left open. Items found beyond the issue text are folded in (install guard, wrong N5 comment, `ImportConfig` conversion, playwright-ci compose mount). If implementation finds anything else, file it per the triage rule at that time. - -## 5. Branch/PR Decision and Commit Slicing Strategy - -### 5.1 Branch/PR decision: ONE branch, ONE PR - -Single branch `fix/crowdsec-preset-apply-hardening` -> PR into `development`, titled e.g. `fix: harden CrowdSec preset apply and keep hub data across container recreation`. - -Why one PR (cohesion): all seven issues are one defect family, "the CrowdSec persistent tree is mutated unsafely and its hub/data state is not durable", and they share code: #1514, #1515 and #1516 all consume the same new `backup.go` helper (a single fix for rename-based backups, the file endpoint, and pruning); #1517 is the file those changes land in; #1518 is the test net for #1514/#1515; #1523 and #1524 are the same persistent-dir/hub-state story at the container layer and are both verified by the same rebuilt-container gate. Splitting would leave the helper without its callers, the E2E net without the behavior, or a half-fixed persistence story. - -Considered splits and why rejected: -- Docker/entrypoint (#1523, #1524) vs Go/frontend: they can build independently, but they do not conflict, share a single verification environment (rebuilt `charon:local`), and splitting would double the merge-soak the maintainer applies before `main`. The #1524 toolchain bot is explicitly designed to push the pin bump onto the PR head branch, so it works inside this PR. -- `(security)` fix alone: not required; severity is low (authenticated admin), and the rule "own PR" applies to critical/high only. - -**Contingency without splitting:** if Commits 7-8 (#1524) cannot be validated (hub-branch decision unresolved, toolchain build blocked), drop those commits from the PR and leave #1524 open rather than opening a second PR for it; the remaining six issues still form a complete, coherent PR. Rollback of the PR as a whole is a revert of the merge commit; a revert leaves a valid `data_dir` in `config.yaml` (the old image still creates the default directory), so nothing breaks. - -### 5.2 Ordered commits (all on the one branch) - -Every command run by implementers is foreground/blocking per CLAUDE.md. Each commit must build and pass its gate; the PR as a whole passes the full Definition of Done (section 8). - -**Commit 1 - `test: add deterministic E2E specs for CrowdSec preset and file flows (fixme)`** (#1518, step 1 of the suggested sequence) -- Files: new `tests/security/crowdsec-hub-preset-apply.spec.ts`, new `tests/security/crowdsec-file-editor.spec.ts`. -- Content, all stubbed with `page.route` like `crowdsec-preset-apply.spec.ts`, marked `test.fixme` until their feature commits: hub preset apply success shows success only when body `status==='applied'`; hub preset apply 500/503/504 shows an error and **zero** `POST /admin/crowdsec/file` calls; hub preset apply 501 shows an error and zero writes; file editor save success posts exactly one write for exactly the selected path; file editor shows the server message for 400 (disallowed type / invalid YAML) and 413. -- Depends on: none. Gate: `npx playwright test tests/security/crowdsec-hub-preset-apply.spec.ts tests/security/crowdsec-file-editor.spec.ts --project=security-tests` runs and reports the tests as fixme/skipped with no failures; lint/type-check clean. - -**Commit 2 - `refactor: move CrowdSec preset handlers into their own file`** (#1517) -- Files: new `backend/internal/api/handlers/crowdsec_preset_handler.go`; edit `crowdsec_handler.go`; move/split preset-only tests into `crowdsec_preset_handler_test.go`. -- Pure move: no signature, route, or behavior changes. Depends on: none. -- Gate: `cd backend && go build ./... && go test ./internal/api/handlers/... -race` all green with the same test count; `git diff --stat -M` shows moves, not rewrites; `make lint-fast`. - -**Commit 3 - `fix: keep CrowdSec backups bounded and copy-based for preset apply`** (#1515, #1516) -- Files: new `backend/internal/crowdsec/backup.go` + `backup_test.go`; edit `hub_sync.go` (`Apply` per the flow in 3.1; remove `backupExisting`/`rollback`/`emptyDir`/dead `copyDir`), `curated_apply.go` (move helpers, add prune call), `crowdsec_handler.go` `ImportConfig` (snapshot/restore conversion, handler lock), `hub_pull_apply_test.go`, `device_busy_test.go` (the rename/EBUSY tests are obsolete and are replaced by copy-path tests). -- Depends on: Commit 2 (preset code already in its own file; `ImportConfig` stays in the main handler). -- Unit tests (new, all with `t.TempDir()`): snapshot excludes live db files, top-level `data/` and `hub_cache/`, preserves symlinks, and still copies a nested directory named `data` under `config/`; **size test:** a large file under `data/` and under `hub_cache/` is not copied while a small `config/` file is (assert snapshot size); restore leaves live db, `data/` and `hub_cache/` untouched; `PruneBackups` orders by the timestamp parsed from the name (not mtime; touching an old dir does not save it), handles both name layouts, ignores unparsable names, non-matching names, symlinks and plain files, never deletes `dataDir`, clamps `keep<1` to 1, always keeps the newest, handles a missing parent, and `backup` and `filebackup` namespaces never evict each other; `HubService.Apply` flows: (a) cscli success leaves `DataDir` in place (inode unchanged) and prunes; (b) **cscli failure -> snapshot restored -> cached-archive extract succeeds -> `applied`, `UsedCSCLI=false`, no cscli leftovers**; (c) cscli failure and extract failure -> snapshot restored, error returned with `BackupPath`; (d) cscli failure with cache miss and hub refresh failure -> restored; (e) prune also runs after a failed apply and keeps the failed apply's snapshot; `ImportConfig`: success replaces config but preserves live db/`data/`/`hub_cache/` and never renames `DataDir`; extract failure restores the previous config; prune runs; concurrent `ImportConfig` + apply serialize on the handler lock; an uploaded archive containing `crowdsec.db`, `data/x` and `hub_cache/y` entries extracts none of them. -- Gate: `go test ./internal/crowdsec/... ./internal/api/handlers/... -race`; package coverage not below the current level; **rebuilt-container verification of #1515**: pull + apply one hub preset against a running agent, confirm `/app/data/crowdsec` is never renamed (inode unchanged), the apply result is correct, and at most 5 full-snapshot dirs remain after 7 applies (this also settles the "not confirmed" item in 2.2). - -**Commit 4 - `fix(security): harden CrowdSec configuration file handling`** (#1514 backend; subject intentionally vague, see section 4) -- Files: new `backend/internal/api/handlers/crowdsec_files.go` + `crowdsec_files_test.go`; edit `crowdsec_handler.go` (`ListFiles`, `ReadFile`, `WriteFile` delegate to the shared helper); update existing `TestCrowdsec_WriteFile_*` tests in `crowdsec_handler_coverage_test.go`. -- Depends on: Commit 3 (per-file backups use the `filebackup` namespace and handler lock). Commit body and PR text stay vague (section 4). -- Unit tests (categories only here): path validation rejection cases, symlink policy (write through or replacement of a symlink is refused, a regular file under `DataDir` is written), type/content/size validation (disallowed type, invalid YAML, oversize -> 413, protected files), atomic write leaves no temp file on failure, `DataDir` and unrelated files untouched after a write (regression test for the destructive rename), `filebackup` contains only the previous version of the target and is capped independently of full snapshots, the read/list predicate and the stricter write predicate behave as in 3.2, `ReadFile` shares the validation. -- Gate: `go test ./internal/api/handlers/... -race`, `make lint-fast`; GORM scan not required (no models); the Playwright file-editor spec enabled in Commit 9 depends on this behavior. - -**Commit 5 - `fix: remove the dead local-write fallback from CrowdSec preset apply`** (#1514 frontend) -- Files: `frontend/src/pages/CrowdSecConfig.tsx`, its four test files under `frontend/src/pages/__tests__/`, `frontend/src/api/crowdsec.ts` only if a now-unused export remains. -- Depends on: none technically (the backend never returns 501); ordered after Commit 4 so the whole #1514 story is reviewable in sequence. -- Gate: `cd frontend && npm run type-check && npx vitest run src/pages/__tests__/CrowdSecConfig*` green; no unused-export/lint findings; `npm run build`. - -**Commit 6 - `fix: persist CrowdSec hub data files across container recreation`** (#1523) -- Files: `.docker/docker-entrypoint.sh`, `configs/crowdsec/install_hub_items.sh`, `.docker/compose/docker-compose.playwright-ci.yml`, `scripts/diagnose-crowdsec.sh`, `docs/security.md` (wording), new integration test `scripts/crowdsec_data_persistence_test.sh` (follows the pattern of `scripts/crowdsec_startup_test.sh`). -- Depends on: none (independent of the Go commits; ordered after so a failing container gate does not block the Go work from being reviewed). -- Tests: shell-level test (start container with a named volume, wait for hub init, assert data files and `GeoLite2-City.mmdb` exist under `/app/data/crowdsec/data`, `docker rm` and recreate with the same volume, assert files are present and `crowdsec.log` has no `unable to init data for file` / `unable to initialize GeoIP` lines after enabling CrowdSec; also run it on a volume initialized by the old image to prove the sed migration). Add `shellcheck` cleanliness for changed scripts. -- Gate: rebuild `charon:local` (skill `docker-rebuild-e2e` / `.github/skills/scripts/skill-runner.sh docker-rebuild-e2e`), run `scripts/crowdsec_data_persistence_test.sh` and `scripts/crowdsec_startup_test.sh`; the fresh-volume case must show the install guard now really installing. -- Additional tests: the gate must prove `hub upgrade` is **not** run on a restart with intact data files (log assertion) and **is** run (once, bounded) when data files are missing; `shellcheck` is a required gate for every changed or new shell script (`.docker/docker-entrypoint.sh`, `install_hub_items.sh`, the new test script). -- CI wiring and ignore files: wire `scripts/crowdsec_data_persistence_test.sh` into the existing CrowdSec integration job via a skill wrapper like `.github/skills/integration-test-crowdsec-startup-scripts` and the `integration-tests.yml` workflow (rather than a new workflow). Check `.dockerignore` (it already excludes `scripts/tests/` and the toolchain scripts; the new script must be excluded from the image the same way if it lives under `scripts/`), `.gitignore`, and `.codecov.yml` (shell scripts are not coverage-measured; confirm no new exclusion needed). -- Snapshot interaction (item 4 sequencing): Commit 3's snapshot already excludes top-level `data/` and `hub_cache/`, so Commit 6's relocation of `data_dir` into `/app/data/crowdsec/data` does not enlarge any backup; the Commit 3 size test is the regression guard. - -**Commit 7 - `fix: report the real CrowdSec version in the bundled binaries`** (#1524, step A: ldflags) -- Files: `Dockerfile` (`crowdsec-inline`, both `xx-go build` lines). Bot-authored follow-up commit on the branch updates `CHARON_TOOLCHAIN_TAG` / `CHARON_TOOLCHAIN_DIGEST` (do not hand-edit those lines; the workflow owns them). -- Depends on: none. **Wait here until the toolchain-image workflow has built and pushed the pin bump to the PR branch** (this is the only asynchronous step in the plan). -- Gate: `toolchain-image.yml` green on the PR (build + Trivy gate), `verify-toolchain-pin` green, and a local `make build-offline`-style build (`--build-arg CROWDSEC_BUILDER_SRC=crowdsec-inline`) shows `cscli version` -> `version: v1.8.1`. -- Expected CI state: after this commit `verify-toolchain-pin` (and any check that compares TAG/DIGEST to the recipe key) is **red until the bot pushes the new pin**. Implementers must `git pull` the bot's commit before starting Commit 8 and must not hand-edit the two ARG lines. - -**Commit 8 - `fix: fail the image build if CrowdSec reports no version and make the hub branch deterministic`** (#1524, step B) -- Files: `Dockerfile` (N5 block: correct the comment, add the version assertion, add `ARG CROWDSEC_VERSION` to the final stage), `.docker/docker-entrypoint.sh` (hub branch setting per D1). -- Depends on: Commit 7 and the bot pin bump. -- Tests / build-time assertion: the Dockerfile `RUN` fails with a clear message if `cscli version` does not print `version: v${CROWDSEC_VERSION}`. Prove it fails by temporarily building with a mismatched `--build-arg CROWDSEC_VERSION` against the pinned toolchain (expected hard failure), then with the correct value (pass). Runtime check in the rebuilt container: `cscli version` truthful; `cscli hub update` succeeds online; with the network blocked (`--network none`) hub commands behave as the D1 decision specifies (no hard failure when `hub_branch` is set). -- Gate: image builds; `scripts/crowdsec_startup_test.sh` green; both stderr lines `Crowdsec version is not set` and `unable to retrieve latest crowdsec version` absent from `docker logs` in the online and offline runs. -- Record in the PR description: `cscli.hub_branch` is honoured by v1.8.1 and skips the `version.crowdsec.net` lookup (source-verified in `chooseBranch`), plus the measured online and offline (`--network none`) results. - -**Commit 9 - `test: enable CrowdSec preset and file-editor E2E specs and drop tolerant ones`** (#1518, final step) -- Files: the two specs from Commit 1 (remove `fixme`), `tests/security/crowdsec-config.spec.ts` (delete the tolerant "Preset Management" assertions that are now covered by deterministic specs, or convert each remaining case to a deterministic stub; no `isVisible().catch(() => false)` guards, no "feature may not be implemented" annotations remain in `tests/security/crowdsec-*.spec.ts`). -- Depends on: Commits 4 and 5. -- Gate: `npx playwright test tests/security/crowdsec-hub-preset-apply.spec.ts tests/security/crowdsec-file-editor.spec.ts tests/security/crowdsec-preset-apply.spec.ts tests/security/crowdsec-config.spec.ts --project=security-tests` green. Mutation check: temporarily re-add the local-write fallback (or revert the success-status check) and confirm at least one of the new specs fails, then undo. Full-suite and cross-browser runs are CI-only. - -**Commit 10 - `docs: document CrowdSec preset apply safety and data persistence`** -- Files: `ARCHITECTURE.md`, `docs/features/crowdsec.md`, `docs/troubleshooting/` entry if appropriate (see 7). -- Depends on: all. Gate: markdown lint/pre-commit; `docs-site` manifest unchanged (new content goes into already-manifested `docs/features/` / `docs/troubleshooting/`). - -## 6. Decisions and Edge Cases +## 9. Acceptance Criteria -- **D1 hub branch (Q1 resolved: option A).** Options: (A, recommended) set `cscli.hub_branch: master` explicitly: identical to today's effective behavior while 1.8.1 is the latest, no dependency on `version.crowdsec.net`, no 404 risk, truthful `cscli version`. (B) Leave auto-selection with the fixed version: matches upstream's intent but can hard-fail hub commands offline and break hub updates when 1.8.1 stops being latest and the CDN has no `v1.8.1` branch (404 today). (C) Pin to the version branch: broken today (404). Commit 8 gate must run the empirical online/offline test either way. -- **Locking (one mechanism).** A single handler-level `sync.Mutex` on `CrowdsecHandler` (e.g. `dataMu`) is held for the whole of `ApplyPreset` (both curated and hub paths), `ImportConfig` and `WriteFile`. `HubService.mu` remains as an inner guard for direct service callers; lock order is always handler mutex first, then `HubService.mu`, so no deadlock. No other lock is introduced. -- Edge cases to cover in tests: snapshot dir name collisions (microsecond timestamp plus exclusive `os.Mkdir`), parent dir not writable, `DataDir` being a mount point (the copy path must work where rename used to fall back), symlinks inside `DataDir` never followed by restore/prune/write, concurrent apply/import/file write serialized by the handler mutex (held for the full `ApplyTimeout`, so file saves block during a long apply; `ReadFile` and `ListFiles` stay lock-free), CrowdSec stopped, existing volumes whose `config.yaml` still points at `/var/lib/crowdsec/data` (sed migration, then the gated `hub upgrade` repopulates), tmpfs `/var/lib/crowdsec` deployments (no longer relevant for data after migration). -- Reload behavior from #1513 (SIGHUP via `reloadCrowdSec`) is unchanged and should also be triggered, best-effort, after the entrypoint-independent hub changes that happen at runtime only (not applicable to startup). - -## 7. Docs Impact - -- `ARCHITECTURE.md`: update the Layer 2 CrowdSec section (~L868-890): CrowdSec `data_dir` and LAPI db live under `/app/data/crowdsec/data` (persistent), hub data files are re-synced when missing via a gated `cscli hub upgrade`, preset applies and config edits are copy-based/atomic with bounded backup retention, `cscli version` reports the pinned version and the hub branch is explicit. The file layout table around L288-294 gets the data subdirectory note. -- `docs/features/crowdsec.md`: extend "Curated Presets" (L57-59) / Hub Presets with the backup retention (last N kept), that hub presets are also applied on the server with a rollback, and that the config editor only accepts specific file types with validation. Add a short troubleshooting entry for the `unable to init data for file` log line (what it meant, that it self-heals on restart after this release). Keep it novice-friendly. -- `docs/features.md`: no new capability; no change expected (confirm during Commit 10). -- `docs/security.md`: tmpfs `/var/lib/crowdsec` guidance wording (Commit 6). -- Changelog is generated from commit subjects; subjects in section 5.2 are written for that. +Part A: credentials files absent from editor list and unreadable/unwritable with the correct messages; no absolute path in any CrowdSec API response, including error text, and UI still names the backup and where it is; `..`-prefixed legitimate names accepted, parent components rejected; `WithAllowedDomains` and `hubAllowLoopback` gone and all three safeclient test references handled; `validateHubURL` accepts only https allowlisted hosts; DoD passes. Part B: the six files pass the Bz grep gate; every converted test fails when the feature it names is removed (mutation spot-check per file); findings handled per section 4; targeted Playwright green. -## 8. Test Plan Summary and Definition of Done +## 10. Rollback and Contingency -- **Backend unit:** new tests in Commits 3 and 4 (list above). Coverage: package and patch coverage must not drop; overall >= 85% (`scripts/go-test-coverage.sh`); `bash scripts/local-patch-report.sh` produces `test-results/local-patch-report.md/.json`. -- **Frontend unit:** updated Vitest in Commit 5; `scripts/frontend-test-coverage.sh` >= 85%; `npm run type-check`; `npm run build`. -- **Playwright (#1518):** deterministic stubbed specs per Commit 1/9, role-based locators, no `waitForTimeout`, no visibility-guard skips. Locally only the targeted spec files with `--project=security-tests` (security specs are excluded from the browser projects; other specs use `--project=firefox`); CI runs the full matrix. -- **Build-time assertion (#1524):** Dockerfile `RUN` check in Commit 8 with the negative (mismatched version) and positive proof recorded in the PR description. -- **Container integration:** `scripts/crowdsec_data_persistence_test.sh` and `scripts/crowdsec_startup_test.sh` (Commit 6/8). -- **Security scans:** this is a `fix:` with no new endpoints; CodeQL/Trivy are deferred to CI per CLAUDE.md, except that Commit 7's toolchain Trivy gate runs in CI by design. GORM scan not required (no model changes); run it only if a model is touched. -- **Full DoD checklist before merge (in order):** targeted Playwright (firefox) -> patch coverage preflight -> `lefthook run pre-commit` -> staticcheck (`make lint-fast`) -> backend/frontend coverage -> type-check -> `go build ./...` and `npm run build` -> clean-up (no debug prints, dead code, unused imports). Any failing test, even pre-existing, is fixed in this PR. -- **Pipeline after the plan:** Supervisor plan review -> user approval -> implement commit by commit (`backend-dev` for 2-4, `frontend-dev` for 5, `devops` for 6-8, `playwright-dev` for 1 and 9, `docs-writer` for 10) -> Supervisor implementation review -> `qa-security` last -> `docs-writer` pass. -- **Dispatch instructions (every subagent prompt must include these, verbatim in spirit):** run all tests, builds, coverage scripts, Playwright, linters, Docker builds and `lefthook` as **foreground, blocking** calls with the maximum timeout; never use `run_in_background`, `&`, `nohup` or end the turn waiting for a notification; if a call auto-backgrounds, immediately re-attach and poll until a real result; no worktrees; stay inside the repo (no live hosts); do not commit unless the orchestrator says so; Commit 4's text (body, comments, PR text) must stay vague per section 4; and the Commit 7 to Commit 8 hand-off requires pulling the toolchain bot's commit first. +Commits are independent except A2 after A1 (shared fixtures) and A5 after A4. If A5 proves too large, ship A1-A4 and keep A5 as a follow-up rather than a half-migrated seam. If B surfaces a high/critical bug, split it out per section 4. -## 9. Acceptance Criteria +## 11. Open Questions (need the user's decision) -1. Applying a hub preset or editing a CrowdSec file never renames, empties or removes `/app/data/crowdsec`; the live tree is intact after success and after any failure (tests prove it). -2. Full-tree snapshot dirs (`.backup.*`) are bounded to 5 and single-file edit backups (`.filebackup.*`) to 10, independently, after any apply/import/write attempt; snapshots exclude `data/` and `hub_cache/` so they stay small. -3. `POST /admin/crowdsec/file` validates paths, file types, YAML and size, refuses symlink writes, never renames `DataDir`, and `ReadFile`/`ListFiles` share the same validation; the frontend has no local-write fallback. -4. After `docker rm` + recreate with the same volume, the 12 listed hub data files and `GeoLite2-City.mmdb` exist and `crowdsec.log` has no `unable to init data` / GeoIP lines; fresh volumes actually install the items in `install_hub_items.sh`. -5. `cscli version` and `crowdsec -version` print `v1.8.1`; the image build fails if they do not; hub commands work online and offline per D1. -6. `crowdsec_handler.go` no longer contains the preset handlers; no behavior change from the move. -7. No tolerant "may not be implemented" Playwright tests remain for CrowdSec presets, and the mutation check in Commit 9 shows the new specs fail on regression. -8. Only one commit uses the `(security)` scope and its subject is the vague form above. - -## 10. Resolved Questions (all decided by the user) - -- Q1 (D1): RESOLVED: explicit `cscli.hub_branch: master`. -- Q2: RESOLVED by the user: no private advisory (issue is public; severity low, admin-only). Revisit only if implementation finds impact beyond admin privileges. -- Q3: RESOLVED: write allowlist, protected-file list, 1 MiB cap and the refuse-symlinks rule as specced in 3.2, with a separate looser read/list predicate. -- Q4: RESOLVED: startup log line only (no diagnostics field, no UI). -- Q5: RESOLVED: constants (5 full snapshots, 10 file backups), not configurable. +1. **F5**: approve hiding the two credentials files from editor list and read (recommended), or masked values, or leave as is? +2. **Admin export**: `ExportConfig` currently includes credentials files and `bouncer_key`. Exclude secrets from the export (behavior change: an export-then-import round trip would no longer carry credentials; machine re-registers) or leave as is and document the limitation? Recommendation: leave for this batch, document it, and file a separate issue for the export decision. +3. **Slicing**: default is one PR with commit slicing; approve the two-PR exception (A then B)? +4. **F3 shape**: base name of the backup (recommended) plus UI location hint, versus a random id with a server-side map (not recommended). +5. **`(security)` scope**: A1 as `fix(security): harden CrowdSec editor access` (recommended). A2 as plain `fix:` (recommended; admin-only low information exposure, and every `(security)` subject goes to the public changelog) or `fix(security): harden CrowdSec API responses`? +6. **#1479**: remove (recommended) versus enforce in the dialer. +7. **Include the acquisition-update `backup` field and the non-listed leak sites** (rollback text, extract/export errors) in F3 (recommended; the acceptance criterion requires it). +8. **PR B stop rule**: N=6 real bugs then re-plan with you (recommended)? From 4239f848e08faa4322f6e75a4803bc419ff03e1b Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Fri, 9 Oct 2026 20:53:03 +0000 Subject: [PATCH 02/22] test(e2e): add fixme specs for editor secrets and response paths --- tests/security/crowdsec-file-editor.spec.ts | 57 ++++++++++++++++++- .../crowdsec-hub-preset-apply.spec.ts | 2 +- tests/security/crowdsec-preset-apply.spec.ts | 2 +- 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/tests/security/crowdsec-file-editor.spec.ts b/tests/security/crowdsec-file-editor.spec.ts index 8afa3da89..def21c549 100644 --- a/tests/security/crowdsec-file-editor.spec.ts +++ b/tests/security/crowdsec-file-editor.spec.ts @@ -86,7 +86,7 @@ test.describe('CrowdSec File Editor @security', () => { test('should post exactly one write for exactly the selected path on save', async ({ page }) => { const stub = await stubEditor(page, { status: 200, - json: { status: 'written', backup: '/app/data/crowdsec.filebackup.20260101-000000.000000' }, + json: { status: 'written', backup: 'crowdsec.filebackup.20260101-000000.000000' }, }); const newContent = 'common:\n log_level: debug\n'; @@ -141,4 +141,59 @@ test.describe('CrowdSec File Editor @security', () => { await expect(page.getByText(/file saved/i)).toHaveCount(0); }); }); + + // Enabled by commit A6 once the A1 (credentials hidden) and A2 (name-only backups) backend changes land. + test.describe('credentials files and response paths (live API)', () => { + const CREDENTIAL_FILES = ['local_api_credentials.yaml', 'online_api_credentials.yaml']; + const ABSOLUTE_PATH = /(^|["'\s(])\/(app|data|etc|tmp|var|home)\//; + + test.fixme('should omit both credentials files from the file list', async ({ page }) => { + const response = await page.request.get('/api/v1/admin/crowdsec/files'); + expect(response.ok()).toBe(true); + const body = (await response.json()) as { files: string[] }; + for (const name of CREDENTIAL_FILES) { + expect(body.files.map((f) => f.toLowerCase())).not.toContain(name); + } + }); + + for (const name of CREDENTIAL_FILES) { + test.fixme(`should reject reading ${name} with invalid path`, async ({ page }) => { + const response = await page.request.get(`/api/v1/admin/crowdsec/file?path=${encodeURIComponent(name)}`); + expect(response.status()).toBe(400); + expect(await response.json()).toMatchObject({ error: 'invalid path' }); + }); + + test.fixme(`should reject writing ${name} with file cannot be edited`, async ({ page }) => { + const response = await page.request.post('/api/v1/admin/crowdsec/file', { + data: { path: name, content: 'url: http://127.0.0.1:8080\n' }, + }); + expect(response.status()).toBe(400); + expect(await response.json()).toMatchObject({ error: 'file cannot be edited' }); + }); + } + + test.fixme('should not leak an absolute path in rejection bodies', async ({ page }) => { + const read = await page.request.get(`/api/v1/admin/crowdsec/file?path=${encodeURIComponent(CREDENTIAL_FILES[0])}`); + const write = await page.request.post('/api/v1/admin/crowdsec/file', { + data: { path: CREDENTIAL_FILES[1], content: 'x: y\n' }, + }); + expect(await read.text()).not.toMatch(ABSOLUTE_PATH); + expect(await write.text()).not.toMatch(ABSOLUTE_PATH); + }); + + test.fixme('should return only a backup name, never an absolute path, on a successful write', async ({ page }) => { + const read = await page.request.get(`/api/v1/admin/crowdsec/file?path=${encodeURIComponent(SELECTED_PATH)}`); + expect(read.ok()).toBe(true); + const { content } = (await read.json()) as { content: string }; + + const write = await page.request.post('/api/v1/admin/crowdsec/file', { + data: { path: SELECTED_PATH, content }, + }); + expect(write.ok()).toBe(true); + const body = (await write.json()) as { backup?: string }; + expect(body.backup).toBeTruthy(); + expect(body.backup).not.toContain('/'); + expect(JSON.stringify(body)).not.toMatch(ABSOLUTE_PATH); + }); + }); }); diff --git a/tests/security/crowdsec-hub-preset-apply.spec.ts b/tests/security/crowdsec-hub-preset-apply.spec.ts index a4b029832..cfd613c93 100644 --- a/tests/security/crowdsec-hub-preset-apply.spec.ts +++ b/tests/security/crowdsec-hub-preset-apply.spec.ts @@ -107,7 +107,7 @@ test.describe('CrowdSec Hub Preset Apply @security', () => { json: { status: 'applied', used_cscli: true, - backup: '/app/data/crowdsec.backup.20260101-000000.000000', + backup: 'crowdsec.backup.20260101-000000.000000', reload_hint: true, cache_key: CACHE_KEY, }, diff --git a/tests/security/crowdsec-preset-apply.spec.ts b/tests/security/crowdsec-preset-apply.spec.ts index dcc2eccad..0f0586770 100644 --- a/tests/security/crowdsec-preset-apply.spec.ts +++ b/tests/security/crowdsec-preset-apply.spec.ts @@ -60,7 +60,7 @@ test.describe('CrowdSec Curated Preset Apply @security', () => { json: { status: 'applied', used_cscli: true, - backup: '/app/data/backups/crowdsec-preset-test.tar.gz', + backup: 'crowdsec.backup.20260101-000000.000000', reload_hint: true, }, }); From b6dd5272d586f69653599fbe5cdd0f09d1cabb59 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Fri, 9 Oct 2026 20:58:26 +0000 Subject: [PATCH 03/22] fix(security): harden CrowdSec editor access Tighten which generated files the CrowdSec configuration editor will list, open and save, and add tests covering the new behavior. --- .../internal/api/handlers/crowdsec_files.go | 13 ++- .../api/handlers/crowdsec_files_test.go | 92 +++++++++++++++---- 2 files changed, 79 insertions(+), 26 deletions(-) diff --git a/backend/internal/api/handlers/crowdsec_files.go b/backend/internal/api/handlers/crowdsec_files.go index e2bdf3f68..44754622a 100644 --- a/backend/internal/api/handlers/crowdsec_files.go +++ b/backend/internal/api/handlers/crowdsec_files.go @@ -39,8 +39,10 @@ var ( ".yaml": {}, ".yml": {}, ".json": {}, ".txt": {}, ".conf": {}, } - // protectedWriteNames are credential files the editor never overwrites. - protectedWriteNames = map[string]struct{}{ + // hiddenSecretNames are lowercase base names of secret files that are neither listed nor readable nor + // writable through the editor. + hiddenSecretNames = map[string]struct{}{ + "bouncer_key": {}, "local_api_credentials.yaml": {}, "online_api_credentials.yaml": {}, } @@ -75,14 +77,14 @@ func cleanRelPath(raw string) (string, *fileError) { } // isReadable reports whether rel may be listed and read: engine-owned state (databases, the data and -// hub_cache trees) and secrets are hidden, everything else stays visible. +// hub_cache trees) and secrets (see hiddenSecretNames) are hidden, everything else stays visible. func isReadable(rel string) bool { slash := filepath.ToSlash(rel) if slash == "" || slash == "." || crowdsec.IsEngineOwnedPath(slash) { return false } base := strings.ToLower(path.Base(slash)) - if base == "bouncer_key" { + if _, hidden := hiddenSecretNames[base]; hidden { return false } if matched, _ := path.Match("*.db*", base); matched { @@ -102,9 +104,6 @@ func isWritable(rel string) bool { if _, ok := writableExtensions[strings.ToLower(path.Ext(base))]; !ok { return false } - if _, protected := protectedWriteNames[base]; protected { - return false - } if strings.HasPrefix(base, writeTempPrefix) { return false } diff --git a/backend/internal/api/handlers/crowdsec_files_test.go b/backend/internal/api/handlers/crowdsec_files_test.go index a1d0504bb..52b719638 100644 --- a/backend/internal/api/handlers/crowdsec_files_test.go +++ b/backend/internal/api/handlers/crowdsec_files_test.go @@ -92,25 +92,31 @@ func errorOf(t *testing.T, w *httptest.ResponseRecorder) string { func TestCrowdsecFiles_Predicates(t *testing.T) { t.Parallel() readable := map[string]bool{ - "config.yaml": true, - "config/config.yaml": true, - "config/scenarios/x.yaml": true, - "config/acquis.d/a.conf": true, - "notes.md": true, - "config/data/nested.yaml": true, // only the top-level data/ is protected - "bouncer_key": false, - "config/bouncer_key": false, - "crowdsec.db": false, - "config/crowdsec.db-wal": false, - "data/crowdsec.db": false, - "data/GeoLite2-City.mmdb": false, - "hub_cache/a.tgz": false, - "config/other.db": false, - "config/other.db-journal": false, - "config/CASE.DB": false, - "data": false, - "hub_cache": false, - "config/hub_cache/thing.yaml": true, + "config.yaml": true, + "config/config.yaml": true, + "config/scenarios/x.yaml": true, + "config/acquis.d/a.conf": true, + "notes.md": true, + "config/data/nested.yaml": true, // only the top-level data/ is protected + "bouncer_key": false, + "config/bouncer_key": false, + "crowdsec.db": false, + "config/crowdsec.db-wal": false, + "data/crowdsec.db": false, + "data/GeoLite2-City.mmdb": false, + "hub_cache/a.tgz": false, + "config/other.db": false, + "config/other.db-journal": false, + "config/CASE.DB": false, + "data": false, + "hub_cache": false, + "local_api_credentials.yaml": false, + "config/local_api_credentials.yaml": false, + "config/online_api_credentials.yaml": false, + "config/Local_API_Credentials.YAML": false, + "config/ONLINE_API_CREDENTIALS.yaml": false, + "config/local_api_credentials.yaml.bak": true, + "config/hub_cache/thing.yaml": true, } for rel, want := range readable { assert.Equal(t, want, isReadable(rel), "isReadable(%q)", rel) @@ -689,3 +695,51 @@ func TestCrowdsecFiles_Write_FilesystemFailuresAre500(t *testing.T) { assert.NoFileExists(t, filepath.Join(cfg, "new.yaml")) }) } + +var credentialsFileNames = []string{ + "local_api_credentials.yaml", "online_api_credentials.yaml", + "Local_API_Credentials.yaml", "ONLINE_API_CREDENTIALS.YAML", +} + +func TestCrowdsecFiles_CredentialsHiddenFromListReadAndWrite(t *testing.T) { + t.Parallel() + f := newFilesFixture(t) + f.put("config/config.yaml", "a: 1\n") + for _, name := range credentialsFileNames { + f.put("config/"+name, "password: hunter2\n") + } + + assert.Equal(t, []string{filepath.Join("config", "config.yaml")}, f.list()) + + for _, name := range credentialsFileNames { + rel := "config/" + name + w := f.read(rel) + assert.Equal(t, http.StatusBadRequest, w.Code, rel) + assert.Equal(t, "invalid path", errorOf(t, w), rel) + assert.NotContains(t, w.Body.String(), "hunter2", rel) + + w = f.write(rel, "a: 1\n") + assert.Equal(t, http.StatusBadRequest, w.Code, rel) + assert.Equal(t, errFileCannotBeEdited, errorOf(t, w), rel) + } + + for _, name := range credentialsFileNames[:2] { + b, err := os.ReadFile(filepath.Join(f.dir, "config", name)) // #nosec G304 -- test path + require.NoError(t, err) + assert.Equal(t, "password: hunter2\n", string(b), "rejected write must not modify %s", name) + } +} + +func TestCrowdsecFiles_Read_SymlinkToCredentialsRefused(t *testing.T) { + t.Parallel() + f := newFilesFixture(t) + f.put("config/local_api_credentials.yaml", "password: hunter2\n") + require.NoError(t, os.Symlink( + filepath.Join(f.dir, "config", "local_api_credentials.yaml"), + filepath.Join(f.dir, "config", "innocent.yaml"))) + + w := f.read("config/innocent.yaml") + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Equal(t, "invalid path", errorOf(t, w)) + assert.NotContains(t, w.Body.String(), "hunter2") +} From 081ff478a93ed901a7d1eacaa8bbce9c00472adc Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Fri, 9 Oct 2026 21:16:54 +0000 Subject: [PATCH 04/22] fix: show CrowdSec backups by name CrowdSec API responses and UI messages now identify backups by name only, with a location hint in the UI. Detail stays in the server log. --- .../crowdsec_curated_apply_handler_test.go | 7 +- .../handlers/crowdsec_data_mutation_test.go | 7 +- .../internal/api/handlers/crowdsec_files.go | 2 +- .../api/handlers/crowdsec_files_test.go | 6 +- .../internal/api/handlers/crowdsec_handler.go | 14 +- .../api/handlers/crowdsec_handler_test.go | 7 +- .../handlers/crowdsec_preset_event_test.go | 6 +- .../api/handlers/crowdsec_preset_handler.go | 10 +- .../handlers/crowdsec_response_paths_test.go | 146 ++++++++++++++++++ backend/internal/crowdsec/backup.go | 39 ++++- backend/internal/crowdsec/backup_name_test.go | 35 +++++ backend/internal/crowdsec/backup_test.go | 9 +- .../internal/crowdsec/curated_apply_test.go | 2 +- backend/internal/crowdsec/hub_sync.go | 8 +- backend/internal/crowdsec/safe_create.go | 10 +- frontend/src/locales/de/translation.json | 2 + frontend/src/locales/en/translation.json | 2 + frontend/src/locales/es/translation.json | 2 + frontend/src/locales/fr/translation.json | 2 + frontend/src/locales/zh/translation.json | 2 + frontend/src/pages/CrowdSecConfig.tsx | 12 +- .../CrowdSecConfig.coverage.test.tsx | 8 +- .../CrowdSecConfig.curatedApply.test.tsx | 9 +- .../pages/__tests__/CrowdSecConfig.spec.tsx | 6 +- 24 files changed, 297 insertions(+), 56 deletions(-) create mode 100644 backend/internal/api/handlers/crowdsec_response_paths_test.go create mode 100644 backend/internal/crowdsec/backup_name_test.go diff --git a/backend/internal/api/handlers/crowdsec_curated_apply_handler_test.go b/backend/internal/api/handlers/crowdsec_curated_apply_handler_test.go index 80d8059ee..46c26ff5e 100644 --- a/backend/internal/api/handlers/crowdsec_curated_apply_handler_test.go +++ b/backend/internal/api/handlers/crowdsec_curated_apply_handler_test.go @@ -123,7 +123,8 @@ func TestApplyCuratedPresetInstallsViaCSCLI(t *testing.T) { require.Equal(t, "curated-"+slug, resp["cache_key"]) backup, _ := resp["backup"].(string) require.NotEmpty(t, backup) - require.DirExists(t, backup) + require.Equal(t, filepath.Base(backup), backup, "response carries a name, not a path") + require.DirExists(t, filepath.Join(filepath.Dir(c.dataDir), backup)) preset, ok := crowdsec.FindPreset(slug) require.True(t, ok) @@ -133,7 +134,7 @@ func TestApplyCuratedPresetInstallsViaCSCLI(t *testing.T) { require.Len(t, events, 1) require.Equal(t, slug, events[0].Slug) require.Equal(t, "applied", events[0].Status) - require.Equal(t, backup, events[0].BackupPath) + require.Equal(t, backup, filepath.Base(events[0].BackupPath)) } func TestApplyCuratedPresetCSCLIUnavailableReturns503(t *testing.T) { @@ -180,7 +181,7 @@ func TestApplyCuratedPresetInstallFailureReturns500WithBackup(t *testing.T) { events := c.events() require.Len(t, events, 1) require.Equal(t, "failed", events[0].Status) - require.Equal(t, backup, events[0].BackupPath) + require.Equal(t, backup, filepath.Base(events[0].BackupPath)) require.Contains(t, events[0].Error, "install exploded") } diff --git a/backend/internal/api/handlers/crowdsec_data_mutation_test.go b/backend/internal/api/handlers/crowdsec_data_mutation_test.go index ebc3d1422..997cdf697 100644 --- a/backend/internal/api/handlers/crowdsec_data_mutation_test.go +++ b/backend/internal/api/handlers/crowdsec_data_mutation_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "time" @@ -104,8 +105,10 @@ func TestImportConfigReplacesConfigButPreservesEngineStateAndDataDir(t *testing. var resp map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - backup, _ := resp["backup"].(string) - require.Equal(t, dir+".backup.", backup[:len(dir)+len(".backup.")]) + backupName, _ := resp["backup"].(string) + require.True(t, strings.HasPrefix(backupName, filepath.Base(dir)+".backup."), backupName) + require.Equal(t, filepath.Base(backupName), backupName, "response carries a name, not a path") + backup := filepath.Join(filepath.Dir(dir), backupName) after, err := os.Stat(dir) require.NoError(t, err) diff --git a/backend/internal/api/handlers/crowdsec_files.go b/backend/internal/api/handlers/crowdsec_files.go index 44754622a..3b0a08a27 100644 --- a/backend/internal/api/handlers/crowdsec_files.go +++ b/backend/internal/api/handlers/crowdsec_files.go @@ -411,7 +411,7 @@ func (h *CrowdsecHandler) WriteFile(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write file"}) return } - c.JSON(http.StatusOK, gin.H{"status": "written", "backup": backupDir}) + c.JSON(http.StatusOK, gin.H{"status": "written", "backup": crowdsec.BackupID(backupDir)}) } // fileErrorLog returns a log entry carrying err with control characters diff --git a/backend/internal/api/handlers/crowdsec_files_test.go b/backend/internal/api/handlers/crowdsec_files_test.go index 52b719638..db25481d0 100644 --- a/backend/internal/api/handlers/crowdsec_files_test.go +++ b/backend/internal/api/handlers/crowdsec_files_test.go @@ -499,10 +499,12 @@ func TestCrowdsecFiles_Write_BackupHoldsOnlyPreviousVersionAndIsCappedIndependen var resp map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) require.NotEmpty(t, resp["backup"]) - prev, err := os.ReadFile(filepath.Join(resp["backup"], "config", "config.yaml")) // #nosec G304 -- test path + require.Equal(t, filepath.Base(resp["backup"]), resp["backup"], "response carries a name, not a path") + backupDir := filepath.Join(f.root, resp["backup"]) + prev, err := os.ReadFile(filepath.Join(backupDir, "config", "config.yaml")) // #nosec G304 -- test path require.NoError(t, err) assert.Equal(t, "v: 0\n", string(prev)) - _, err = os.Stat(filepath.Join(resp["backup"], "config", "other.yaml")) + _, err = os.Stat(filepath.Join(backupDir, "config", "other.yaml")) assert.True(t, os.IsNotExist(err), "backup must hold only the replaced file") for i := 2; i <= 14; i++ { diff --git a/backend/internal/api/handlers/crowdsec_handler.go b/backend/internal/api/handlers/crowdsec_handler.go index 8adb5aa57..41c6b5cea 100644 --- a/backend/internal/api/handlers/crowdsec_handler.go +++ b/backend/internal/api/handlers/crowdsec_handler.go @@ -679,7 +679,7 @@ func (h *CrowdsecHandler) ImportConfig(c *gin.Context) { } if err = validator.Validate(dst); err != nil { - c.JSON(http.StatusUnprocessableEntity, gin.H{"error": fmt.Sprintf("validation failed: %v", err)}) + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": fmt.Sprintf("validation failed: %v", crowdsec.RedactPaths(err.Error()))}) return } @@ -711,8 +711,9 @@ func (h *CrowdsecHandler) ImportConfig(c *gin.Context) { // Extract archive extractErr := h.extractArchive(dst, h.DataDir) if extractErr != nil { + logger.Log().WithField("error", sanitizeForLog(extractErr.Error())).Warn("crowdsec import extraction failed") rollback() - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("extraction failed: %v", extractErr)}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "extraction failed"}) return } @@ -720,11 +721,11 @@ func (h *CrowdsecHandler) ImportConfig(c *gin.Context) { configPath := filepath.Join(h.DataDir, "config.yaml") if err := validateYAMLFile(configPath); err != nil { rollback() - c.JSON(http.StatusUnprocessableEntity, gin.H{"error": fmt.Sprintf("config validation failed: %v", err)}) + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": fmt.Sprintf("config validation failed: %v", crowdsec.RedactPaths(err.Error()))}) return } - c.JSON(http.StatusOK, gin.H{"status": "imported", "backup": backupDir}) + c.JSON(http.StatusOK, gin.H{"status": "imported", "backup": crowdsec.BackupID(backupDir)}) } // pruneSnapshots bounds the full-tree snapshots kept next to DataDir; failures are logged only. @@ -875,8 +876,9 @@ func (h *CrowdsecHandler) ExportConfig(c *gin.Context) { return nil }) if err != nil { + logger.Log().WithField("error", sanitizeForLog(err.Error())).Warn("crowdsec export failed") // If any error occurred while creating the archive, return 500 - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to export crowdsec config"}) return } } @@ -2027,7 +2029,7 @@ func (h *CrowdsecHandler) UpdateAcquisitionConfig(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "status": "updated", - "backup": backupPath, + "backup": crowdsec.BackupID(backupPath), "reload_hint": true, }) } diff --git a/backend/internal/api/handlers/crowdsec_handler_test.go b/backend/internal/api/handlers/crowdsec_handler_test.go index 3fb468f79..5927b9cf1 100644 --- a/backend/internal/api/handlers/crowdsec_handler_test.go +++ b/backend/internal/api/handlers/crowdsec_handler_test.go @@ -3249,9 +3249,10 @@ func TestCrowdsecHandler_WriteFile_BackupCreation(t *testing.T) { require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) require.Contains(t, resp, "backup") - backupPath := resp["backup"].(string) - require.NotEmpty(t, backupPath) - require.True(t, strings.HasPrefix(backupPath, tmpDir+".filebackup."), backupPath) + backupName := resp["backup"].(string) + require.True(t, strings.HasPrefix(backupName, filepath.Base(tmpDir)+".filebackup."), backupName) + require.Equal(t, filepath.Base(backupName), backupName, "response carries a name, not a path") + backupPath := filepath.Join(filepath.Dir(tmpDir), backupName) // Only the replaced file is backed up, with its previous content; DataDir stays in place. // #nosec G304 -- test reads a path it just created diff --git a/backend/internal/api/handlers/crowdsec_preset_event_test.go b/backend/internal/api/handlers/crowdsec_preset_event_test.go index b2f71891b..9c05cb946 100644 --- a/backend/internal/api/handlers/crowdsec_preset_event_test.go +++ b/backend/internal/api/handlers/crowdsec_preset_event_test.go @@ -62,10 +62,10 @@ func TestApplyResponseHelpers(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) - respondApplySuccess(c, crowdsec.ApplyResult{Status: "applied", BackupPath: "/b", ReloadHint: true, UsedCSCLI: true, CacheKey: "k", AppliedPreset: "s"}) + respondApplySuccess(c, crowdsec.ApplyResult{Status: "applied", BackupPath: "/data/crowdsec.backup.1", ReloadHint: true, UsedCSCLI: true, CacheKey: "k", AppliedPreset: "s"}) require.Equal(t, http.StatusOK, w.Code) - require.JSONEq(t, `{"status":"applied","backup":"/b","reload_hint":true,"used_cscli":true,"cache_key":"k","slug":"s"}`, w.Body.String()) + require.JSONEq(t, `{"status":"applied","backup":"crowdsec.backup.1","reload_hint":true,"used_cscli":true,"cache_key":"k","slug":"s"}`, w.Body.String()) require.Equal(t, gin.H{"error": "m"}, applyFailureBody("m", crowdsec.ApplyResult{})) - require.Equal(t, gin.H{"error": "m", "backup": "/b", "cache_key": "k"}, applyFailureBody("m", crowdsec.ApplyResult{BackupPath: "/b", CacheKey: "k"})) + require.Equal(t, gin.H{"error": "m", "backup": "crowdsec.backup.1", "cache_key": "k"}, applyFailureBody("m", crowdsec.ApplyResult{BackupPath: "/data/crowdsec.backup.1", CacheKey: "k"})) } diff --git a/backend/internal/api/handlers/crowdsec_preset_handler.go b/backend/internal/api/handlers/crowdsec_preset_handler.go index 66f2db62f..f8552e314 100644 --- a/backend/internal/api/handlers/crowdsec_preset_handler.go +++ b/backend/internal/api/handlers/crowdsec_preset_handler.go @@ -168,7 +168,7 @@ func (h *CrowdsecHandler) PullPreset(c *gin.Context) { // control characters (0x00-0x1F, 0x7F) including CRLF // codeql[go/log-injection] logger.Log().WithField("error", util.SanitizeForLog(err.Error())).WithField("slug", util.SanitizeForLog(slug)).WithField("hub_base_url", util.SanitizeForLog(h.Hub.HubBaseURL)).Warn("crowdsec preset pull failed") - c.JSON(status, gin.H{"error": err.Error(), "hub_endpoints": h.hubEndpoints()}) + c.JSON(status, gin.H{"error": crowdsec.RedactPaths(err.Error()), "hub_endpoints": h.hubEndpoints()}) return } @@ -273,7 +273,7 @@ func (h *CrowdsecHandler) ApplyPreset(c *gin.Context) { logger.Log().WithField("error", util.SanitizeForLog(err.Error())).WithField("slug", util.SanitizeForLog(slug)).WithField("hub_base_url", util.SanitizeForLog(h.Hub.HubBaseURL)).WithField("backup_path", util.SanitizeForLog(res.BackupPath)).WithField("cache_key", util.SanitizeForLog(res.CacheKey)).Warn("crowdsec preset apply failed") h.recordPresetEvent(slug, res, err) // Build detailed error response - errorMsg := err.Error() + errorMsg := crowdsec.RedactPaths(err.Error()) // Add actionable guidance based on error type if errors.Is(err, crowdsec.ErrCacheMiss) || strings.Contains(errorMsg, "cache miss") { errorMsg = "Preset cache missing or expired. Pull the preset again, then retry apply." @@ -299,7 +299,7 @@ func (h *CrowdsecHandler) applyCuratedPreset(c *gin.Context, preset crowdsec.Pre h.recordPresetEvent(preset.Slug, res, err) status := mapCrowdsecStatus(err, http.StatusInternalServerError) - msg := err.Error() + msg := crowdsec.RedactPaths(err.Error()) if errors.Is(err, crowdsec.ErrCSCLIUnavailable) { status = http.StatusServiceUnavailable msg = "CrowdSec CLI is not available; curated presets require cscli" @@ -350,7 +350,7 @@ func (h *CrowdsecHandler) recordPresetEvent(slug string, res crowdsec.ApplyResul func applyFailureBody(msg string, res crowdsec.ApplyResult) gin.H { body := gin.H{"error": msg} if res.BackupPath != "" { - body["backup"] = res.BackupPath + body["backup"] = crowdsec.BackupID(res.BackupPath) } if res.CacheKey != "" { body["cache_key"] = res.CacheKey @@ -362,7 +362,7 @@ func applyFailureBody(msg string, res crowdsec.ApplyResult) gin.H { func respondApplySuccess(c *gin.Context, res crowdsec.ApplyResult) { c.JSON(http.StatusOK, gin.H{ "status": res.Status, - "backup": res.BackupPath, + "backup": crowdsec.BackupID(res.BackupPath), "reload_hint": res.ReloadHint, "used_cscli": res.UsedCSCLI, "cache_key": res.CacheKey, diff --git a/backend/internal/api/handlers/crowdsec_response_paths_test.go b/backend/internal/api/handlers/crowdsec_response_paths_test.go new file mode 100644 index 000000000..f253a405b --- /dev/null +++ b/backend/internal/api/handlers/crowdsec_response_paths_test.go @@ -0,0 +1,146 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// serverPathPattern matches absolute server path prefixes that must never reach a client. +var serverPathPattern = regexp.MustCompile(`(^|[\s"'(=:])/(app|data|etc|tmp|var|home)(/|\b)`) + +// requireNoServerPaths fails when body leaks an absolute server path or any of the extra +// test-specific directories (typically t.TempDir()). +func requireNoServerPaths(t *testing.T, body string, extra ...string) { + t.Helper() + require.Falsef(t, serverPathPattern.MatchString(body), "response leaks a server path: %s", body) + for _, e := range extra { + require.NotContainsf(t, body, e, "response leaks %s: %s", e, body) + } +} + +func TestResponses_FileWriteCarriesBackupNameOnly(t *testing.T) { + f := newFilesFixture(t) + f.put("config/config.yaml", "v: 0\n") + + w := f.write("config/config.yaml", "v: 1\n") + require.Equal(t, http.StatusOK, w.Code) + requireNoServerPaths(t, w.Body.String(), f.root) + + var resp map[string]string + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Regexp(t, `^crowdsec\.filebackup\.\d{8}-\d{6}\.\d{6}$`, resp["backup"]) +} + +func TestResponses_ImportSuccessAndFailuresCarryNoPaths(t *testing.T) { + t.Parallel() + dir := filepath.Join(t.TempDir(), "crowdsec") + require.NoError(t, os.MkdirAll(dir, 0o750)) + seedEngineState(t, dir) + root := filepath.Dir(dir) + _, r := newImportRouter(t, dir) + + cases := []struct { + name string + files map[string]string + code int + }{ + {"success", map[string]string{"config.yaml": importableConfig}, http.StatusOK}, + {"extraction failure", map[string]string{"config.yaml": importableConfig, "a": "file", "a/b": "nested"}, http.StatusInternalServerError}, + {"validation failure", map[string]string{"other.yaml": "x"}, http.StatusUnprocessableEntity}, + {"config validation failure", map[string]string{"config.yaml": "not: valid"}, http.StatusUnprocessableEntity}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + r.ServeHTTP(w, importRequest(t, tc.files)) + require.Equal(t, tc.code, w.Code, w.Body.String()) + requireNoServerPaths(t, w.Body.String(), root) + if tc.code == http.StatusInternalServerError { + require.JSONEq(t, `{"error":"extraction failed"}`, w.Body.String()) + } + }) + } +} + +func TestResponses_CuratedApplyCarriesNoPaths(t *testing.T) { + t.Run("success", func(t *testing.T) { + c := newCuratedHarness(t, nil) + code, resp := c.apply(t, "honeypot-friendly-defaults") + require.Equal(t, http.StatusOK, code) + body, _ := json.Marshal(resp) + requireNoServerPaths(t, string(body), filepath.Dir(c.dataDir)) + require.Regexp(t, `\.backup\.\d{8}-\d{6}\.\d{6}$`, resp["backup"]) + }) + + t.Run("rollback failure", func(t *testing.T) { + var c *curatedHarness + c = newCuratedHarness(t, func(_ context.Context, cmd string) ([]byte, error) { + if strings.Contains(cmd, " install ") { + // Make the restore fail too: DataDir becomes a regular file mid-apply. + require.NoError(t, os.RemoveAll(c.dataDir)) + require.NoError(t, os.WriteFile(c.dataDir, []byte("not a dir"), 0o600)) + return nil, context.DeadlineExceeded + } + return nil, nil + }) + code, resp := c.apply(t, "geoip-enrichment") + require.GreaterOrEqual(t, code, http.StatusInternalServerError) + body, _ := json.Marshal(resp) + require.Contains(t, string(body), "see server logs") + requireNoServerPaths(t, string(body), filepath.Dir(c.dataDir)) + }) +} + +func TestResponses_AcquisitionUpdateCarriesBackupNameOnly(t *testing.T) { + dir := t.TempDir() + acquis := filepath.Join(dir, "acquis.yaml") + require.NoError(t, os.WriteFile(acquis, []byte("old: true\n"), 0o600)) + t.Setenv("CHARON_CROWDSEC_ACQUIS_PATH", acquis) + + h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir()) + r := gin.New() + r.PUT("/acquisition", h.UpdateAcquisitionConfig) + + body, _ := json.Marshal(map[string]string{"content": "source: file\n"}) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/acquisition", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + requireNoServerPaths(t, w.Body.String(), dir) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Regexp(t, `^acquis\.yaml\.backup\.\d{8}-\d{6}$`, resp["backup"]) + require.FileExists(t, filepath.Join(dir, resp["backup"].(string))) +} + +func TestResponses_ExportErrorCarriesNoPaths(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("permission checks do not apply to root") + } + dir := t.TempDir() + secret := filepath.Join(dir, "unreadable.yaml") + require.NoError(t, os.WriteFile(secret, []byte("x"), 0o000)) + h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", dir) + r := gin.New() + h.RegisterRoutes(r.Group("/api/v1")) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/export", http.NoBody)) + + require.Equal(t, http.StatusInternalServerError, w.Code) + requireNoServerPaths(t, w.Body.String(), dir) + require.Contains(t, w.Body.String(), "failed to export crowdsec config") +} diff --git a/backend/internal/crowdsec/backup.go b/backend/internal/crowdsec/backup.go index 0e9fa0187..641a4365f 100644 --- a/backend/internal/crowdsec/backup.go +++ b/backend/internal/crowdsec/backup.go @@ -6,6 +6,7 @@ import ( "io/fs" "os" "path/filepath" + "regexp" "sort" "strings" "time" @@ -287,10 +288,20 @@ func PruneBackups(dataDir, kind string, keep int) (removed []string, err error) return removed, errors.Join(errs...) } -// rollbackFailure annotates cause with a failed restore and the snapshot kept for manual recovery. +// BackupID returns the client-safe name of a backup: the final path element, never the absolute +// location. It covers the directory kinds (*.backup., *.filebackup.) and file backups +// (acquis.yaml.backup.); an empty path yields an empty name. +func BackupID(path string) string { + if path == "" { + return "" + } + return filepath.Base(path) +} + +// rollbackFailure annotates cause with a failed restore; the snapshot location is logged, never returned. func rollbackFailure(cause, restoreErr error, backupPath string) error { logger.Log().WithError(restoreErr).WithField("backup_path", util.SanitizeForLog(backupPath)).Error("preset rollback failed; backup retained for manual recovery") - return fmt.Errorf("%w (rollback failed: %v; backup retained at %s)", cause, restoreErr, backupPath) + return fmt.Errorf("%w (rollback failed; backup retained, see server logs)", cause) } // BackupFile copies the single file dataDir/rel into a new .filebackup./ directory @@ -335,3 +346,27 @@ func BackupFile(dataDir, rel string) (string, error) { } return dir, nil } + +// absPathPattern matches absolute filesystem paths that follow whitespace, a quote, "(" or "=". +// URL path segments are preceded by ":" or "/" and are therefore left alone. +var absPathPattern = regexp.MustCompile(`(^|[\s('"=])/[^\s:;)'"]+`) + +// RedactPaths replaces absolute filesystem paths in an error message with "" so server +// layout never reaches an API client. Detail belongs in server logs. +func RedactPaths(msg string) string { + return absPathPattern.ReplaceAllString(msg, "${1}") +} + +// withoutPath unwraps a filesystem error to its underlying cause (for example "permission +// denied"), dropping the absolute path that *fs.PathError and *os.LinkError embed. +func withoutPath(err error) error { + var pathErr *fs.PathError + if errors.As(err, &pathErr) { + return pathErr.Err + } + var linkErr *os.LinkError + if errors.As(err, &linkErr) { + return linkErr.Err + } + return err +} diff --git a/backend/internal/crowdsec/backup_name_test.go b/backend/internal/crowdsec/backup_name_test.go new file mode 100644 index 000000000..dfa90ebb2 --- /dev/null +++ b/backend/internal/crowdsec/backup_name_test.go @@ -0,0 +1,35 @@ +package crowdsec + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBackupID(t *testing.T) { + t.Parallel() + require.Equal(t, "crowdsec.backup.20260101-000000.000000", BackupID("/app/data/crowdsec.backup.20260101-000000.000000")) + require.Equal(t, "crowdsec.filebackup.20260101-000000.000000", BackupID("/data/crowdsec.filebackup.20260101-000000.000000/")) + require.Equal(t, "acquis.yaml.backup.20260101-000000", BackupID("/etc/crowdsec/acquis.yaml.backup.20260101-000000")) + require.Empty(t, BackupID("")) +} + +func TestRedactPaths(t *testing.T) { + t.Parallel() + require.Equal(t, "open : no such file", RedactPaths("open /app/data/x/y.yaml: no such file")) + require.Equal(t, "mkdir : denied", RedactPaths("mkdir /tmp/a-b/c: denied")) + require.Equal(t, `fetch ""`, RedactPaths(`fetch "/var/lib/x"`)) + require.Equal(t, "fetch https://hub.example.com/a/index.json failed", RedactPaths("fetch https://hub.example.com/a/index.json failed")) + require.Equal(t, "plain message", RedactPaths("plain message")) +} + +func TestExtractTarGzErrorsOmitAbsolutePaths(t *testing.T) { + t.Parallel() + // A file entry colliding with a later nested entry forces a mkdir failure. + target := t.TempDir() + svc := NewHubService(nil, nil, target) + require.NoError(t, svc.extractTarGz(t.Context(), makeTarGz(t, map[string]string{"a": "file"}), target)) + err := svc.extractTarGz(t.Context(), makeTarGz(t, map[string]string{"a/b": "nested"}), target) + require.Error(t, err) + require.NotContains(t, err.Error(), target) +} diff --git a/backend/internal/crowdsec/backup_test.go b/backend/internal/crowdsec/backup_test.go index 68f9726c5..3191bbfbd 100644 --- a/backend/internal/crowdsec/backup_test.go +++ b/backend/internal/crowdsec/backup_test.go @@ -639,7 +639,9 @@ func TestApplyRestoreFailureIsReported(t *testing.T) { res, err := svc.Apply(context.Background(), "test/preset") require.ErrorContains(t, err, "rollback failed") - require.ErrorContains(t, err, "backup retained at "+res.BackupPath) + require.ErrorContains(t, err, "backup retained, see server logs") + require.NotContains(t, err.Error(), res.BackupPath, "the backup location stays in server logs") + require.NotContains(t, err.Error(), dir) } func TestApplySnapshotFailureChangesNothing(t *testing.T) { @@ -689,8 +691,9 @@ func TestRollbackFailureWrapsCause(t *testing.T) { cause := errors.New("boom") err := rollbackFailure(cause, errors.New("restore broke"), "/b") require.ErrorIs(t, err, cause) - require.ErrorContains(t, err, "restore broke") - require.ErrorContains(t, err, "/b") + require.ErrorContains(t, err, "see server logs") + require.NotContains(t, err.Error(), "restore broke") + require.NotContains(t, err.Error(), "/b") } // makeTarGzWithSymlink returns an archive that writes one file and then hits a symlink entry. diff --git a/backend/internal/crowdsec/curated_apply_test.go b/backend/internal/crowdsec/curated_apply_test.go index b3eb8dced..22656d690 100644 --- a/backend/internal/crowdsec/curated_apply_test.go +++ b/backend/internal/crowdsec/curated_apply_test.go @@ -441,7 +441,7 @@ func TestApplyCuratedRollbackFailureIsReported(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "install exploded") require.Contains(t, err.Error(), "rollback failed") - require.Contains(t, err.Error(), res.BackupPath) + require.NotContains(t, err.Error(), res.BackupPath, "the backup location stays in server logs") require.Equal(t, "failed", res.Status) require.DirExists(t, dir, "DataDir itself is never removed") } diff --git a/backend/internal/crowdsec/hub_sync.go b/backend/internal/crowdsec/hub_sync.go index 8ef531538..e2a60abfa 100644 --- a/backend/internal/crowdsec/hub_sync.go +++ b/backend/internal/crowdsec/hub_sync.go @@ -960,7 +960,7 @@ func (s *HubService) extractTarGz(ctx context.Context, archive []byte, targetDir if hdr.FileInfo().IsDir() { if mkdirErr := os.MkdirAll(destPath, mode); mkdirErr != nil { //nolint:gosec // G703: destPath contained under targetDir by the filepath.Rel check above - return fmt.Errorf("mkdir %s: %w", destPath, mkdirErr) + return fmt.Errorf("mkdir %s: %w", cleanName, withoutPath(mkdirErr)) } continue } @@ -975,15 +975,15 @@ func (s *HubService) extractTarGz(ctx context.Context, archive []byte, targetDir written, err := io.Copy(f, limitedReader) if err != nil { _ = f.Close() - return fmt.Errorf("write %s: %w", destPath, err) + return fmt.Errorf("write %s: %w", cleanName, withoutPath(err)) } // Verify we didn't hit the limit (potential attack) if written >= maxDecompressedSize { _ = f.Close() - return fmt.Errorf("file %s exceeded decompression limit (%d bytes), potential decompression bomb", destPath, maxDecompressedSize) + return fmt.Errorf("file %s exceeded decompression limit (%d bytes), potential decompression bomb", cleanName, maxDecompressedSize) } if err := f.Close(); err != nil { - return fmt.Errorf("close %s: %w", destPath, err) + return fmt.Errorf("close %s: %w", cleanName, withoutPath(err)) } } return nil diff --git a/backend/internal/crowdsec/safe_create.go b/backend/internal/crowdsec/safe_create.go index 01d68d19d..545f6d99d 100644 --- a/backend/internal/crowdsec/safe_create.go +++ b/backend/internal/crowdsec/safe_create.go @@ -19,7 +19,7 @@ func CreateFileNoSymlink(root, dest string, parentMode, fileMode os.FileMode) (* root = filepath.Clean(root) rel, err := filepath.Rel(root, filepath.Clean(dest)) if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { - return nil, fmt.Errorf("path escapes target: %s", dest) + return nil, fmt.Errorf("path escapes target: %s", rel) } cur := root for _, part := range strings.Split(rel, string(os.PathSeparator)) { @@ -29,18 +29,18 @@ func CreateFileNoSymlink(root, dest string, parentMode, fileMode os.FileMode) (* break } if lerr != nil { - return nil, fmt.Errorf("inspect %s: %w", cur, lerr) + return nil, fmt.Errorf("inspect %s: %w", rel, withoutPath(lerr)) } if info.Mode()&os.ModeSymlink != 0 { - return nil, fmt.Errorf("%w: %s", ErrSymlinkInPath, cur) + return nil, fmt.Errorf("%w: %s", ErrSymlinkInPath, rel) } } if err = os.MkdirAll(filepath.Dir(dest), parentMode); err != nil { //nolint:gosec // G703: dest verified contained under root and symlink-free above - return nil, fmt.Errorf("mkdir parent: %w", err) + return nil, fmt.Errorf("mkdir parent: %w", withoutPath(err)) } f, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, fileMode) //nolint:gosec // G304,G703: dest verified contained under root and symlink-free above if err != nil { - return nil, fmt.Errorf("open %s: %w", dest, err) + return nil, fmt.Errorf("open %s: %w", rel, withoutPath(err)) } return f, nil } diff --git a/frontend/src/locales/de/translation.json b/frontend/src/locales/de/translation.json index 1f9cf3085..70b599b6c 100644 --- a/frontend/src/locales/de/translation.json +++ b/frontend/src/locales/de/translation.json @@ -1235,6 +1235,8 @@ "previewUnavailable": "Vorschau nicht verfügbar", "applied": "Angewendet", "backup": "Backup", + "backupCreated": "Sicherung erstellt: {{name}} ({{location}})", + "backupLocation": "neben dem CrowdSec-Datenverzeichnis gespeichert", "reload": "Neu laden", "required": "Erforderlich", "method": "Methode", diff --git a/frontend/src/locales/en/translation.json b/frontend/src/locales/en/translation.json index a72858cbd..c691b0d35 100644 --- a/frontend/src/locales/en/translation.json +++ b/frontend/src/locales/en/translation.json @@ -1573,6 +1573,8 @@ "previewUnavailable": "Preview unavailable", "applied": "Applied", "backup": "Backup", + "backupCreated": "Backup created: {{name}} ({{location}})", + "backupLocation": "stored next to the CrowdSec data directory", "reload": "Reload", "required": "Required", "method": "Method", diff --git a/frontend/src/locales/es/translation.json b/frontend/src/locales/es/translation.json index 7768f0d6f..304474589 100644 --- a/frontend/src/locales/es/translation.json +++ b/frontend/src/locales/es/translation.json @@ -1237,6 +1237,8 @@ "previewUnavailable": "Vista previa no disponible", "applied": "Aplicado", "backup": "Respaldo", + "backupCreated": "Copia de seguridad creada: {{name}} ({{location}})", + "backupLocation": "almacenada junto al directorio de datos de CrowdSec", "reload": "Recargar", "required": "Requerido", "method": "Método", diff --git a/frontend/src/locales/fr/translation.json b/frontend/src/locales/fr/translation.json index cf003bd2a..1d09953a4 100644 --- a/frontend/src/locales/fr/translation.json +++ b/frontend/src/locales/fr/translation.json @@ -1237,6 +1237,8 @@ "previewUnavailable": "Aperçu non disponible", "applied": "Appliqué", "backup": "Sauvegarde", + "backupCreated": "Sauvegarde créée : {{name}} ({{location}})", + "backupLocation": "stockée à côté du répertoire de données CrowdSec", "reload": "Recharger", "required": "Requis", "method": "Méthode", diff --git a/frontend/src/locales/zh/translation.json b/frontend/src/locales/zh/translation.json index 874691e4a..66bc32305 100644 --- a/frontend/src/locales/zh/translation.json +++ b/frontend/src/locales/zh/translation.json @@ -1236,6 +1236,8 @@ "previewUnavailable": "预览不可用", "applied": "已应用", "backup": "备份", + "backupCreated": "备份已创建:{{name}}({{location}})", + "backupLocation": "存放在 CrowdSec 数据目录旁", "reload": "重新加载", "required": "必需", "method": "方法", diff --git a/frontend/src/pages/CrowdSecConfig.tsx b/frontend/src/pages/CrowdSecConfig.tsx index cc6b017a1..0dd0d30f0 100644 --- a/frontend/src/pages/CrowdSecConfig.tsx +++ b/frontend/src/pages/CrowdSecConfig.tsx @@ -57,6 +57,8 @@ export default function CrowdSecConfig() { const [validationError, setValidationError] = useState(null) const [applyInfo, setApplyInfo] = useState<{ status?: string; backup?: string; reloadHint?: boolean; usedCscli?: boolean; cacheKey?: string } | null>(null) const queryClient = useQueryClient() + const backupCreatedMessage = (name: string) => + t('crowdsecConfig.presets.backupCreated', { name, location: t('crowdsecConfig.presets.backupLocation') }) // Read the "CrowdSec is starting" signal broadcast by Security.tsx via the // QueryClient cache. No HTTP call is made; this is pure in-memory coordination. const { data: crowdsecStartingCache } = useQuery<{ isStarting: boolean; startedAt?: number }>({ @@ -471,7 +473,7 @@ export default function CrowdSecConfig() { const reloadNote = res.reload_hint ? ' (reload required)' : '' toast.success(`Preset applied via backend${reloadNote}`) if (res.backup) { - setPresetStatusMessage(`Backup stored at ${res.backup}`) + setPresetStatusMessage(backupCreatedMessage(res.backup)) } } catch (err) { if (!isAxiosError(err)) { @@ -481,7 +483,7 @@ export default function CrowdSecConfig() { // The server is authoritative for every preset source: surface its message for any // non-2xx response and never write preset content client-side. const status = err.response?.status - const backupPath = (err.response?.data as { backup?: string } | undefined)?.backup + const backupName = (err.response?.data as { backup?: string } | undefined)?.backup const message = getServerErrorMessage(err, 'Failed to apply preset') if (status === 400) { setValidationError(message) @@ -489,8 +491,8 @@ export default function CrowdSecConfig() { if (status === 503) { setHubUnavailable(true) } - setApplyInfo({ status: 'failed', backup: backupPath || undefined, cacheKey: presetMeta?.cacheKey }) - toast.error(`Apply failed: ${message}${backupPath ? `. Backup created at ${backupPath}` : ''}`) + setApplyInfo({ status: 'failed', backup: backupName || undefined, cacheKey: presetMeta?.cacheKey }) + toast.error(`Apply failed: ${message}${backupName ? `. ${backupCreatedMessage(backupName)}` : ''}`) } finally { setIsApplyingPreset(false) } @@ -1113,7 +1115,7 @@ export default function CrowdSecConfig() { {applyInfo && (

{t('common.status')}: {applyInfo.status || t('crowdsecConfig.presets.applied')}

- {applyInfo.backup &&

{t('crowdsecConfig.presets.backup')}: {applyInfo.backup}

} + {applyInfo.backup &&

{t('crowdsecConfig.presets.backup')}: {applyInfo.backup} ({t('crowdsecConfig.presets.backupLocation')})

} {applyInfo.reloadHint &&

{t('crowdsecConfig.presets.reload')}: {t('crowdsecConfig.presets.required')}

} {applyInfo.usedCscli !== undefined &&

{t('crowdsecConfig.presets.method')}: {applyInfo.usedCscli ? 'cscli' : t('crowdsecConfig.presets.filesystem')}

}
diff --git a/frontend/src/pages/__tests__/CrowdSecConfig.coverage.test.tsx b/frontend/src/pages/__tests__/CrowdSecConfig.coverage.test.tsx index 820cc1875..1eedbc17f 100644 --- a/frontend/src/pages/__tests__/CrowdSecConfig.coverage.test.tsx +++ b/frontend/src/pages/__tests__/CrowdSecConfig.coverage.test.tsx @@ -138,7 +138,7 @@ describe('CrowdSecConfig coverage', () => { }) vi.mocked(presetsApi.applyCrowdsecPreset).mockResolvedValue({ status: 'applied', - backup: '/tmp/backup.tar.gz', + backup: 'crowdsec.backup.20260101-000000.000000', reload_hint: true, used_cscli: true, cache_key: 'cache-123', @@ -286,7 +286,7 @@ describe('CrowdSecConfig coverage', () => { it('sets apply info on backend success', async () => { await renderPage() await userEvent.click(screen.getByTestId('apply-preset-btn')) - await waitFor(() => expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('Backup: /tmp/backup.tar.gz')) + await waitFor(() => expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('Backup: crowdsec.backup.20260101-000000.000000 (stored next to the CrowdSec data directory)')) }) it('supports keyboard selection for preset cards (Enter and Space)', async () => { @@ -351,10 +351,10 @@ describe('CrowdSecConfig coverage', () => { }) it('records backup info on apply failure and generic errors', async () => { - vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(axiosError(500, 'failed', { error: 'boom', backup: '/tmp/backup' })) + vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(axiosError(500, 'failed', { error: 'boom', backup: 'crowdsec.backup.20260101-000000.000000' })) await renderPage() await userEvent.click(screen.getByTestId('apply-preset-btn')) - await waitFor(() => expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('/tmp/backup')) + await waitFor(() => expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('crowdsec.backup.20260101-000000.000000')) cleanup() diff --git a/frontend/src/pages/__tests__/CrowdSecConfig.curatedApply.test.tsx b/frontend/src/pages/__tests__/CrowdSecConfig.curatedApply.test.tsx index 72dc0ab86..636dac361 100644 --- a/frontend/src/pages/__tests__/CrowdSecConfig.curatedApply.test.tsx +++ b/frontend/src/pages/__tests__/CrowdSecConfig.curatedApply.test.tsx @@ -127,7 +127,7 @@ describe('CrowdSecConfig curated preset apply', () => { }) vi.mocked(presetsApi.applyCrowdsecPreset).mockResolvedValue({ status: 'applied', - backup: '/tmp/backup.tar.gz', + backup: 'crowdsec.backup.20260101-000000.000000', reload_hint: true, used_cscli: true, cache_key: curatedPreset.cache_key, @@ -144,7 +144,8 @@ describe('CrowdSecConfig curated preset apply', () => { await renderPage() await userEvent.click(screen.getByTestId('apply-preset-btn')) await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Preset applied via backend (reload required)')) - expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('Backup: /tmp/backup.tar.gz') + expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('Backup: crowdsec.backup.20260101-000000.000000 (stored next to the CrowdSec data directory)') + expect(screen.getByTestId('preset-apply-info').textContent).not.toMatch(/(^|\s)\//) expect(toast.error).not.toHaveBeenCalled() }) @@ -161,13 +162,13 @@ describe('CrowdSecConfig curated preset apply', () => { 'surfaces the server error and never applies locally for curated presets on %i', async (status) => { vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValue( - axiosError(status, 'request failed', { error: `server says ${status}`, backup: '/tmp/rollback.tar.gz' }), + axiosError(status, 'request failed', { error: `server says ${status}`, backup: 'crowdsec.backup.20260101-000000.000000' }), ) await renderPage() await userEvent.click(screen.getByTestId('apply-preset-btn')) await waitFor(() => expect(toast.error).toHaveBeenCalledWith( - `Apply failed: server says ${status}. Backup created at /tmp/rollback.tar.gz`, + `Apply failed: server says ${status}. Backup created: crowdsec.backup.20260101-000000.000000 (stored next to the CrowdSec data directory)`, ), ) expect(toast.success).not.toHaveBeenCalled() diff --git a/frontend/src/pages/__tests__/CrowdSecConfig.spec.tsx b/frontend/src/pages/__tests__/CrowdSecConfig.spec.tsx index d6b40b10d..88ca777bd 100644 --- a/frontend/src/pages/__tests__/CrowdSecConfig.spec.tsx +++ b/frontend/src/pages/__tests__/CrowdSecConfig.spec.tsx @@ -85,7 +85,7 @@ describe('CrowdSecConfig', () => { }) vi.mocked(presetsApi.applyCrowdsecPreset).mockResolvedValue({ status: 'applied', - backup: '/tmp/backup.tar.gz', + backup: 'crowdsec.backup.20260101-000000.000000', reload_hint: true, used_cscli: true, cache_key: 'cache-123', @@ -370,7 +370,7 @@ describe('CrowdSecConfig', () => { vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: '' }) vi.mocked(presetsApi.applyCrowdsecPreset).mockResolvedValueOnce({ status: 'applied', - backup: '/tmp/crowdsec-backup', + backup: 'crowdsec.backup.20260101-000000.000000', reload_hint: true, used_cscli: true, cache_key: 'cache-123', @@ -382,7 +382,7 @@ describe('CrowdSecConfig', () => { const applyBtn = await screen.findByTestId('apply-preset-btn') await userEvent.click(applyBtn) - await waitFor(() => expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('/tmp/crowdsec-backup')) + await waitFor(() => expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('crowdsec.backup.20260101-000000.000000')) expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('Status: applied') expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('Method: cscli') // reloadHint is a boolean and renders as empty/true - just verify the info section exists From c8bed1feca6e4ff4293b7d25ee234dc1bb81a8d8 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Fri, 9 Oct 2026 21:25:05 +0000 Subject: [PATCH 05/22] fix: accept archive entries that merely start with two dots --- backend/internal/crowdsec/hub_sync.go | 13 ++++++- backend/internal/crowdsec/hub_sync_test.go | 41 +++++++++++++++------- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/backend/internal/crowdsec/hub_sync.go b/backend/internal/crowdsec/hub_sync.go index e2a60abfa..c2d71415d 100644 --- a/backend/internal/crowdsec/hub_sync.go +++ b/backend/internal/crowdsec/hub_sync.go @@ -943,7 +943,7 @@ func (s *HubService) extractTarGz(ctx context.Context, archive []byte, targetDir continue } cleanName := filepath.Clean(hdr.Name) - if strings.HasPrefix(cleanName, "..") || strings.Contains(cleanName, ".."+string(os.PathSeparator)) || filepath.IsAbs(cleanName) { + if hasParentComponent(cleanName) || filepath.IsAbs(cleanName) { return fmt.Errorf("unsafe path %s", hdr.Name) } if IsEngineOwnedPath(cleanName) { @@ -1037,3 +1037,14 @@ func isGzip(data []byte) bool { } return data[0] == 0x1f && data[1] == 0x8b } + +// hasParentComponent reports whether any path component equals "..". +// Names that merely start with two dots (e.g. "..foo") are legitimate. +func hasParentComponent(name string) bool { + for _, part := range strings.Split(filepath.ToSlash(name), "/") { + if part == ".." { + return true + } + } + return false +} diff --git a/backend/internal/crowdsec/hub_sync_test.go b/backend/internal/crowdsec/hub_sync_test.go index c6c79e9d3..3de639967 100644 --- a/backend/internal/crowdsec/hub_sync_test.go +++ b/backend/internal/crowdsec/hub_sync_test.go @@ -1834,19 +1834,6 @@ func TestExtractTarGz_RejectsBareParentEntry(t *testing.T) { require.Contains(t, err.Error(), "unsafe path") } -func TestExtractTarGz_RejectsDotDotPrefixedName(t *testing.T) { - t.Parallel() - svc := NewHubService(nil, nil, t.TempDir()) - targetDir := t.TempDir() - - // Documents the existing name check: any cleaned name starting with ".." is rejected. - archive := buildSingleEntryTarGz(t, &tar.Header{Name: "..foo", Mode: 0o644, Size: 3}, "abc") - err := svc.extractTarGz(context.Background(), archive, targetDir) - require.Error(t, err) - require.Contains(t, err.Error(), "unsafe path") - require.NoFileExists(t, filepath.Join(targetDir, "..foo")) -} - func TestExtractTarGz_MasksSpecialModeBits(t *testing.T) { t.Parallel() svc := NewHubService(nil, nil, t.TempDir()) @@ -2274,3 +2261,31 @@ func TestNewHubHTTPClient_LoopbackSeam(t *testing.T) { setHubAllowLoopbackForTest(t, true) require.NoError(t, get(), "seam permits loopback in tests") } + +func TestExtractTarGzDotDotNames(t *testing.T) { + t.Parallel() + svc := NewHubService(nil, nil, t.TempDir()) + + for _, name := range []string{"..foo", "a/..b", "...", "a/..."} { + t.Run("accepts "+name, func(t *testing.T) { + t.Parallel() + targetDir := t.TempDir() + archive := makeTarGz(t, map[string]string{name: "ok"}) + require.NoError(t, svc.extractTarGz(context.Background(), archive, targetDir)) + require.FileExists(t, filepath.Join(targetDir, filepath.FromSlash(name))) + }) + } + + for _, name := range []string{"../x", "a/../../x", "/abs", ".."} { + t.Run("rejects "+name, func(t *testing.T) { + t.Parallel() + parent := t.TempDir() + targetDir := filepath.Join(parent, "data") + archive := makeTarGz(t, map[string]string{name: "bad"}) + err := svc.extractTarGz(context.Background(), archive, targetDir) + require.Error(t, err) + require.Contains(t, err.Error(), "unsafe path") + require.NoFileExists(t, filepath.Join(parent, "x")) + }) + } +} From 624c6ebc5ccfb6c9ec364d3f248a844ad3ef48fc Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Fri, 9 Oct 2026 21:28:03 +0000 Subject: [PATCH 06/22] refactor: remove an unused HTTP client option --- backend/internal/crowdsec/hub_sync.go | 5 --- backend/internal/network/safeclient.go | 13 ------- backend/internal/network/safeclient_test.go | 43 --------------------- 3 files changed, 61 deletions(-) diff --git a/backend/internal/crowdsec/hub_sync.go b/backend/internal/crowdsec/hub_sync.go index c2d71415d..ad6e2d785 100644 --- a/backend/internal/crowdsec/hub_sync.go +++ b/backend/internal/crowdsec/hub_sync.go @@ -192,11 +192,6 @@ func NewHubService(exec CommandExecutor, cache *HubCache, dataDir string) *HubSe func newHubHTTPClient(timeout time.Duration) *http.Client { opts := []network.Option{ network.WithTimeout(timeout), - network.WithAllowedDomains( - "hub-data.crowdsec.net", - "hub.crowdsec.net", - "raw.githubusercontent.com", - ), } if hubAllowLoopback { opts = append(opts, network.WithAllowLocalhost()) diff --git a/backend/internal/network/safeclient.go b/backend/internal/network/safeclient.go index 8c0059bd9..4c3f8d1d5 100644 --- a/backend/internal/network/safeclient.go +++ b/backend/internal/network/safeclient.go @@ -262,10 +262,6 @@ type ClientOptions struct { // Use only for testing or when connecting to known-safe local services. AllowLocalhost bool - // AllowedDomains restricts requests to specific domains (optional). - // If set, only these domains will be allowed (in addition to localhost if AllowLocalhost is true). - AllowedDomains []string - // MaxRedirects sets the maximum number of redirects to follow (default: 0) // Set to 0 to disable redirects entirely. MaxRedirects int @@ -303,7 +299,6 @@ func defaultOptions() ClientOptions { return ClientOptions{ Timeout: 10 * time.Second, AllowLocalhost: false, - AllowedDomains: nil, MaxRedirects: 0, DialTimeout: 5 * time.Second, } @@ -324,14 +319,6 @@ func WithAllowLocalhost() Option { } } -// WithAllowedDomains restricts requests to specific domains. -// When set, only requests to these domains will be permitted. -func WithAllowedDomains(domains ...string) Option { - return func(opts *ClientOptions) { - opts.AllowedDomains = append(opts.AllowedDomains, domains...) - } -} - // WithMaxRedirects sets the maximum number of redirects to follow. // Default is 0 (no redirects). Each redirect target is validated against SSRF rules. func WithMaxRedirects(maxRedirects int) Option { diff --git a/backend/internal/network/safeclient_test.go b/backend/internal/network/safeclient_test.go index f78e7f895..d43d8afc0 100644 --- a/backend/internal/network/safeclient_test.go +++ b/backend/internal/network/safeclient_test.go @@ -150,33 +150,6 @@ func TestSafeDialer_AllowsLocalhost(t *testing.T) { _ = conn.Close() } -func TestSafeDialer_AllowedDomains(t *testing.T) { - t.Parallel() - opts := &ClientOptions{ - AllowLocalhost: false, - AllowedDomains: []string{"app.crowdsec.net", "hub.crowdsec.net"}, - DialTimeout: time.Second, - } - dialer := safeDialer(opts) - - // Test that allowed domain passes validation (we can't actually connect) - // This is a structural test - we're verifying the domain check passes - ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer cancel() - - // This will fail to connect (no server) but should NOT fail validation - _, err := dialer(ctx, "tcp", "app.crowdsec.net:443") - if err != nil { - // Check it's a connection error, not a validation error - if _, ok := err.(*net.OpError); !ok { - // Context deadline exceeded is also acceptable (DNS/connection timeout) - if err != context.DeadlineExceeded { - t.Logf("Got expected error type for allowed domain: %T: %v", err, err) - } - } - } -} - func TestNewSafeHTTPClient_DefaultOptions(t *testing.T) { t.Parallel() client := NewSafeHTTPClient() @@ -288,21 +261,6 @@ func TestNewSafeHTTPClient_WithMaxRedirects(t *testing.T) { } } -func TestNewSafeHTTPClient_WithAllowedDomains(t *testing.T) { - t.Parallel() - client := NewSafeHTTPClient( - WithTimeout(2*time.Second), - WithAllowedDomains("example.com"), - ) - - if client == nil { - t.Fatal("NewSafeHTTPClient() returned nil") - } - - // We can't actually connect, but we verify the client is created - // with the correct configuration -} - func TestClientOptions_Defaults(t *testing.T) { t.Parallel() opts := defaultOptions() @@ -846,7 +804,6 @@ func TestClientOptions_AllFunctionalOptions(t *testing.T) { client := NewSafeHTTPClient( WithTimeout(15*time.Second), WithAllowLocalhost(), - WithAllowedDomains("example.com", "api.example.com"), WithMaxRedirects(5), WithDialTimeout(3*time.Second), ) From 4d7434e00471baf6f98c0384372a7d2c61819b8d Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Fri, 9 Oct 2026 21:35:16 +0000 Subject: [PATCH 07/22] refactor: restrict the hub URL check to production hosts --- .../handlers/crowdsec_preset_handler_test.go | 22 +- .../crowdsec_pull_apply_integration_test.go | 14 +- backend/internal/crowdsec/backup_name_test.go | 2 +- .../internal/crowdsec/hub_pull_apply_test.go | 62 ++--- backend/internal/crowdsec/hub_sync.go | 43 ++-- .../crowdsec/hub_sync_raw_index_test.go | 4 +- backend/internal/crowdsec/hub_sync_test.go | 216 +++++++++--------- 7 files changed, 183 insertions(+), 180 deletions(-) diff --git a/backend/internal/api/handlers/crowdsec_preset_handler_test.go b/backend/internal/api/handlers/crowdsec_preset_handler_test.go index 690cc8906..ee04b0b93 100644 --- a/backend/internal/api/handlers/crowdsec_preset_handler_test.go +++ b/backend/internal/api/handlers/crowdsec_preset_handler_test.go @@ -52,9 +52,9 @@ func TestListPresetsIncludesCacheAndIndex(t *testing.T) { require.NoError(t, err) hub := crowdsec.NewHubService(nil, cache, t.TempDir()) - hub.HubBaseURL = "http://example.com" + hub.HubBaseURL = "https://hub.crowdsec.net" hub.HTTPClient = &http.Client{Transport: presetRoundTripper(func(req *http.Request) (*http.Response, error) { - if req.URL.String() == "http://example.com/api/index.json" { + if req.URL.String() == "https://hub.crowdsec.net/api/index.json" { return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"items":[{"name":"crowdsecurity/demo","title":"Demo","description":"desc","type":"collection"}]}`)), Header: make(http.Header)}, nil } return &http.Response{StatusCode: http.StatusNotFound, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil @@ -97,14 +97,14 @@ func TestPullPresetHandlerSuccess(t *testing.T) { archive := makePresetTar(t, map[string]string{"config.yaml": "key: value"}) hub := crowdsec.NewHubService(nil, cache, dataDir) - hub.HubBaseURL = "http://example.com" + hub.HubBaseURL = "https://hub.crowdsec.net" hub.HTTPClient = &http.Client{Transport: presetRoundTripper(func(req *http.Request) (*http.Response, error) { switch req.URL.String() { - case "http://example.com/api/index.json": - return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"items":[{"name":"crowdsecurity/demo","title":"Demo","description":"desc","etag":"e1","download_url":"http://example.com/demo.tgz","preview_url":"http://example.com/demo.yaml"}]}`)), Header: make(http.Header)}, nil - case "http://example.com/demo.yaml": + case "https://hub.crowdsec.net/api/index.json": + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"items":[{"name":"crowdsecurity/demo","title":"Demo","description":"desc","etag":"e1","download_url":"https://hub.crowdsec.net/demo.tgz","preview_url":"https://hub.crowdsec.net/demo.yaml"}]}`)), Header: make(http.Header)}, nil + case "https://hub.crowdsec.net/demo.yaml": return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("preview")), Header: make(http.Header)}, nil - case "http://example.com/demo.tgz": + case "https://hub.crowdsec.net/demo.tgz": return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(archive)), Header: make(http.Header)}, nil default: return &http.Response{StatusCode: http.StatusNotFound, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil @@ -187,7 +187,7 @@ func TestPullPresetHandlerHubError(t *testing.T) { require.NoError(t, err) hub := crowdsec.NewHubService(nil, cache, t.TempDir()) - hub.HubBaseURL = "http://example.com" + hub.HubBaseURL = "https://hub.crowdsec.net" hub.HTTPClient = &http.Client{Transport: presetRoundTripper(func(req *http.Request) (*http.Response, error) { return &http.Response{StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil })} @@ -213,7 +213,7 @@ func TestPullPresetHandlerTimeout(t *testing.T) { require.NoError(t, err) hub := crowdsec.NewHubService(nil, cache, t.TempDir()) - hub.HubBaseURL = "http://example.com" + hub.HubBaseURL = "https://hub.crowdsec.net" hub.HTTPClient = &http.Client{Transport: presetRoundTripper(func(req *http.Request) (*http.Response, error) { return nil, context.DeadlineExceeded })} @@ -319,9 +319,9 @@ func TestApplyPresetHandlerBackupFailure(t *testing.T) { func TestListPresetsMergesCuratedAndHub(t *testing.T) { hub := crowdsec.NewHubService(nil, nil, t.TempDir()) - hub.HubBaseURL = "http://hub.example" + hub.HubBaseURL = "https://hub.crowdsec.net" hub.HTTPClient = &http.Client{Transport: presetRoundTripper(func(req *http.Request) (*http.Response, error) { - if req.URL.String() == "http://hub.example/api/index.json" { + if req.URL.String() == "https://hub.crowdsec.net/api/index.json" { return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"items":[{"name":"crowdsecurity/custom","title":"Custom","description":"d","type":"collection"}]}`)), Header: make(http.Header)}, nil } return nil, errors.New("unexpected request") diff --git a/backend/internal/api/handlers/crowdsec_pull_apply_integration_test.go b/backend/internal/api/handlers/crowdsec_pull_apply_integration_test.go index f714e42d2..6a277e062 100644 --- a/backend/internal/api/handlers/crowdsec_pull_apply_integration_test.go +++ b/backend/internal/api/handlers/crowdsec_pull_apply_integration_test.go @@ -37,16 +37,16 @@ func TestPullThenApplyIntegration(t *testing.T) { }) hub := crowdsec.NewHubService(nil, cache, dataDir) - hub.HubBaseURL = "http://test.hub" + hub.HubBaseURL = "https://hub.crowdsec.net" hub.HTTPClient = &http.Client{ Transport: testRoundTripper(func(req *http.Request) (*http.Response, error) { switch req.URL.String() { - case "http://test.hub/api/index.json": - body := `{"items":[{"name":"test/preset","title":"Test","description":"Test preset","etag":"abc123","download_url":"http://test.hub/test.tgz","preview_url":"http://test.hub/test.yaml"}]}` + case "https://hub.crowdsec.net/api/index.json": + body := `{"items":[{"name":"test/preset","title":"Test","description":"Test preset","etag":"abc123","download_url":"https://hub.crowdsec.net/test.tgz","preview_url":"https://hub.crowdsec.net/test.yaml"}]}` return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil - case "http://test.hub/test.yaml": + case "https://hub.crowdsec.net/test.yaml": return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("preview content")), Header: make(http.Header)}, nil - case "http://test.hub/test.tgz": + case "https://hub.crowdsec.net/test.tgz": return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(archive)), Header: make(http.Header)}, nil default: return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil @@ -119,7 +119,7 @@ func TestApplyWithoutPullReturnsProperError(t *testing.T) { // Empty cache, no cscli hub := crowdsec.NewHubService(nil, cache, dataDir) - hub.HubBaseURL = "http://test.hub" + hub.HubBaseURL = "https://hub.crowdsec.net" hub.HTTPClient = &http.Client{Transport: testRoundTripper(func(req *http.Request) (*http.Response, error) { return &http.Response{StatusCode: http.StatusInternalServerError, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil })} @@ -165,7 +165,7 @@ func TestApplyRollbackWhenCacheMissingAndRepullFails(t *testing.T) { require.NoError(t, err) hub := crowdsec.NewHubService(nil, cache, dataDir) - hub.HubBaseURL = "http://test.hub" + hub.HubBaseURL = "https://hub.crowdsec.net" hub.HTTPClient = &http.Client{Transport: testRoundTripper(func(req *http.Request) (*http.Response, error) { // Force repull failure return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil diff --git a/backend/internal/crowdsec/backup_name_test.go b/backend/internal/crowdsec/backup_name_test.go index dfa90ebb2..23bf807df 100644 --- a/backend/internal/crowdsec/backup_name_test.go +++ b/backend/internal/crowdsec/backup_name_test.go @@ -19,7 +19,7 @@ func TestRedactPaths(t *testing.T) { require.Equal(t, "open : no such file", RedactPaths("open /app/data/x/y.yaml: no such file")) require.Equal(t, "mkdir : denied", RedactPaths("mkdir /tmp/a-b/c: denied")) require.Equal(t, `fetch ""`, RedactPaths(`fetch "/var/lib/x"`)) - require.Equal(t, "fetch https://hub.example.com/a/index.json failed", RedactPaths("fetch https://hub.example.com/a/index.json failed")) + require.Equal(t, "fetch https://hub.crowdsec.net/a/index.json failed", RedactPaths("fetch https://hub.crowdsec.net/a/index.json failed")) require.Equal(t, "plain message", RedactPaths("plain message")) } diff --git a/backend/internal/crowdsec/hub_pull_apply_test.go b/backend/internal/crowdsec/hub_pull_apply_test.go index 5d5f8e6b5..c44a00ef2 100644 --- a/backend/internal/crowdsec/hub_pull_apply_test.go +++ b/backend/internal/crowdsec/hub_pull_apply_test.go @@ -51,24 +51,24 @@ func TestPullThenApplyFlow(t *testing.T) { // Create hub service with mock HTTP client hub := NewHubService(nil, cache, dataDir) - hub.HubBaseURL = "http://test.example.com" + hub.HubBaseURL = "https://hub.crowdsec.net" hub.HTTPClient = &http.Client{ Transport: mockTransport(func(req *http.Request) (*http.Response, error) { switch req.URL.String() { - case "http://test.example.com/api/index.json": - body := `{"items":[{"name":"test/preset","title":"Test Preset","description":"Test","etag":"etag123","download_url":"http://test.example.com/test.tgz","preview_url":"http://test.example.com/test.yaml"}]}` + case "https://hub.crowdsec.net/api/index.json": + body := `{"items":[{"name":"test/preset","title":"Test Preset","description":"Test","etag":"etag123","download_url":"https://hub.crowdsec.net/test.tgz","preview_url":"https://hub.crowdsec.net/test.yaml"}]}` return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header), }, nil - case "http://test.example.com/test.yaml": + case "https://hub.crowdsec.net/test.yaml": return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("test: preview\nkey: value")), Header: make(http.Header), }, nil - case "http://test.example.com/test.tgz": + case "https://hub.crowdsec.net/test.tgz": return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(archive)), @@ -141,15 +141,15 @@ func TestApplyRepullsOnCacheMissAfterCSCLIFailure(t *testing.T) { exec := &stubExec{responses: map[string]error{"install": fmt.Errorf("install failed")}} hub := NewHubService(exec, cache, dataDir) - hub.HubBaseURL = "http://test.example.com" + hub.HubBaseURL = "https://hub.crowdsec.net" hub.HTTPClient = &http.Client{Transport: mockTransport(func(req *http.Request) (*http.Response, error) { switch req.URL.String() { - case "http://test.example.com/api/index.json": + case "https://hub.crowdsec.net/api/index.json": body := `{"items":[{"name":"test/preset","title":"Test","etag":"e1"}]}` return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil - case "http://test.example.com/test/preset.yaml": + case "https://hub.crowdsec.net/test/preset.yaml": return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("preview")), Header: make(http.Header)}, nil - case "http://test.example.com/test/preset.tgz": + case "https://hub.crowdsec.net/test/preset.tgz": return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(archive)), Header: make(http.Header)}, nil default: return &http.Response{StatusCode: http.StatusNotFound, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil @@ -187,15 +187,15 @@ func TestApplyRepullsOnCacheExpired(t *testing.T) { cache.nowFn = func() time.Time { return time.Now().Add(2 * time.Hour) } hub := NewHubService(nil, cache, dataDir) - hub.HubBaseURL = "http://test.example.com" + hub.HubBaseURL = "https://hub.crowdsec.net" hub.HTTPClient = &http.Client{Transport: mockTransport(func(req *http.Request) (*http.Response, error) { switch req.URL.String() { - case "http://test.example.com/api/index.json": + case "https://hub.crowdsec.net/api/index.json": body := `{"items":[{"name":"expired/preset","title":"Expired","etag":"e2"}]}` return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil - case "http://test.example.com/expired/preset.yaml": + case "https://hub.crowdsec.net/expired/preset.yaml": return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("preview new")), Header: make(http.Header)}, nil - case "http://test.example.com/expired/preset.tgz": + case "https://hub.crowdsec.net/expired/preset.tgz": return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(archive)), Header: make(http.Header)}, nil default: return &http.Response{StatusCode: http.StatusNotFound, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil @@ -222,15 +222,15 @@ func TestPullAcceptsNamespacedIndexEntry(t *testing.T) { archive := makeTestArchive(t, map[string]string{"config.yaml": "test: namespaced"}) hub := NewHubService(nil, cache, dataDir) - hub.HubBaseURL = "http://test.example.com" + hub.HubBaseURL = "https://hub.crowdsec.net" hub.HTTPClient = &http.Client{Transport: mockTransport(func(req *http.Request) (*http.Response, error) { switch req.URL.String() { - case "http://test.example.com/api/index.json": + case "https://hub.crowdsec.net/api/index.json": body := `{"items":[{"name":"crowdsecurity/bot-mitigation-essentials","title":"Bot Mitigation Essentials","etag":"etag-bme"}]}` return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil - case "http://test.example.com/crowdsecurity/bot-mitigation-essentials.yaml": + case "https://hub.crowdsec.net/crowdsecurity/bot-mitigation-essentials.yaml": return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("namespaced preview")), Header: make(http.Header)}, nil - case "http://test.example.com/crowdsecurity/bot-mitigation-essentials.tgz": + case "https://hub.crowdsec.net/crowdsecurity/bot-mitigation-essentials.tgz": return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(archive)), Header: make(http.Header)}, nil default: return &http.Response{StatusCode: http.StatusNotFound, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil @@ -254,22 +254,22 @@ func TestHubFallbackToMirrorOnForbidden(t *testing.T) { archive := makeTestArchive(t, map[string]string{"config.yaml": "mirror"}) hub := NewHubService(nil, cache, dataDir) - hub.HubBaseURL = "http://primary.example.com" - hub.MirrorBaseURL = "http://mirror.example.com" + hub.HubBaseURL = "https://hub.crowdsec.net" + hub.MirrorBaseURL = "https://hub-data.crowdsec.net" hub.HTTPClient = &http.Client{Transport: mockTransport(func(req *http.Request) (*http.Response, error) { switch req.URL.String() { - case "http://primary.example.com/api/index.json": + case "https://hub.crowdsec.net/api/index.json": return &http.Response{StatusCode: http.StatusForbidden, Body: io.NopCloser(strings.NewReader("blocked")), Header: make(http.Header)}, nil - case "http://mirror.example.com/api/index.json": + case "https://hub-data.crowdsec.net/api/index.json": body := `{"items":[{"name":"fallback/preset","title":"Fallback","etag":"etag-mirror"}]}` return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil - case "http://primary.example.com/fallback/preset.yaml": + case "https://hub.crowdsec.net/fallback/preset.yaml": return &http.Response{StatusCode: http.StatusForbidden, Body: io.NopCloser(strings.NewReader("blocked")), Header: make(http.Header)}, nil - case "http://mirror.example.com/fallback/preset.yaml": + case "https://hub-data.crowdsec.net/fallback/preset.yaml": return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("mirror preview")), Header: make(http.Header)}, nil - case "http://primary.example.com/fallback/preset.tgz": + case "https://hub.crowdsec.net/fallback/preset.tgz": return &http.Response{StatusCode: http.StatusForbidden, Body: io.NopCloser(strings.NewReader("blocked")), Header: make(http.Header)}, nil - case "http://mirror.example.com/fallback/preset.tgz": + case "https://hub-data.crowdsec.net/fallback/preset.tgz": return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(archive)), Header: make(http.Header)}, nil default: return &http.Response{StatusCode: http.StatusNotFound, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil @@ -293,7 +293,7 @@ func TestApplyWithoutPullFails(t *testing.T) { // Create hub service without cscli (nil executor) and empty cache hub := NewHubService(nil, cache, dataDir) - hub.HubBaseURL = "http://test.example.com" + hub.HubBaseURL = "https://hub.crowdsec.net" hub.HTTPClient = &http.Client{Transport: mockTransport(func(req *http.Request) (*http.Response, error) { return &http.Response{StatusCode: http.StatusInternalServerError, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil })} @@ -344,16 +344,16 @@ func TestCacheListAfterPull(t *testing.T) { archive := makeTestArchive(t, map[string]string{"test.yaml": "content"}) hub := NewHubService(nil, cache, dataDir) - hub.HubBaseURL = "http://test.example.com" + hub.HubBaseURL = "https://hub.crowdsec.net" hub.HTTPClient = &http.Client{ Transport: mockTransport(func(req *http.Request) (*http.Response, error) { switch req.URL.String() { - case "http://test.example.com/api/index.json": + case "https://hub.crowdsec.net/api/index.json": body := `{"items":[{"name":"preset1","title":"Preset 1","etag":"e1"}]}` return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil - case "http://test.example.com/preset1.yaml": + case "https://hub.crowdsec.net/preset1.yaml": return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("preview1")), Header: make(http.Header)}, nil - case "http://test.example.com/preset1.tgz": + case "https://hub.crowdsec.net/preset1.tgz": return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(archive)), Header: make(http.Header)}, nil default: return &http.Response{StatusCode: http.StatusNotFound, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil @@ -451,7 +451,7 @@ func TestApplyReadsArchiveBeforeBackup(t *testing.T) { // Create hub service WITHOUT cscli (nil executor) to force cache fallback path hub := NewHubService(nil, cache, dataDir) - hub.HubBaseURL = "http://test.example.com" + hub.HubBaseURL = "https://hub.crowdsec.net" // HTTP client that fails everything - we don't want to hit network hub.HTTPClient = &http.Client{ Transport: mockTransport(func(req *http.Request) (*http.Response, error) { diff --git a/backend/internal/crowdsec/hub_sync.go b/backend/internal/crowdsec/hub_sync.go index ad6e2d785..7a320ff68 100644 --- a/backend/internal/crowdsec/hub_sync.go +++ b/backend/internal/crowdsec/hub_sync.go @@ -85,22 +85,18 @@ type HubService struct { PullTimeout time.Duration ApplyTimeout time.Duration + // validateURL overrides validateHubURL when non-nil. Test-only: production + // code never sets it, so every hub request goes through validateHubURL. + validateURL func(string) error + // mu serializes Apply and ApplyCurated so backup/rollback cycles never interleave. mu sync.Mutex } -// hubAllowLoopback is a test-only seam. It is always false in production and is -// only toggled from _test.go files. Tests that toggle it must not call -// t.Parallel(), as it is shared package state. -var hubAllowLoopback bool - // validateHubURL validates a hub URL for security (SSRF protection - HIGH-001). // This function prevents Server-Side Request Forgery by: // 1. Enforcing HTTPS for production hub URLs // 2. Allowlisting known CrowdSec hub domains -// 3. Accepting localhost/test hostnames at this layer; the dial layer -// (network.NewSafeHTTPClient) still blocks loopback and private targets -// unless the test-only hubAllowLoopback seam is set // // Returns: error if URL is invalid or not allowlisted func validateHubURL(rawURL string) error { @@ -109,7 +105,7 @@ func validateHubURL(rawURL string) error { return fmt.Errorf("invalid URL format: %w", err) } - // Only allow http/https schemes + // Only allow http/https schemes at parse level; https is enforced below if parsed.Scheme != "http" && parsed.Scheme != "https" { return fmt.Errorf("unsupported scheme: %s (only http and https are allowed)", parsed.Scheme) } @@ -119,16 +115,7 @@ func validateHubURL(rawURL string) error { return fmt.Errorf("missing hostname in URL") } - // Allow localhost and test domains for development/testing - // This is safe because tests control the mock servers - if host == "localhost" || host == "127.0.0.1" || host == "::1" || - strings.HasSuffix(host, ".example.com") || strings.HasSuffix(host, ".example") || - host == "example.com" || strings.HasSuffix(host, ".local") || - host == "test.hub" { // Allow test.hub for integration tests - return nil - } - - // For production URLs, must be HTTPS + // Hub URLs must be HTTPS if parsed.Scheme != "https" { return fmt.Errorf("hub URLs must use HTTPS (got: %s)", parsed.Scheme) } @@ -155,6 +142,15 @@ func validateHubURL(rawURL string) error { return nil } +// checkURL validates target with the test override when set, otherwise with +// the production validateHubURL. +func (s *HubService) checkURL(target string) error { + if s.validateURL != nil { + return s.validateURL(target) + } + return validateHubURL(target) +} + // NewHubService constructs a HubService with sane defaults. func NewHubService(exec CommandExecutor, cache *HubCache, dataDir string) *HubService { pullTimeout := defaultPullTimeout @@ -187,15 +183,12 @@ func NewHubService(exec CommandExecutor, cache *HubCache, dataDir string) *HubSe // Hub URLs are validated by validateHubURL() which: // - Enforces HTTPS for production // - Allowlists known CrowdSec domains (hub-data.crowdsec.net, hub.crowdsec.net, raw.githubusercontent.com) -// - Blocks loopback unless the test-only hubAllowLoopback seam is set +// - Blocks loopback and private targets at the dial layer // Using network.NewSafeHTTPClient provides defense-in-depth at the connection level. func newHubHTTPClient(timeout time.Duration) *http.Client { opts := []network.Option{ network.WithTimeout(timeout), } - if hubAllowLoopback { - opts = append(opts, network.WithAllowLocalhost()) - } return network.NewSafeHTTPClient(opts...) } @@ -445,7 +438,7 @@ func (h hubHTTPError) CanFallback() bool { func (s *HubService) fetchIndexHTTPFromURL(ctx context.Context, target string) (HubIndex, error) { // CRITICAL FIX: Validate hub URL before making HTTP request (HIGH-001) - if err := validateHubURL(target); err != nil { + if err := s.checkURL(target); err != nil { return HubIndex{}, fmt.Errorf("invalid hub URL: %w", err) } @@ -746,7 +739,7 @@ func (s *HubService) fetchWithFallback(ctx context.Context, urls []string) (data func (s *HubService) fetchWithLimitFromURL(ctx context.Context, url string) ([]byte, error) { // CRITICAL FIX: Validate hub URL before making HTTP request (HIGH-001) - if err := validateHubURL(url); err != nil { + if err := s.checkURL(url); err != nil { return nil, fmt.Errorf("invalid hub URL: %w", err) } diff --git a/backend/internal/crowdsec/hub_sync_raw_index_test.go b/backend/internal/crowdsec/hub_sync_raw_index_test.go index 619b7f29d..b33a5f45f 100644 --- a/backend/internal/crowdsec/hub_sync_raw_index_test.go +++ b/backend/internal/crowdsec/hub_sync_raw_index_test.go @@ -10,7 +10,7 @@ import ( func TestFetchIndexParsesRawIndexFormat(t *testing.T) { svc := NewHubService(nil, nil, t.TempDir()) - svc.HubBaseURL = "http://example.com" + svc.HubBaseURL = "https://hub.crowdsec.net" // This JSON represents the "raw" index format (map of maps) which has no "items" field. // json.Unmarshal into HubIndex will succeed but result in empty Items. @@ -33,7 +33,7 @@ func TestFetchIndexParsesRawIndexFormat(t *testing.T) { }` svc.HTTPClient = &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { - if req.URL.String() == "http://example.com"+defaultHubIndexPath { + if req.URL.String() == "https://hub.crowdsec.net"+defaultHubIndexPath { resp := newResponse(http.StatusOK, rawIndexBody) resp.Header.Set("Content-Type", "application/json") return resp, nil diff --git a/backend/internal/crowdsec/hub_sync_test.go b/backend/internal/crowdsec/hub_sync_test.go index 3de639967..15858765c 100644 --- a/backend/internal/crowdsec/hub_sync_test.go +++ b/backend/internal/crowdsec/hub_sync_test.go @@ -114,6 +114,7 @@ func TestFetchIndexFallbackHTTP(t *testing.T) { defer hubServer.Close() svc.HubBaseURL = hubServer.URL + svc.validateURL = func(string) error { return nil } // local httptest server is not an allowlisted hub host svc.HTTPClient = hubServer.Client() idx, err := svc.FetchIndex(context.Background()) @@ -128,7 +129,7 @@ func TestFetchIndexHTTPRejectsRedirect(t *testing.T) { } t.Parallel() svc := NewHubService(nil, nil, t.TempDir()) - svc.HubBaseURL = "http://hub.example" + svc.HubBaseURL = "https://hub.crowdsec.net" svc.HTTPClient = &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { resp := newResponse(http.StatusMovedPermanently, "") resp.Header.Set("Location", "https://hub.crowdsec.net/") @@ -229,14 +230,14 @@ func TestPullCachesPreview(t *testing.T) { archiveBytes := makeTarGz(t, map[string]string{"config.yaml": "value: 1"}) svc := NewHubService(nil, cache, dataDir) - svc.HubBaseURL = "http://example.com" + svc.HubBaseURL = "https://hub.crowdsec.net" svc.HTTPClient = &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { switch req.URL.String() { - case "http://example.com" + defaultHubIndexPath: - return newResponse(http.StatusOK, `{"items":[{"name":"crowdsecurity/demo","title":"Demo","description":"desc","type":"collection","etag":"etag1","download_url":"http://example.com/demo.tgz","preview_url":"http://example.com/demo.yaml"}]}`), nil - case "http://example.com/demo.yaml": + case "https://hub.crowdsec.net" + defaultHubIndexPath: + return newResponse(http.StatusOK, `{"items":[{"name":"crowdsecurity/demo","title":"Demo","description":"desc","type":"collection","etag":"etag1","download_url":"https://hub.crowdsec.net/demo.tgz","preview_url":"https://hub.crowdsec.net/demo.yaml"}]}`), nil + case "https://hub.crowdsec.net/demo.yaml": return newResponse(http.StatusOK, "preview-body"), nil - case "http://example.com/demo.tgz": + case "https://hub.crowdsec.net/demo.tgz": return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(archiveBytes)), Header: make(http.Header)}, nil default: return newResponse(http.StatusNotFound, ""), nil @@ -341,14 +342,14 @@ func TestPullEvictsExpiredCacheAndRefreshes(t *testing.T) { cache.nowFn = func() time.Time { return fixed.Add(3 * time.Second) } svc := NewHubService(nil, cache, t.TempDir()) - svc.HubBaseURL = "http://example.com" + svc.HubBaseURL = "https://hub.crowdsec.net" svc.HTTPClient = &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { switch req.URL.String() { - case "http://example.com" + defaultHubIndexPath: - return newResponse(http.StatusOK, `{"items":[{"name":"crowdsecurity/demo","title":"Demo","description":"desc","type":"collection","etag":"etag2","download_url":"http://example.com/demo.tgz","preview_url":"http://example.com/demo.yaml"}]}`), nil - case "http://example.com/demo.yaml": + case "https://hub.crowdsec.net" + defaultHubIndexPath: + return newResponse(http.StatusOK, `{"items":[{"name":"crowdsecurity/demo","title":"Demo","description":"desc","type":"collection","etag":"etag2","download_url":"https://hub.crowdsec.net/demo.tgz","preview_url":"https://hub.crowdsec.net/demo.yaml"}]}`), nil + case "https://hub.crowdsec.net/demo.yaml": return newResponse(http.StatusOK, "fresh-preview"), nil - case "http://example.com/demo.tgz": + case "https://hub.crowdsec.net/demo.tgz": return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(archive)), Header: make(http.Header)}, nil default: return newResponse(http.StatusNotFound, ""), nil @@ -368,15 +369,15 @@ func TestPullFallsBackToArchivePreview(t *testing.T) { archive := makeTarGz(t, map[string]string{"scenarios/demo.yaml": "title: demo"}) svc := NewHubService(nil, cache, t.TempDir()) - svc.HubBaseURL = "http://example.com" + svc.HubBaseURL = "https://hub.crowdsec.net" svc.HTTPClient = &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { - if req.URL.String() == "http://example.com"+defaultHubIndexPath { - return newResponse(http.StatusOK, `{"items":[{"name":"crowdsecurity/demo","title":"Demo","etag":"etag1","download_url":"http://example.com/demo.tgz","preview_url":"http://example.com/demo.yaml"}]}`), nil + if req.URL.String() == "https://hub.crowdsec.net"+defaultHubIndexPath { + return newResponse(http.StatusOK, `{"items":[{"name":"crowdsecurity/demo","title":"Demo","etag":"etag1","download_url":"https://hub.crowdsec.net/demo.tgz","preview_url":"https://hub.crowdsec.net/demo.yaml"}]}`), nil } - if req.URL.String() == "http://example.com/demo.yaml" { + if req.URL.String() == "https://hub.crowdsec.net/demo.yaml" { return newResponse(http.StatusInternalServerError, ""), nil } - if req.URL.String() == "http://example.com/demo.tgz" { + if req.URL.String() == "https://hub.crowdsec.net/demo.tgz" { return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(archive)), Header: make(http.Header)}, nil } return newResponse(http.StatusNotFound, ""), nil @@ -395,7 +396,7 @@ func TestPullFallsBackToMirrorArchiveOnForbidden(t *testing.T) { archiveBytes := makeTarGz(t, map[string]string{"config.yml": "foo: bar"}) svc := NewHubService(nil, cache, dataDir) - svc.HubBaseURL = "https://primary.example" + svc.HubBaseURL = "https://hub.crowdsec.net" svc.MirrorBaseURL = defaultHubMirrorBaseURL calls := make([]string, 0) @@ -403,13 +404,13 @@ func TestPullFallsBackToMirrorArchiveOnForbidden(t *testing.T) { svc.HTTPClient = &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { calls = append(calls, req.URL.String()) switch req.URL.String() { - case "https://primary.example/api/index.json": + case "https://hub.crowdsec.net/api/index.json": resp := newResponse(http.StatusOK, indexBody) resp.Header.Set("Content-Type", "application/json") return resp, nil - case "https://primary.example/crowdsecurity/demo.tgz": + case "https://hub.crowdsec.net/crowdsecurity/demo.tgz": return newResponse(http.StatusForbidden, ""), nil - case "https://primary.example/crowdsecurity/demo.yaml": + case "https://hub.crowdsec.net/crowdsecurity/demo.yaml": return newResponse(http.StatusForbidden, ""), nil case defaultHubMirrorBaseURL + "/.index.json": resp := newResponse(http.StatusOK, indexBody) @@ -441,7 +442,7 @@ func TestFetchWithLimitRejectsLargePayload(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(big)), Header: make(http.Header)}, nil })} - _, err := svc.fetchWithLimitFromURL(context.Background(), "http://example.com/large.tgz") + _, err := svc.fetchWithLimitFromURL(context.Background(), "https://hub.crowdsec.net/large.tgz") require.Error(t, err) require.Contains(t, err.Error(), "payload too large") } @@ -511,7 +512,7 @@ func TestPullValidatesSlugAndMissingPreset(t *testing.T) { cache, cacheErr := NewHubCache(t.TempDir(), time.Hour) require.NoError(t, cacheErr) svc.Cache = cache - svc.HubBaseURL = "http://hub.example" + svc.HubBaseURL = "https://hub.crowdsec.net" svc.HTTPClient = &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { return newResponse(http.StatusOK, `{"items":[{"name":"crowdsecurity/other","title":"Other","description":"d","type":"collection"}]}`), nil })} @@ -531,7 +532,7 @@ func TestFetchWithLimitRequiresClient(t *testing.T) { t.Parallel() svc := NewHubService(nil, nil, t.TempDir()) svc.HTTPClient = nil - _, err := svc.fetchWithLimitFromURL(context.Background(), "http://example.com/demo.tgz") + _, err := svc.fetchWithLimitFromURL(context.Background(), "https://hub.crowdsec.net/demo.tgz") require.Error(t, err) } @@ -568,7 +569,7 @@ func TestFetchIndexCSCLIParseError(t *testing.T) { t.Parallel() exec := &recordingExec{outputs: map[string][]byte{"cscli hub list -o json": []byte("not-json")}} svc := NewHubService(exec, nil, t.TempDir()) - svc.HubBaseURL = "http://hub.example" + svc.HubBaseURL = "https://hub.crowdsec.net" svc.HTTPClient = &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { return newResponse(http.StatusInternalServerError, ""), nil })} @@ -580,12 +581,12 @@ func TestFetchIndexCSCLIParseError(t *testing.T) { func TestFetchWithLimitStatusError(t *testing.T) { t.Parallel() svc := NewHubService(nil, nil, t.TempDir()) - svc.HubBaseURL = "http://hub.example" + svc.HubBaseURL = "https://hub.crowdsec.net" svc.HTTPClient = &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { return newResponse(http.StatusNotFound, ""), nil })} - _, err := svc.fetchWithLimitFromURL(context.Background(), "http://hub.example/demo.tgz") + _, err := svc.fetchWithLimitFromURL(context.Background(), "https://hub.crowdsec.net/demo.tgz") require.Error(t, err) } @@ -839,9 +840,9 @@ func TestIndexURLCandidates_GitHubMirror(t *testing.T) { func TestBuildResourceURLs_DeduplicatesExplicitAndBases(t *testing.T) { t.Parallel() - urls := buildResourceURLs("https://hub.example/preset.tgz", "crowdsecurity/demo", "/%s.tgz", []string{"https://hub.example", "https://hub.example"}) + urls := buildResourceURLs("https://hub.crowdsec.net/preset.tgz", "crowdsecurity/demo", "/%s.tgz", []string{"https://hub.crowdsec.net", "https://hub.crowdsec.net"}) require.NotEmpty(t, urls) - require.Equal(t, "https://hub.example/preset.tgz", urls[0]) + require.Equal(t, "https://hub.crowdsec.net/preset.tgz", urls[0]) require.Len(t, urls, 2) } @@ -849,9 +850,9 @@ func TestHubHTTPErrorMethods(t *testing.T) { t.Parallel() inner := errors.New("inner") - err := hubHTTPError{url: "https://hub.example", statusCode: 404, inner: inner, fallback: true} + err := hubHTTPError{url: "https://hub.crowdsec.net", statusCode: 404, inner: inner, fallback: true} - require.Contains(t, err.Error(), "https://hub.example") + require.Contains(t, err.Error(), "https://hub.crowdsec.net") require.ErrorIs(t, err, inner) require.True(t, err.CanFallback()) } @@ -991,27 +992,43 @@ func TestValidateHubURL_InvalidSchemes(t *testing.T) { } } -func TestValidateHubURL_LocalhostExceptions(t *testing.T) { +func TestValidateHubURL_RejectsNonProductionHosts(t *testing.T) { t.Parallel() - localhostURLs := []string{ + nonProductionURLs := []string{ "http://localhost:8080/index.json", "http://127.0.0.1:8080/index.json", "http://[::1]:8080/index.json", - "http://test.hub/api/index.json", + "https://localhost/index.json", + "https://127.0.0.1/index.json", + "https://[::1]/index.json", + "https://test.hub/api/index.json", + "https://example.com/api/index.json", + "https://test.example.com/api/index.json", + "https://server.local/api/index.json", "http://example.com/api/index.json", - "http://test.example.com/api/index.json", - "http://server.local/api/index.json", } - for _, url := range localhostURLs { + for _, url := range nonProductionURLs { t.Run(url, func(t *testing.T) { t.Parallel() - err := validateHubURL(url) - require.NoError(t, err, "Expected localhost/test domain to be allowed") + require.Error(t, validateHubURL(url), "Expected non-production host to be rejected") }) } } +func TestNewHubHTTPClient_RejectsLoopback(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + resp, err := newHubHTTPClient(2 * time.Second).Get(srv.URL) + if err == nil { + _ = resp.Body.Close() + } + require.Error(t, err, "hub client must reject loopback") +} + func TestValidateHubURL_UnknownDomainRejection(t *testing.T) { t.Parallel() unknownURLs := []string{ @@ -1101,7 +1118,7 @@ func TestParseRawIndex(t *testing.T) { } }` - idx, err := parseRawIndex([]byte(rawJSON), "https://hub.example.com/api/index.json") + idx, err := parseRawIndex([]byte(rawJSON), "https://hub.crowdsec.net/api/index.json") require.NoError(t, err) require.Len(t, idx.Items, 2) @@ -1122,14 +1139,14 @@ func TestParseRawIndex(t *testing.T) { t.Run("returns error on invalid JSON", func(t *testing.T) { t.Parallel() - _, err := parseRawIndex([]byte("not json"), "https://hub.example.com") + _, err := parseRawIndex([]byte("not json"), "https://hub.crowdsec.net") require.Error(t, err) require.Contains(t, err.Error(), "parse raw index") }) t.Run("returns error on empty index", func(t *testing.T) { t.Parallel() - _, err := parseRawIndex([]byte("{}"), "https://hub.example.com") + _, err := parseRawIndex([]byte("{}"), "https://hub.crowdsec.net") require.Error(t, err) require.Contains(t, err.Error(), "empty raw index") }) @@ -1154,7 +1171,7 @@ func TestFetchIndexHTTPFromURL_HTMLDetection(t *testing.T) { return resp, nil })} - _, err := svc.fetchIndexHTTPFromURL(context.Background(), "http://test.hub/index.json") + _, err := svc.fetchIndexHTTPFromURL(context.Background(), "https://hub.crowdsec.net/index.json") require.Error(t, err) require.Contains(t, err.Error(), "HTML") } @@ -1195,11 +1212,11 @@ func TestHubService_Apply_CacheRefresh(t *testing.T) { // Reset time to trigger expiration cache.nowFn = time.Now - indexBody := `{"items":[{"name":"test/preset","title":"Test","etag":"etag2","download_url":"http://test.hub/preset.tgz"}]}` + indexBody := `{"items":[{"name":"test/preset","title":"Test","etag":"etag2","download_url":"https://hub.crowdsec.net/preset.tgz"}]}` newArchive := makeTarGz(t, map[string]string{"config.yml": "new"}) svc := NewHubService(nil, cache, dataDir) - svc.HubBaseURL = "http://test.hub" + svc.HubBaseURL = "https://hub.crowdsec.net" svc.HTTPClient = &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { if strings.Contains(req.URL.String(), "index.json") { return newResponse(http.StatusOK, indexBody), nil @@ -1443,14 +1460,14 @@ func TestHubHTTPErrorError(t *testing.T) { t.Parallel() inner := errors.New("connection refused") err := hubHTTPError{ - url: "https://hub.example.com/index.json", + url: "https://hub.crowdsec.net/index.json", statusCode: 503, inner: inner, fallback: true, } msg := err.Error() - require.Contains(t, msg, "https://hub.example.com/index.json") + require.Contains(t, msg, "https://hub.crowdsec.net/index.json") require.Contains(t, msg, "503") require.Contains(t, msg, "connection refused") }) @@ -1458,14 +1475,14 @@ func TestHubHTTPErrorError(t *testing.T) { t.Run("error without inner error", func(t *testing.T) { t.Parallel() err := hubHTTPError{ - url: "https://hub.example.com/index.json", + url: "https://hub.crowdsec.net/index.json", statusCode: 404, inner: nil, fallback: false, } msg := err.Error() - require.Contains(t, msg, "https://hub.example.com/index.json") + require.Contains(t, msg, "https://hub.crowdsec.net/index.json") require.Contains(t, msg, "404") require.NotContains(t, msg, "nil") }) @@ -1477,7 +1494,7 @@ func TestHubHTTPErrorUnwrap(t *testing.T) { t.Parallel() inner := errors.New("underlying error") err := hubHTTPError{ - url: "https://hub.example.com", + url: "https://hub.crowdsec.net", statusCode: 500, inner: inner, } @@ -1489,7 +1506,7 @@ func TestHubHTTPErrorUnwrap(t *testing.T) { t.Run("unwrap returns nil when no inner", func(t *testing.T) { t.Parallel() err := hubHTTPError{ - url: "https://hub.example.com", + url: "https://hub.crowdsec.net", statusCode: 500, inner: nil, } @@ -1502,7 +1519,7 @@ func TestHubHTTPErrorUnwrap(t *testing.T) { t.Parallel() inner := context.Canceled err := hubHTTPError{ - url: "https://hub.example.com", + url: "https://hub.crowdsec.net", statusCode: 0, inner: inner, } @@ -1517,7 +1534,7 @@ func TestHubHTTPErrorCanFallback(t *testing.T) { t.Run("returns true when fallback is true", func(t *testing.T) { t.Parallel() err := hubHTTPError{ - url: "https://hub.example.com", + url: "https://hub.crowdsec.net", statusCode: 503, fallback: true, } @@ -1528,7 +1545,7 @@ func TestHubHTTPErrorCanFallback(t *testing.T) { t.Run("returns false when fallback is false", func(t *testing.T) { t.Parallel() err := hubHTTPError{ - url: "https://hub.example.com", + url: "https://hub.crowdsec.net", statusCode: 404, fallback: false, } @@ -1607,29 +1624,39 @@ func TestValidateHubURL_EdgeCases(t *testing.T) { errorMsg: "unsupported scheme", }, { - name: "Test domain allowed", - url: "http://test.hub/api/index.json", + name: "Allowlisted hosts over HTTPS accepted", + url: "https://raw.githubusercontent.com/crowdsecurity/hub/master/.index.json", wantError: false, }, { - name: "Example.com allowed for testing", - url: "http://example.com/index.json", - wantError: false, + name: "Test domain rejected", + url: "https://test.hub/api/index.json", + wantError: true, + errorMsg: "unknown hub domain", }, { - name: ".local domain allowed", - url: "http://myserver.local/index.json", - wantError: false, + name: "Example.com rejected", + url: "https://example.com/index.json", + wantError: true, + errorMsg: "unknown hub domain", }, { - name: "Subdomain of example.com allowed", - url: "http://test.example.com/index.json", - wantError: false, + name: ".local domain rejected", + url: "https://myserver.local/index.json", + wantError: true, + errorMsg: "unknown hub domain", + }, + { + name: "Plain HTTP loopback rejected", + url: "http://localhost:8080/index.json", + wantError: true, + errorMsg: "must use HTTPS", }, { - name: "IPv6 loopback allowed", + name: "IPv6 loopback rejected", url: "http://[::1]:8080/index.json", - wantError: false, + wantError: true, + errorMsg: "must use HTTPS", }, { name: "Unknown production domain rejected", @@ -1723,20 +1750,20 @@ func TestNewHubService_EnvVarTimeouts_Whitespace(t *testing.T) { func TestNewHubService_CustomHubBaseURL(t *testing.T) { // Note: Cannot use t.Parallel() with t.Setenv() - t.Setenv("HUB_BASE_URL", "https://custom.hub.example.com") + t.Setenv("HUB_BASE_URL", "https://hub.crowdsec.net") svc := NewHubService(nil, nil, t.TempDir()) - require.Equal(t, "https://custom.hub.example.com", svc.HubBaseURL) + require.Equal(t, "https://hub.crowdsec.net", svc.HubBaseURL) } func TestNewHubService_CustomMirrorBaseURL(t *testing.T) { // Note: Cannot use t.Parallel() with t.Setenv() - t.Setenv("HUB_MIRROR_BASE_URL", "https://mirror.example.com") + t.Setenv("HUB_MIRROR_BASE_URL", "https://hub-data.crowdsec.net") svc := NewHubService(nil, nil, t.TempDir()) - require.Equal(t, "https://mirror.example.com", svc.MirrorBaseURL) + require.Equal(t, "https://hub-data.crowdsec.net", svc.MirrorBaseURL) } // ============================================ @@ -2052,7 +2079,7 @@ func TestFetchIndexHTTPFromURL_ParseRawIndexFallback(t *testing.T) { return resp, nil })} - idx, err := svc.fetchIndexHTTPFromURL(context.Background(), "http://test.hub/.index.json") + idx, err := svc.fetchIndexHTTPFromURL(context.Background(), "https://hub.crowdsec.net/.index.json") require.NoError(t, err) require.Len(t, idx.Items, 1) require.Equal(t, "crowdsecurity/nginx", idx.Items[0].Name) @@ -2077,7 +2104,7 @@ func TestFetchIndexHTTPFromURL_EmptyJSONArray(t *testing.T) { // Empty items array triggers raw index parsing (map[string]map[string]...), which succeeds // but returns empty index. This is actually valid JSON but semantically empty. // The code returns idx even if empty in this case (no error), so we should not expect an error. - idx, err := svc.fetchIndexHTTPFromURL(context.Background(), "http://test.hub/index.json") + idx, err := svc.fetchIndexHTTPFromURL(context.Background(), "https://hub.crowdsec.net/index.json") require.NoError(t, err) require.Empty(t, idx.Items, "should parse successfully but return empty items") } @@ -2095,7 +2122,7 @@ func TestFetchIndexHTTPFromURL_InvalidJSON(t *testing.T) { return resp, nil })} - _, err := svc.fetchIndexHTTPFromURL(context.Background(), "http://test.hub/index.json") + _, err := svc.fetchIndexHTTPFromURL(context.Background(), "https://hub.crowdsec.net/index.json") require.Error(t, err) } @@ -2232,36 +2259,6 @@ func TestFindIndexEntry_EmptySlug(t *testing.T) { require.False(t, found) } -// setHubAllowLoopbackForTest toggles the package-level seam. Callers must not -// use t.Parallel(). -func setHubAllowLoopbackForTest(t *testing.T, v bool) { - t.Helper() - prev := hubAllowLoopback - hubAllowLoopback = v - t.Cleanup(func() { hubAllowLoopback = prev }) -} - -// Must not run in parallel: relies on shared seam state. -func TestNewHubHTTPClient_LoopbackSeam(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - - get := func() error { - resp, err := newHubHTTPClient(2 * time.Second).Get(srv.URL) - if err == nil { - _ = resp.Body.Close() - } - return err - } - - require.Error(t, get(), "default client must reject loopback") - - setHubAllowLoopbackForTest(t, true) - require.NoError(t, get(), "seam permits loopback in tests") -} - func TestExtractTarGzDotDotNames(t *testing.T) { t.Parallel() svc := NewHubService(nil, nil, t.TempDir()) @@ -2289,3 +2286,16 @@ func TestExtractTarGzDotDotNames(t *testing.T) { }) } } + +func TestHubBaseCandidates_DistinctRolesKeepOrder(t *testing.T) { + t.Parallel() + svc := NewHubService(nil, nil, t.TempDir()) + svc.HubBaseURL = "https://hub.crowdsec.net" + svc.MirrorBaseURL = defaultHubMirrorBaseURL + + require.Equal(t, []string{ + "https://hub.crowdsec.net", + defaultHubMirrorBaseURL, + defaultHubBaseURL, + }, svc.hubBaseCandidates()) +} From cc204495e9cd5979b3c0388d94c6fa7c5bbed8f7 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Fri, 9 Oct 2026 21:41:32 +0000 Subject: [PATCH 08/22] test: enable the CrowdSec editor and response checks and document the changes Enable the end-to-end checks added earlier and describe the editor, backup naming and hub address behavior in the user docs. --- docs/features.md | 2 ++ docs/features/crowdsec.md | 2 ++ docs/troubleshooting/crowdsec.md | 3 +++ tests/security/crowdsec-file-editor.spec.ts | 23 ++++++++++++++------- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/docs/features.md b/docs/features.md index 75d4777f3..04f830b25 100644 --- a/docs/features.md +++ b/docs/features.md @@ -105,6 +105,8 @@ Protect your applications using behavior-based threat detection powered by a glo Preset changes are backed up first and rolled back automatically if anything goes wrong, and CrowdSec's downloaded data now survives container recreation. +The CrowdSec file editor no longer lists CrowdSec's account and connection credential files, and backups are shown by name. + → [Learn More](features/crowdsec.md) • [Setup Guide](guides/crowdsec-setup.md) --- diff --git a/docs/features/crowdsec.md b/docs/features/crowdsec.md index 2b1c66e79..c83193053 100644 --- a/docs/features/crowdsec.md +++ b/docs/features/crowdsec.md @@ -65,6 +65,8 @@ Hub presets are applied on the server too, with the same safety net: before anyt - Charon keeps the 5 most recent full backups and the 10 most recent single-file backups, and quietly deletes older ones. - Only one change runs at a time, so two edits can't collide. - The config file editor only accepts common text and settings files (`.yaml`, `.yml`, `.json`, `.txt`, `.conf`) up to 1 MiB, and validates file type, size and YAML. +- The config file editor does not list CrowdSec's account and connection credential files. They stay safely on disk; an operator can still reach them from the container shell if needed. +- Backups are shown by name and are stored next to the CrowdSec data folder (for example `/app/data/`). Full snapshots are named `crowdsec.backup.`, single-file saves `crowdsec.filebackup.`. Saved acquisition settings are backed up as `acquis.yaml.backup.` right beside the acquisition file instead. To recover by hand, stop CrowdSec, copy the backup you want back into place, and start it again. ### Console Enrollment diff --git a/docs/troubleshooting/crowdsec.md b/docs/troubleshooting/crowdsec.md index 7279858a4..5eb44dd40 100644 --- a/docs/troubleshooting/crowdsec.md +++ b/docs/troubleshooting/crowdsec.md @@ -15,8 +15,11 @@ Keep Cerberus terminology and the Configuration Packages flow in mind while debu - Bare-metal deployments: install cscli for Hub preset sync or use HTTP fallback with HUB_BASE_URL. - HUB_BASE_URL points to a JSON hub endpoint (default: ). Redirects to HTML will be rejected. - A loopback hub mirror is no longer reachable from the hub client; RFC 1918 hosts were already blocked. + - The hub address must be an `https` address on the official CrowdSec hub hosts. Custom `http`, local, or other mirror addresses are not supported. - Proxy env is set when required: HTTP(S)_PROXY and NO_PROXY are respected by the hub client. - For slow or proxied networks, increase HUB_PULL_TIMEOUT_SECONDS (default 25) and HUB_APPLY_TIMEOUT_SECONDS (default 45) to avoid premature timeouts. +- The config file editor hides CrowdSec's account and connection credential files; they remain on disk (see `docker exec charon ls -la /app/data/crowdsec/config`). +- Backups sit next to the CrowdSec data folder, named `crowdsec.backup.` and `crowdsec.filebackup.`. Acquisition settings are backed up as `acquis.yaml.backup.` right beside the acquisition file. For manual recovery, run `docker exec charon ls -la /app/data` to find them, stop CrowdSec, copy the one you want back, then start it again. - Preset workflow: pull from Hub using cache keys/ETags → preview changes → apply with automatic backup and reload flag. - Preset pull/apply requires either cscli or cached presets. - Offline/curated presets remain available at all times. diff --git a/tests/security/crowdsec-file-editor.spec.ts b/tests/security/crowdsec-file-editor.spec.ts index def21c549..c920b2b9b 100644 --- a/tests/security/crowdsec-file-editor.spec.ts +++ b/tests/security/crowdsec-file-editor.spec.ts @@ -142,12 +142,11 @@ test.describe('CrowdSec File Editor @security', () => { }); }); - // Enabled by commit A6 once the A1 (credentials hidden) and A2 (name-only backups) backend changes land. test.describe('credentials files and response paths (live API)', () => { const CREDENTIAL_FILES = ['local_api_credentials.yaml', 'online_api_credentials.yaml']; const ABSOLUTE_PATH = /(^|["'\s(])\/(app|data|etc|tmp|var|home)\//; - test.fixme('should omit both credentials files from the file list', async ({ page }) => { + test('should omit both credentials files from the file list', async ({ page }) => { const response = await page.request.get('/api/v1/admin/crowdsec/files'); expect(response.ok()).toBe(true); const body = (await response.json()) as { files: string[] }; @@ -157,13 +156,13 @@ test.describe('CrowdSec File Editor @security', () => { }); for (const name of CREDENTIAL_FILES) { - test.fixme(`should reject reading ${name} with invalid path`, async ({ page }) => { + test(`should reject reading ${name} with invalid path`, async ({ page }) => { const response = await page.request.get(`/api/v1/admin/crowdsec/file?path=${encodeURIComponent(name)}`); expect(response.status()).toBe(400); expect(await response.json()).toMatchObject({ error: 'invalid path' }); }); - test.fixme(`should reject writing ${name} with file cannot be edited`, async ({ page }) => { + test(`should reject writing ${name} with file cannot be edited`, async ({ page }) => { const response = await page.request.post('/api/v1/admin/crowdsec/file', { data: { path: name, content: 'url: http://127.0.0.1:8080\n' }, }); @@ -172,7 +171,7 @@ test.describe('CrowdSec File Editor @security', () => { }); } - test.fixme('should not leak an absolute path in rejection bodies', async ({ page }) => { + test('should not leak an absolute path in rejection bodies', async ({ page }) => { const read = await page.request.get(`/api/v1/admin/crowdsec/file?path=${encodeURIComponent(CREDENTIAL_FILES[0])}`); const write = await page.request.post('/api/v1/admin/crowdsec/file', { data: { path: CREDENTIAL_FILES[1], content: 'x: y\n' }, @@ -181,13 +180,21 @@ test.describe('CrowdSec File Editor @security', () => { expect(await write.text()).not.toMatch(ABSOLUTE_PATH); }); - test.fixme('should return only a backup name, never an absolute path, on a successful write', async ({ page }) => { - const read = await page.request.get(`/api/v1/admin/crowdsec/file?path=${encodeURIComponent(SELECTED_PATH)}`); + test('should return only a backup name, never an absolute path, on a successful write', async ({ page }) => { + // Pick a real, editable config.yaml from the live listing rather than assuming its location, + // and write it back unchanged so repeated runs leave the config untouched (no backup-count assumptions). + const list = await page.request.get('/api/v1/admin/crowdsec/files'); + expect(list.ok()).toBe(true); + const { files } = (await list.json()) as { files: string[] }; + const target = files.find((f) => f === SELECTED_PATH || f.endsWith(`/${SELECTED_PATH}`)); + expect(target, 'live file list should include config.yaml').toBeTruthy(); + + const read = await page.request.get(`/api/v1/admin/crowdsec/file?path=${encodeURIComponent(target!)}`); expect(read.ok()).toBe(true); const { content } = (await read.json()) as { content: string }; const write = await page.request.post('/api/v1/admin/crowdsec/file', { - data: { path: SELECTED_PATH, content }, + data: { path: target, content }, }); expect(write.ok()).toBe(true); const body = (await write.json()) as { backup?: string }; From ac9f0597f9e8f3cfa13a0d5b18f4aea9503c9cb7 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Fri, 9 Oct 2026 21:48:44 +0000 Subject: [PATCH 09/22] test(e2e): shared CrowdSec stub helpers --- tests/utils/crowdsec-stubs.ts | 211 ++++++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 tests/utils/crowdsec-stubs.ts diff --git a/tests/utils/crowdsec-stubs.ts b/tests/utils/crowdsec-stubs.ts new file mode 100644 index 000000000..d0163c59c --- /dev/null +++ b/tests/utils/crowdsec-stubs.ts @@ -0,0 +1,211 @@ +/** + * Shared CrowdSec API stubs for deterministic Playwright specs. + * + * The E2E container has no cscli/LAPI, so CrowdSec specs stub the admin API with + * `page.route`. Every route matcher here is an ANCHORED regular expression. A loose + * glob such as `** /crowdsec/file**` also matches `/crowdsec/files`, which once made a + * file-content stub swallow the file-list request. + * + * Install stubs BEFORE navigating to the page under test. + */ + +import type { Page, Route } from '@playwright/test'; + +const ADMIN = String.raw`/api/v1/admin/crowdsec`; +/** Optional query string, then end of URL. */ +const END = String.raw`(?:\?.*)?$`; + +/** Anchored matchers for each CrowdSec admin endpoint. */ +export const CROWDSEC_ROUTES = { + featureFlags: new RegExp(String.raw`/api/v1/feature-flags${END}`), + status: new RegExp(`${ADMIN}/status${END}`), + decisions: new RegExp(`${ADMIN}/decisions${END}`), + consoleStatus: new RegExp(`${ADMIN}/console/status${END}`), + consoleEnroll: new RegExp(`${ADMIN}/console/enroll${END}`), + consoleEnrollment: new RegExp(`${ADMIN}/console/enrollment${END}`), + files: new RegExp(`${ADMIN}/files${END}`), + file: new RegExp(`${ADMIN}/file${END}`), + export: new RegExp(`${ADMIN}/export${END}`), + diagnosticsConfig: new RegExp(`${ADMIN}/diagnostics/config${END}`), + diagnosticsConnectivity: new RegExp(`${ADMIN}/diagnostics/connectivity${END}`), +} as const; + +export interface CrowdSecProcessStatus { + running: boolean; + pid: number; + lapi_ready: boolean; +} + +export interface ConsoleEnrollmentState { + status: string; + key_present: boolean; + tenant?: string; + agent_name?: string; + last_error?: string; + last_attempt_at?: string; + enrolled_at?: string; + last_heartbeat_at?: string; + correlation_id?: string; +} + +export interface CrowdSecDecisionFixture { + id: string; + ip: string; + reason: string; + duration: string; + created_at: string; + source: string; +} + +/** Fixtures with sensible defaults; override per test. */ +export const crowdsecFixtures = { + runningStatus: (overrides: Partial = {}): CrowdSecProcessStatus => ({ + running: true, + pid: 1234, + lapi_ready: true, + ...overrides, + }), + notEnrolled: (): ConsoleEnrollmentState => ({ status: 'not_enrolled', key_present: false }), + enrollment: (overrides: Partial = {}): ConsoleEnrollmentState => ({ + status: 'enrolled', + key_present: true, + agent_name: 'e2e-agent', + tenant: 'e2e-tenant', + ...overrides, + }), + decision: (overrides: Partial = {}): CrowdSecDecisionFixture => ({ + id: 'decision-1', + ip: '203.0.113.7', + reason: 'e2e brute force', + duration: '24h', + created_at: '2026-01-01T00:00:00Z', + source: 'manual', + ...overrides, + }), + diagnosticsConfig: (overrides: Record = {}): Record => ({ + config_exists: true, + config_valid: true, + acquis_exists: true, + lapi_port: '8085', + errors: [], + ...overrides, + }), + diagnosticsConnectivity: (overrides: Record = {}): Record => ({ + lapi_running: true, + lapi_ready: true, + capi_registered: false, + console_enrolled: false, + ...overrides, + }), +}; + +export interface CrowdSecStubOptions { + /** Enables the console enrollment feature flag. Default false. */ + consoleEnrollmentEnabled?: boolean; + /** Body for GET /status. */ + status?: CrowdSecProcessStatus; + /** Body for GET /console/status. */ + consoleStatus?: ConsoleEnrollmentState; + /** Body for GET /decisions. */ + decisions?: CrowdSecDecisionFixture[]; + /** File names returned by GET /files. */ + files?: string[]; + /** Map of file path to content for GET /file?path=. Unknown paths get 404. */ + fileContents?: Record; + /** Archive bytes returned by GET /export. */ + exportBody?: Buffer; + diagnosticsConfig?: Record; + diagnosticsConnectivity?: Record; +} + +/** Requests recorded by the stubs, for assertions on what the UI actually sent. */ +export interface CrowdSecStubRecorder { + fileReads: string[]; + exportRequests: number; +} + +async function fulfillJson(route: Route, json: unknown, status = 200): Promise { + await route.fulfill({ status, contentType: 'application/json', json }); +} + +/** + * Stubs the CrowdSec admin API endpoints for the options provided. Endpoints whose + * option is omitted are left untouched (they hit the real backend). + * Only GET requests are stubbed unless noted; other methods fall through. + */ +export async function stubCrowdSecApi( + page: Page, + options: CrowdSecStubOptions = {}, +): Promise { + const recorder: CrowdSecStubRecorder = { fileReads: [], exportRequests: 0 }; + + if (options.consoleEnrollmentEnabled !== undefined) { + const enabled = options.consoleEnrollmentEnabled; + await page.route(CROWDSEC_ROUTES.featureFlags, async (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + const response = await route.fetch(); + const flags = (await response.json()) as Record; + await fulfillJson(route, { ...flags, 'feature.crowdsec.console_enrollment': enabled }); + }); + } + + if (options.status) { + const body = options.status; + await page.route(CROWDSEC_ROUTES.status, (route) => + route.request().method() === 'GET' ? fulfillJson(route, body) : route.fallback(), + ); + } + + if (options.consoleStatus) { + const body = options.consoleStatus; + await page.route(CROWDSEC_ROUTES.consoleStatus, (route) => + route.request().method() === 'GET' ? fulfillJson(route, body) : route.fallback(), + ); + } + + if (options.decisions) { + const decisions = options.decisions; + await page.route(CROWDSEC_ROUTES.decisions, (route) => + route.request().method() === 'GET' ? fulfillJson(route, { decisions }) : route.fallback(), + ); + } + + if (options.files) { + const files = options.files; + await page.route(CROWDSEC_ROUTES.files, (route) => + route.request().method() === 'GET' ? fulfillJson(route, { files }) : route.fallback(), + ); + } + + if (options.fileContents) { + const contents = options.fileContents; + await page.route(CROWDSEC_ROUTES.file, async (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + const path = new URL(route.request().url()).searchParams.get('path') ?? ''; + recorder.fileReads.push(path); + if (!(path in contents)) return fulfillJson(route, { error: 'file not found' }, 404); + return fulfillJson(route, { content: contents[path] }); + }); + } + + if (options.exportBody) { + const body = options.exportBody; + await page.route(CROWDSEC_ROUTES.export, async (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + recorder.exportRequests += 1; + await route.fulfill({ status: 200, contentType: 'application/gzip', body }); + }); + } + + if (options.diagnosticsConfig) { + const body = options.diagnosticsConfig; + await page.route(CROWDSEC_ROUTES.diagnosticsConfig, (route) => fulfillJson(route, body)); + } + + if (options.diagnosticsConnectivity) { + const body = options.diagnosticsConnectivity; + await page.route(CROWDSEC_ROUTES.diagnosticsConnectivity, (route) => fulfillJson(route, body)); + } + + return recorder; +} From 29c0ead99d0521c7d3aee50ec32d055ec264c2cd Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Fri, 9 Oct 2026 21:48:51 +0000 Subject: [PATCH 10/22] test(e2e): deterministic crowdsec-config non-preset cases --- tests/security/crowdsec-config.spec.ts | 408 +++++++++++++++++++------ 1 file changed, 317 insertions(+), 91 deletions(-) diff --git a/tests/security/crowdsec-config.spec.ts b/tests/security/crowdsec-config.spec.ts index a65bf523a..543fbf7ec 100644 --- a/tests/security/crowdsec-config.spec.ts +++ b/tests/security/crowdsec-config.spec.ts @@ -6,13 +6,28 @@ * - Preset management (view, apply, preview) * - Configuration file management * - Import/Export functionality - * - Console enrollment (if enabled) + * - Console enrollment (stubbed API states) * * @see /projects/Charon/docs/plans/current_spec.md */ +import type { Page } from '@playwright/test'; import { test, expect, loginUser } from '../fixtures/auth-fixtures'; import { waitForLoadingComplete } from '../utils/wait-helpers'; +import { + CROWDSEC_ROUTES, + crowdsecFixtures, + stubCrowdSecApi, + type CrowdSecStubOptions, +} from '../utils/crowdsec-stubs'; + +/** Installs the CrowdSec API stubs, then (re)loads the configuration page so they take effect. */ +async function openWithStubs(page: Page, options: CrowdSecStubOptions) { + const recorder = await stubCrowdSecApi(page, options); + await page.goto('/security/crowdsec'); + await waitForLoadingComplete(page); + return recorder; +} test.describe('CrowdSec Configuration @security', () => { test.beforeEach(async ({ page, adminUser }) => { @@ -28,29 +43,9 @@ test.describe('CrowdSec Configuration @security', () => { await expect(page.getByRole('heading', { name: /crowdsec/i }).first()).toBeVisible(); }); - test('should show navigation back to security dashboard', async ({ page }) => { - // Should have breadcrumb, back link, or navigation element - const backLink = page.getByRole('link', { name: /security|back/i }); - const backLinkVisible = await backLink.isVisible().catch(() => false); - - if (backLinkVisible) { - await expect(backLink).toBeVisible(); - } else { - // Check for navigation button instead - const backButton = page.getByRole('button', { name: /back/i }); - const buttonVisible = await backButton.isVisible().catch(() => false); - - if (!buttonVisible) { - // Check for breadcrumbs or navigation in header - const breadcrumb = page.locator('nav, [class*="breadcrumb"], [aria-label*="breadcrumb"]'); - const breadcrumbVisible = await breadcrumb.isVisible().catch(() => false); - - // At minimum, the page should be loaded - if (!breadcrumbVisible) { - await expect(page).toHaveURL(/crowdsec/); - } - } - } + test('should navigate back to the security dashboard from the page note', async ({ page }) => { + await page.getByRole('link', { name: 'Cerberus', exact: true }).click(); + await expect(page).toHaveURL(/\/security$/); }); test('should display presets section', async ({ page }) => { @@ -96,43 +91,97 @@ test.describe('CrowdSec Configuration @security', () => { }); test.describe('Configuration Files', () => { - test('should display configuration file list', async ({ page }) => { - // Look for file selector or list - const fileSelector = page.locator('select, [role="listbox"]').filter({ - hasNotText: /sort|filter/i - }).first(); + const FILES = ['acquis.yaml', 'config.yaml']; + const CONTENTS: Record = { + 'acquis.yaml': 'filenames:\n - /var/log/caddy/access.log\n', + 'config.yaml': 'common:\n log_level: info\n', + }; + + test.beforeEach(async ({ page }) => { + await openWithStubs(page, { files: FILES, fileContents: CONTENTS }); + }); - const filesVisible = await fileSelector.isVisible().catch(() => false); + test('should list the configuration files in the file selector', async ({ page }) => { + const selector = page.getByRole('combobox', { name: 'Select a file...' }); + await expect(selector).toBeVisible(); + await expect(selector.getByRole('option')).toHaveText(['Select a file...', ...FILES]); + }); - if (filesVisible) { - await expect(fileSelector).toBeVisible(); - } + test('should show the file content when a file is selected', async ({ page }) => { + const selector = page.getByRole('combobox', { name: 'Select a file...' }); + const content = page.locator('textarea'); + + await test.step('No content is shown before a file is chosen', async () => { + await expect(content).toHaveValue(''); + }); + + await test.step('Select the first file', async () => { + await selector.selectOption('acquis.yaml'); + await expect(content).toHaveValue(CONTENTS['acquis.yaml']); + }); + + await test.step('Select the second file', async () => { + await selector.selectOption('config.yaml'); + await expect(content).toHaveValue(CONTENTS['config.yaml']); + }); }); - test('should show file content when selected', async ({ page }) => { - // Find file selector - const fileSelector = page.locator('select').first(); - const selectorVisible = await fileSelector.isVisible().catch(() => false); + test('should clear the content when the editor is closed', async ({ page }) => { + const selector = page.getByRole('combobox', { name: 'Select a file...' }); + const content = page.locator('textarea'); + await selector.selectOption('config.yaml'); + await expect(content).toHaveValue(CONTENTS['config.yaml']); - if (selectorVisible) { - // Select a file - content area should appear - const contentArea = page.locator('textarea, pre, [class*="editor"]'); - const contentVisible = await contentArea.isVisible().catch(() => false); + await page.getByRole('button', { name: 'Close', exact: true }).click(); + await expect(content).toHaveValue(''); + }); - // Content area may or may not be visible depending on selection - expect(contentVisible !== undefined).toBeTruthy(); - } + test('should expose the file editor with an accessible name', async ({ page }) => { + test.fixme(true, 'F2: files.content, packages.selectFile and presets.sortBy translation keys are missing, so aria-labels are raw keys'); + await expect(page.getByRole('textbox', { name: /file content/i })).toBeVisible(); }); }); test.describe('Import/Export', () => { - test('should have export functionality', async ({ page }) => { - const exportButton = page.getByRole('button', { name: /export/i }); - const exportVisible = await exportButton.isVisible().catch(() => false); + const ARCHIVE = Buffer.from('stub-archive-bytes'); - if (exportVisible) { - await expect(exportButton).toBeEnabled(); - } + test('should export the configuration under the chosen file name', async ({ page }) => { + const stubs = await openWithStubs(page, { exportBody: ARCHIVE }); + + page.once('dialog', (dialog) => { + expect(dialog.type()).toBe('prompt'); + void dialog.accept('my-backup'); + }); + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Export', exact: true }).click(); + const download = await downloadPromise; + + expect(download.suggestedFilename()).toBe('my-backup.tar.gz'); + expect(stubs.exportRequests).toBe(1); + await expect(page.getByText('CrowdSec configuration exported')).toBeVisible(); + }); + + test('should not export when the file name prompt is cancelled', async ({ page }) => { + const stubs = await openWithStubs(page, { exportBody: ARCHIVE }); + + page.once('dialog', (dialog) => void dialog.dismiss()); + await page.getByRole('button', { name: 'Export', exact: true }).click(); + + await expect(page.getByText('CrowdSec configuration exported')).toHaveCount(0); + expect(stubs.exportRequests).toBe(0); + }); + + test('should show an error when the export fails', async ({ page }) => { + await openWithStubs(page, {}); + await page.route(CROWDSEC_ROUTES.export, (route) => + route.fulfill({ status: 500, contentType: 'application/json', json: { error: 'export failed' } }), + ); + + page.once('dialog', (dialog) => void dialog.accept('my-backup')); + await page.getByRole('button', { name: 'Export', exact: true }).click(); + + await expect(page.getByText('Failed to export CrowdSec configuration')).toBeVisible(); + await expect(page.getByText('CrowdSec configuration exported')).toHaveCount(0); }); test('should have import functionality', async ({ page }) => { @@ -142,66 +191,243 @@ test.describe('CrowdSec Configuration @security', () => { }); test.describe('Console Enrollment', () => { - test('should display console enrollment section if feature enabled', async ({ page }) => { - // Console enrollment is a feature-flagged component - const enrollmentSection = page.getByTestId('console-section').or( - page.locator('[class*="card"]').filter({ hasText: /console.*enrollment|enroll/i }) - ); + const consoleCard = (page: Page) => page.getByTestId('console-enrollment-card'); - const enrollmentVisible = await enrollmentSection.isVisible().catch(() => false); + test('should hide the console enrollment section when the feature is disabled', async ({ page }) => { + await openWithStubs(page, { consoleEnrollmentEnabled: false, consoleStatus: crowdsecFixtures.notEnrolled() }); - if (enrollmentVisible) { - await test.step('Verify enrollment form elements', async () => { - // Should have token input - const tokenInput = page.getByTestId('crowdsec-token-input').or( - page.getByPlaceholder(/token|key/i) - ); - await expect(tokenInput).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Presets', level: 3 })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Console Enrollment', level: 3 })).toHaveCount(0); + }); + + test('should show the enrollment form when the feature is enabled', async ({ page }) => { + await openWithStubs(page, { + consoleEnrollmentEnabled: true, + consoleStatus: crowdsecFixtures.notEnrolled(), + status: crowdsecFixtures.runningStatus(), + }); + + await expect(page.getByRole('heading', { name: 'Console Enrollment', level: 3 })).toBeVisible(); + await expect(consoleCard(page).getByLabel('Enrollment Token', { exact: true })).toBeVisible(); + await expect(consoleCard(page).getByLabel('Agent Name (optional)')).toBeVisible(); + await expect(consoleCard(page).getByRole('button', { name: 'Enroll', exact: true })).toBeVisible(); + await expect(page.getByText('Status: not enrolled')).toBeVisible(); + await expect(page.getByText('Not stored')).toBeVisible(); + }); + + const STATES = [ + { + name: 'pending acceptance', + state: crowdsecFixtures.enrollment({ status: 'pending_acceptance' }), + label: 'Status: pending acceptance', + token: 'Stored (masked)', + }, + { + name: 'enrolled', + state: crowdsecFixtures.enrollment({ + status: 'enrolled', + last_heartbeat_at: '2026-01-02T03:04:05Z', + enrolled_at: '2026-01-01T00:00:00Z', + }), + label: 'Status: enrolled', + token: 'Stored (masked)', + }, + { + name: 'failed', + state: crowdsecFixtures.enrollment({ status: 'failed', last_error: 'key rejected by console' }), + label: 'Status: degraded', + token: 'Stored (masked)', + }, + ]; + + for (const { name, state, label, token } of STATES) { + test(`should display the ${name} enrollment state`, async ({ page }) => { + await openWithStubs(page, { + consoleEnrollmentEnabled: true, + consoleStatus: state, + status: crowdsecFixtures.runningStatus(), }); - } else { - // Feature might be disabled - that's OK - test.info().annotations.push({ - type: 'info', - description: 'Console enrollment feature not enabled' + + await expect(page.getByText(label, { exact: true })).toBeVisible(); + await expect(page.getByTestId('console-token-state')).toHaveText(token); + await expect(consoleCard(page).getByText('e2e-agent', { exact: true })).toBeVisible(); + }); + } + + test('should show the heartbeat time and enrolled actions for an enrolled agent', async ({ page }) => { + await openWithStubs(page, { + consoleEnrollmentEnabled: true, + consoleStatus: crowdsecFixtures.enrollment({ last_heartbeat_at: '2026-01-02T03:04:05Z' }), + status: crowdsecFixtures.runningStatus(), + }); + + await expect(page.getByText(/Last heartbeat: .*2026/)).toBeVisible(); + await expect(page.getByTestId('reenroll-section')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Rotate Key' })).toBeEnabled(); + }); + + test('should show the acceptance instructions while enrollment is pending acceptance', async ({ page }) => { + await openWithStubs(page, { + consoleEnrollmentEnabled: true, + consoleStatus: crowdsecFixtures.enrollment({ status: 'pending_acceptance' }), + status: crowdsecFixtures.runningStatus(), + }); + + await expect(page.getByTestId('pending-acceptance-info')).toContainText('Action Required'); + }); + + test('should show the last error and a retry action for a failed enrollment', async ({ page }) => { + await openWithStubs(page, { + consoleEnrollmentEnabled: true, + consoleStatus: crowdsecFixtures.enrollment({ status: 'failed', last_error: 'key rejected by console' }), + status: crowdsecFixtures.runningStatus(), + }); + + await expect(page.getByTestId('console-status-error')).toContainText('key rejected by console'); + await expect(page.getByRole('button', { name: 'Retry Enrollment' })).toBeVisible(); + }); + + test('should label the last error in readable text', async ({ page }) => { + test.fixme(true, 'F1: consoleEnrollment.lastError translation key is missing, so the last-error line renders a raw key'); + await openWithStubs(page, { + consoleEnrollmentEnabled: true, + consoleStatus: crowdsecFixtures.enrollment({ status: 'failed', last_error: 'key rejected by console' }), + status: crowdsecFixtures.runningStatus(), + }); + + await expect(page.getByTestId('console-status-error')).toHaveText('Last error: key rejected by console'); + }); + + test('should submit the enrollment and report success', async ({ page }) => { + await openWithStubs(page, { + consoleEnrollmentEnabled: true, + consoleStatus: crowdsecFixtures.notEnrolled(), + status: crowdsecFixtures.runningStatus(), + }); + let payload: unknown; + await page.route(CROWDSEC_ROUTES.consoleEnroll, async (route) => { + payload = route.request().postDataJSON(); + await route.fulfill({ + status: 200, + contentType: 'application/json', + json: crowdsecFixtures.enrollment({ status: 'pending_acceptance', agent_name: 'e2e-agent', tenant: 'e2e-tenant' }), }); - } + }); + + const card = consoleCard(page); + await card.getByLabel('Enrollment Token', { exact: true }).fill('stub-enrollment-key'); + await card.getByLabel('Agent Name (optional)').fill('e2e-agent'); + await card.getByLabel('Tenant/Organization (optional)').fill('e2e-tenant'); + await card.getByRole('checkbox', { name: /I understand this will rotate/ }).check(); + await card.getByRole('button', { name: 'Enroll', exact: true }).click(); + + await expect(page.getByText(/Enrollment request sent!/)).toBeVisible(); + expect(payload).toEqual({ + enrollment_key: 'stub-enrollment-key', + tenant: 'e2e-tenant', + agent_name: 'e2e-agent', + force: false, + }); + }); + + test('should require the acknowledgement before enrolling', async ({ page }) => { + await openWithStubs(page, { + consoleEnrollmentEnabled: true, + consoleStatus: crowdsecFixtures.notEnrolled(), + status: crowdsecFixtures.runningStatus(), + }); + let enrollRequests = 0; + await page.route(CROWDSEC_ROUTES.consoleEnroll, (route) => { + enrollRequests += 1; + return route.fulfill({ status: 200, contentType: 'application/json', json: crowdsecFixtures.notEnrolled() }); + }); + + const card = consoleCard(page); + await card.getByLabel('Enrollment Token', { exact: true }).fill('stub-enrollment-key'); + await card.getByRole('button', { name: 'Enroll', exact: true }).click(); + + await expect(page.getByText('You must acknowledge the console data-sharing notice')).toBeVisible(); + expect(enrollRequests).toBe(0); }); - test('should show enrollment status when enrolled', async ({ page }) => { - const statusText = page.getByTestId('console-token-state').or( - page.locator('text=/enrolled|pending|not enrolled/i') + test('should surface the backend error when enrollment is rejected', async ({ page }) => { + await openWithStubs(page, { + consoleEnrollmentEnabled: true, + consoleStatus: crowdsecFixtures.notEnrolled(), + status: crowdsecFixtures.runningStatus(), + }); + await page.route(CROWDSEC_ROUTES.consoleEnroll, (route) => + route.fulfill({ status: 400, contentType: 'application/json', json: { error: 'invalid key' } }), ); - const statusVisible = await statusText.isVisible().catch(() => false); + const card = consoleCard(page); + await card.getByLabel('Enrollment Token', { exact: true }).fill('bad-key'); + await card.getByLabel('Tenant/Organization (optional)').fill('e2e-tenant'); + await card.getByRole('checkbox', { name: /I understand this will rotate/ }).check(); + await card.getByRole('button', { name: 'Enroll', exact: true }).click(); - if (statusVisible) { - // Verify status is displayed - await expect(statusText).toBeVisible(); - } + await expect(page.getByTestId('console-enroll-error')).toContainText('invalid key'); + await expect(page.getByText(/Enrollment request sent!/)).toHaveCount(0); }); }); test.describe('Status Indicators', () => { - test('should display CrowdSec running status', async ({ page }) => { - // Look for status indicators - const statusBadge = page.locator('[class*="badge"]').filter({ - hasText: /running|stopped|enabled|disabled/i + // The LAPI banners appear only after the page's 3 second start-up grace period. + const BANNER_TIMEOUT = 15_000; + const ENROLL_FLAGS = { consoleEnrollmentEnabled: true, consoleStatus: crowdsecFixtures.notEnrolled() }; + + test('should allow enrollment and show no warnings when CrowdSec and LAPI are ready', async ({ page }) => { + await openWithStubs(page, { ...ENROLL_FLAGS, status: crowdsecFixtures.runningStatus() }); + + const card = page.getByTestId('console-enrollment-card'); + await card.getByLabel('Enrollment Token', { exact: true }).fill('stub-enrollment-key'); + const enroll = card.getByRole('button', { name: 'Enroll', exact: true }); + await page.waitForResponse(CROWDSEC_ROUTES.status); + + await expect(enroll).toBeEnabled(); + await expect(page.getByTestId('lapi-warning')).toHaveCount(0); + await expect(page.getByTestId('lapi-not-running-warning')).toHaveCount(0); + }); + + test('should warn and block enrollment while CrowdSec is running but LAPI is not ready', async ({ page }) => { + await openWithStubs(page, { + ...ENROLL_FLAGS, + status: crowdsecFixtures.runningStatus({ lapi_ready: false }), }); - const statusVisible = await statusBadge.first().isVisible().catch(() => false); + const card = page.getByTestId('console-enrollment-card'); + await card.getByLabel('Enrollment Token', { exact: true }).fill('stub-enrollment-key'); - if (statusVisible) { - await expect(statusBadge.first()).toBeVisible(); - } + await expect(page.getByTestId('lapi-warning')).toBeVisible({ timeout: BANNER_TIMEOUT }); + await expect(page.getByTestId('lapi-not-running-warning')).toHaveCount(0); + await expect(card.getByRole('button', { name: 'Enroll', exact: true })).toBeDisabled(); + await expect(page.getByTestId('lapi-warning').getByRole('button', { name: 'Check Now' })).toBeEnabled(); }); - test('should display LAPI status', async ({ page }) => { - // LAPI ready status might be displayed - const lapiStatus = page.getByText(/lapi.*ready|lapi.*status/i); - const lapiVisible = await lapiStatus.isVisible().catch(() => false); + test('should warn and block enrollment when CrowdSec is not running', async ({ page }) => { + await openWithStubs(page, { + ...ENROLL_FLAGS, + status: crowdsecFixtures.runningStatus({ running: false, pid: 0, lapi_ready: false }), + }); + + const warning = page.getByTestId('lapi-not-running-warning'); + await expect(warning).toBeVisible({ timeout: BANNER_TIMEOUT }); + await expect(page.getByTestId('lapi-warning')).toHaveCount(0); + await expect(warning.getByRole('button', { name: 'Check Now' })).toBeEnabled(); + await expect(page.getByTestId('console-enrollment-card').getByRole('button', { name: 'Enroll', exact: true })).toBeDisabled(); + }); + + test('should show readable text in the LAPI warnings', async ({ page }) => { + test.fixme(true, 'F1: crowdsecConfig.lapiInitializing, notRunning, startCrowdsec, goToSecurity and related translation keys are missing, so the warnings render raw keys'); + await openWithStubs(page, { + ...ENROLL_FLAGS, + status: crowdsecFixtures.runningStatus({ running: false, pid: 0, lapi_ready: false }), + }); - // LAPI status might not always be visible - expect(lapiVisible !== undefined).toBeTruthy(); + const warning = page.getByTestId('lapi-not-running-warning'); + await expect(warning).toBeVisible({ timeout: BANNER_TIMEOUT }); + await expect(warning).not.toContainText('crowdsecConfig.'); + await expect(warning.getByRole('button', { name: 'Start CrowdSec' })).toBeVisible(); }); }); From 1e7757ac2e9bb85b6e0d135a2ffcb0dbcef0202f Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Fri, 9 Oct 2026 22:11:39 +0000 Subject: [PATCH 11/22] test(e2e): deterministic crowdsec dashboard and decisions Rewrite the dashboard and banned IPs specs against stubbed CrowdSec endpoints so every assertion runs on known data instead of tolerating whatever the engine-less E2E container returns. Dashboard covers tabs, summary cards, charts, active decisions sorting, alerts paging, time range selection, refresh and export, including empty and error states. Banned IPs covers listing, the disabled and error states, and the exact ban and unban requests. Non-existent search, filter, refresh and navigation controls are removed rather than skipped. Extend the shared stub helper with security status mode, dashboard, alerts, export and ban endpoints plus request recording. Point the two known fixme cases in crowdsec-config.spec.ts at #1530, where the findings from that spec are now tracked. --- tests/security/crowdsec-config.spec.ts | 6 +- tests/security/crowdsec-dashboard.spec.ts | 716 ++++++++++++++++------ tests/security/crowdsec-decisions.spec.ts | 470 +++++++------- tests/utils/crowdsec-stubs.ts | 190 +++++- 4 files changed, 951 insertions(+), 431 deletions(-) diff --git a/tests/security/crowdsec-config.spec.ts b/tests/security/crowdsec-config.spec.ts index 543fbf7ec..71a789017 100644 --- a/tests/security/crowdsec-config.spec.ts +++ b/tests/security/crowdsec-config.spec.ts @@ -137,7 +137,7 @@ test.describe('CrowdSec Configuration @security', () => { }); test('should expose the file editor with an accessible name', async ({ page }) => { - test.fixme(true, 'F2: files.content, packages.selectFile and presets.sortBy translation keys are missing, so aria-labels are raw keys'); + test.fixme(true, '#1530 F2: files.content, packages.selectFile and presets.sortBy translation keys are missing, so aria-labels are raw keys'); await expect(page.getByRole('textbox', { name: /file content/i })).toBeVisible(); }); }); @@ -288,7 +288,7 @@ test.describe('CrowdSec Configuration @security', () => { }); test('should label the last error in readable text', async ({ page }) => { - test.fixme(true, 'F1: consoleEnrollment.lastError translation key is missing, so the last-error line renders a raw key'); + test.fixme(true, '#1530 F1: consoleEnrollment.lastError translation key is missing, so the last-error line renders a raw key'); await openWithStubs(page, { consoleEnrollmentEnabled: true, consoleStatus: crowdsecFixtures.enrollment({ status: 'failed', last_error: 'key rejected by console' }), @@ -418,7 +418,7 @@ test.describe('CrowdSec Configuration @security', () => { }); test('should show readable text in the LAPI warnings', async ({ page }) => { - test.fixme(true, 'F1: crowdsecConfig.lapiInitializing, notRunning, startCrowdsec, goToSecurity and related translation keys are missing, so the warnings render raw keys'); + test.fixme(true, '#1530 F1: crowdsecConfig.lapiInitializing, notRunning, startCrowdsec, goToSecurity and related translation keys are missing, so the warnings render raw keys'); await openWithStubs(page, { ...ENROLL_FLAGS, status: crowdsecFixtures.runningStatus({ running: false, pid: 0, lapi_ready: false }), diff --git a/tests/security/crowdsec-dashboard.spec.ts b/tests/security/crowdsec-dashboard.spec.ts index 0f906224c..b4798b11c 100644 --- a/tests/security/crowdsec-dashboard.spec.ts +++ b/tests/security/crowdsec-dashboard.spec.ts @@ -1,273 +1,581 @@ /** * CrowdSec Dashboard E2E Tests * - * Tests the CrowdSec Dashboard tab functionality including: + * The E2E container has no CrowdSec engine, so every dashboard endpoint is stubbed + * (see tests/utils/crowdsec-stubs.ts) and the assertions run against known data: * - Tab navigation between Configuration and Dashboard - * - Summary cards rendering - * - Chart components rendering - * - Time range selector interaction - * - Active decisions table display - * - Alerts list display - * - Export button functionality - * - Refresh button functionality + * - Summary cards, charts, active decisions table and alerts list + * - Empty and error states of every panel + * - Time range selection and refresh (asserted on the requests the UI sends) + * - Decision export (CSV and JSON) * * @see /projects/Charon/docs/plans/current_spec.md PR-2, PR-3 */ +import type { Locator, Page } from '@playwright/test'; import { test, expect, loginUser } from '../fixtures/auth-fixtures'; import { waitForLoadingComplete } from '../utils/wait-helpers'; +import { + crowdsecFixtures, + stubCrowdSecApi, + type CrowdSecStubOptions, + type StubFailure, +} from '../utils/crowdsec-stubs'; + +const ALERT_TOTAL = 25; +const ALERT_PAGE_SIZE = 10; +const ERROR_TIMEOUT = 15_000; +const SERVER_ERROR: StubFailure = { failWith: 500 }; + +const TOP_IPS = [ + { ip: '203.0.113.1', count: 5, last_seen: '2020-01-01T00:00:00Z', country: 'DE' }, + { ip: '203.0.113.2', count: 9, last_seen: '2020-01-02T00:00:00Z', country: 'US' }, + { ip: '203.0.113.3', count: 1, last_seen: '2020-01-03T00:00:00Z', country: '' }, +]; + +const SCENARIOS = [ + { name: 'crowdsecurity/http-probing', count: 60, percentage: 60 }, + { name: 'crowdsecurity/ssh-bf', count: 40, percentage: 40 }, +]; + +const TIMELINE = [ + { timestamp: '2026-01-01T00:00:00Z', bans: 3, captchas: 1 }, + { timestamp: '2026-01-01T01:00:00Z', bans: 5, captchas: 0 }, +]; + +/** Serves a page of generated alerts; alert N has IP 198.51.100.N and scenario crowdsecurity/scenario-N. */ +function alertsPage(query: URLSearchParams) { + const offset = Number(query.get('offset') ?? 0); + const limit = Number(query.get('limit') ?? ALERT_PAGE_SIZE); + const alerts = Array.from({ length: Math.max(0, Math.min(limit, ALERT_TOTAL - offset)) }, (_, i) => { + const n = offset + i + 1; + return crowdsecFixtures.alert({ + id: n, + ip: `198.51.100.${n}`, + scenario: `crowdsecurity/scenario-${n}`, + events_count: n * 2, + }); + }); + return { alerts, total: ALERT_TOTAL, source: 'lapi', cached: false }; +} + +/** Dashboard stub set with data in every panel; override individual panels per test. */ +function dashboardOptions( + overrides: NonNullable = {}, +): CrowdSecStubOptions { + return { + dashboard: { + summary: crowdsecFixtures.dashboardSummary(), + timeline: { buckets: TIMELINE, range: '24h', interval: '1h', cached: false }, + topIps: { ips: TOP_IPS, range: '24h', cached: false }, + scenarios: { scenarios: SCENARIOS, total: 100, range: '24h', cached: false }, + alerts: alertsPage, + ...overrides, + }, + }; +} + +/** Installs the stubs, loads the CrowdSec page and opens the Dashboard tab. */ +async function openDashboard(page: Page, options: CrowdSecStubOptions = dashboardOptions()) { + const recorder = await stubCrowdSecApi(page, options); + await page.goto('/security/crowdsec'); + await waitForLoadingComplete(page); + await page.getByRole('tab', { name: 'Dashboard', exact: true }).click(); + await expect(page.getByTestId('dashboard-summary-cards')).toBeVisible(); + return recorder; +} + +/** The accessible name of this group is currently a raw i18n error string (F3), so it is located by role only. */ +const timeRangeGroup = (page: Page) => page.getByRole('radiogroup'); +const activeDecisionsTable = (page: Page) => + page.getByRole('table').filter({ has: page.getByRole('button', { name: 'Sort by Alerts' }) }); +const alertsTable = (page: Page) => + page.getByRole('table').filter({ has: page.getByRole('columnheader', { name: 'Scenario' }) }); + +/** Text of the first cell of each body row, in display order. */ +async function firstColumn(table: Locator): Promise { + return table.getByRole('row').filter({ has: table.page().getByRole('cell') }).evaluateAll((rows) => + rows.map((row) => row.querySelector('td')?.textContent?.trim() ?? ''), + ); +} test.describe('CrowdSec Dashboard @security', () => { test.beforeEach(async ({ page, adminUser }) => { await loginUser(page, adminUser); await waitForLoadingComplete(page); - await page.goto('/security/crowdsec'); - await waitForLoadingComplete(page); }); test.describe('Tab Navigation', () => { + test.beforeEach(async ({ page }) => { + await stubCrowdSecApi(page, dashboardOptions()); + await page.goto('/security/crowdsec'); + await waitForLoadingComplete(page); + }); + test('should display Configuration and Dashboard tabs', async ({ page }) => { - await test.step('Verify tab list is present', async () => { - const tabList = page.getByRole('tablist'); - await expect(tabList).toBeVisible(); - }); + await expect(page.getByRole('tablist')).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Configuration', exact: true })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Dashboard', exact: true })).toBeVisible(); + }); + + test('should default to Configuration tab', async ({ page }) => { + await expect(page.getByRole('tab', { name: 'Configuration', exact: true })).toHaveAttribute('data-state', 'active'); + await expect(page.getByRole('tab', { name: 'Dashboard', exact: true })).toHaveAttribute('data-state', 'inactive'); + await expect(page.getByTestId('dashboard-summary-cards')).toHaveCount(0); + }); - await test.step('Verify Configuration tab', async () => { - const configTab = page.getByRole('tab', { name: /configuration/i }); - await expect(configTab).toBeVisible(); + test('should switch to the Dashboard tab and back', async ({ page }) => { + await test.step('Open the Dashboard tab', async () => { + await page.getByRole('tab', { name: 'Dashboard', exact: true }).click(); + await expect(page.getByRole('tab', { name: 'Dashboard', exact: true })).toHaveAttribute('data-state', 'active'); + await expect(page.getByTestId('dashboard-summary-cards')).toBeVisible(); }); - await test.step('Verify Dashboard tab', async () => { - const dashboardTab = page.getByRole('tab', { name: /dashboard/i }); - await expect(dashboardTab).toBeVisible(); + await test.step('Return to the Configuration tab', async () => { + await page.getByRole('tab', { name: 'Configuration', exact: true }).click(); + await expect(page.getByRole('tab', { name: 'Configuration', exact: true })).toHaveAttribute('data-state', 'active'); + await expect(page.getByTestId('dashboard-summary-cards')).toHaveCount(0); + await expect(page.getByRole('heading', { name: 'Presets', level: 3 })).toBeVisible(); }); }); + }); - test('should default to Configuration tab', async ({ page }) => { - await test.step('Verify Configuration tab is selected by default', async () => { - const configTab = page.getByRole('tab', { name: /configuration/i }); - await expect(configTab).toHaveAttribute('data-state', 'active'); - }); + test.describe('Summary Cards', () => { + test('should show the summary figures', async ({ page }) => { + await openDashboard(page); + + const cards = page.getByTestId('dashboard-summary-cards'); + await expect(cards.getByText('Total Decisions', { exact: true })).toBeVisible(); + await expect(cards.getByText('1,234', { exact: true })).toBeVisible(); + await expect(cards.getByText('+12.5%', { exact: true })).toBeVisible(); + await expect(cards.getByText('Active Decisions', { exact: true })).toBeVisible(); + await expect(cards.getByText('56', { exact: true })).toBeVisible(); + await expect(cards.getByText('Currently enforced', { exact: true })).toBeVisible(); + await expect(cards.getByText('Unique IPs', { exact: true })).toBeVisible(); + await expect(cards.getByText('78', { exact: true })).toBeVisible(); + await expect(cards.getByText('Top Scenario', { exact: true })).toBeVisible(); + await expect(cards.getByText('http-probing', { exact: true })).toBeVisible(); + await expect(cards.getByText('crowdsecurity/http-probing', { exact: true })).toBeVisible(); }); - test('should switch to Dashboard tab when clicked', async ({ page }) => { - await test.step('Click Dashboard tab', async () => { - await page.getByRole('tab', { name: /dashboard/i }).click(); + const TRENDS = [ + { name: 'a falling', trend: -3, label: '-3.0%' }, + { name: 'a flat', trend: 0, label: '0%' }, + ]; + for (const { name, trend, label } of TRENDS) { + test(`should show ${name} decision trend`, async ({ page }) => { + await openDashboard( + page, + dashboardOptions({ summary: crowdsecFixtures.dashboardSummary({ decisions_trend: trend }) }), + ); + + await expect(page.getByTestId('dashboard-summary-cards').getByText(label, { exact: true })).toBeVisible(); }); + } - await test.step('Verify Dashboard tab is selected', async () => { - const dashboardTab = page.getByRole('tab', { name: /dashboard/i }); - await expect(dashboardTab).toHaveAttribute('data-state', 'active'); - }); + test('should show N/A when the local API is unavailable', async ({ page }) => { + await openDashboard( + page, + dashboardOptions({ summary: crowdsecFixtures.dashboardSummary({ active_decisions: -1 }) }), + ); - await test.step('Verify dashboard content is visible', async () => { - await expect(page.getByTestId('dashboard-summary-cards')).toBeVisible({ timeout: 10000 }); - }); + const cards = page.getByTestId('dashboard-summary-cards'); + await expect(cards.getByText('N/A', { exact: true })).toBeVisible(); + await expect(cards.getByText('LAPI unavailable', { exact: true })).toBeVisible(); }); - test('should switch back to Configuration tab', async ({ page }) => { - await test.step('Navigate to Dashboard tab', async () => { - await page.getByRole('tab', { name: /dashboard/i }).click(); - await expect(page.getByTestId('dashboard-summary-cards')).toBeVisible({ timeout: 10000 }); - }); + test('should show a placeholder when there is no top scenario', async ({ page }) => { + await openDashboard( + page, + dashboardOptions({ summary: crowdsecFixtures.dashboardSummary({ top_scenario: '' }) }), + ); - await test.step('Switch back to Configuration tab', async () => { - await page.getByRole('tab', { name: /configuration/i }).click(); - }); + const cards = page.getByTestId('dashboard-summary-cards'); + await expect(cards.getByText('—', { exact: true })).toBeVisible(); + await expect(cards.getByText('No data', { exact: true })).toBeVisible(); + }); - await test.step('Verify Configuration content is visible', async () => { - const configTab = page.getByRole('tab', { name: /configuration/i }); - await expect(configTab).toHaveAttribute('data-state', 'active'); + test('should show an error when the summary cannot be loaded', async ({ page }) => { + await openDashboard(page, dashboardOptions({ summary: SERVER_ERROR })); + + await expect(page.getByTestId('dashboard-summary-cards')).toHaveText('Failed to load summary data.', { + timeout: ERROR_TIMEOUT, }); }); }); - test.describe('Dashboard Content', () => { - test.beforeEach(async ({ page }) => { - await page.getByRole('tab', { name: /dashboard/i }).click(); - await expect(page.getByTestId('dashboard-summary-cards')).toBeVisible({ timeout: 10000 }); + test.describe('Charts', () => { + test('should render the timeline, top IPs and scenario charts', async ({ page }) => { + await openDashboard(page); + + await expect(page.getByRole('heading', { name: 'Decision Timeline' })).toBeVisible(); + await expect(page.getByRole('img', { name: 'Area chart showing bans and captchas over time' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Top Attacking IPs' })).toBeVisible(); + await expect( + page.getByRole('img', { name: 'Horizontal bar chart showing top attacking IPs by decision count' }), + ).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Scenario Breakdown' })).toBeVisible(); + await expect( + page.getByRole('img', { name: 'Donut chart showing distribution of scenarios by decision count' }), + ).toBeVisible(); }); - test('should display summary cards section', async ({ page }) => { - await test.step('Verify summary cards container', async () => { - await expect(page.getByTestId('dashboard-summary-cards')).toBeVisible(); - }); + test('should list each scenario with its count and share', async ({ page }) => { + await openDashboard(page); + + const legend = page.getByRole('list', { name: 'Scenario legend' }); + await expect(legend.getByRole('listitem')).toHaveCount(2); + await expect(legend.getByRole('listitem').nth(0)).toHaveText('http-probing6060.0%'); + await expect(legend.getByRole('listitem').nth(1)).toHaveText('ssh-bf4040.0%'); }); - test('should display time range selector', async ({ page }) => { - await test.step('Verify time range buttons are present', async () => { - const timeRangeGroup = page.getByRole('tablist', { name: /time range/i }); - const timeRangeVisible = await timeRangeGroup.isVisible().catch(() => false); - - if (timeRangeVisible) { - await expect(timeRangeGroup).toBeVisible(); - } else { - // Fallback: look for individual time range buttons - const button24h = page.getByRole('tab', { name: /24h/i }); - const buttonVisible = await button24h.isVisible().catch(() => false); - - if (buttonVisible) { - await expect(button24h).toBeVisible(); - } else { - test.info().annotations.push({ - type: 'info', - description: 'Time range selector not visible - may use different UI pattern', - }); - } - } - }); + test('should show empty states when there is no data', async ({ page }) => { + await openDashboard( + page, + dashboardOptions({ + timeline: { buckets: [], range: '24h', interval: '1h', cached: false }, + topIps: { ips: [], range: '24h', cached: false }, + scenarios: { scenarios: [], total: 0, range: '24h', cached: false }, + }), + ); + + await expect(page.getByText('No decision data for the selected period.', { exact: true })).toBeVisible(); + await expect(page.getByText('No attacking IPs in the selected period.', { exact: true })).toBeVisible(); + await expect(page.getByText('No scenario data for the selected period.', { exact: true })).toBeVisible(); + await expect(page.getByRole('img', { name: /chart showing/ })).toHaveCount(0); }); - test('should display refresh button', async ({ page }) => { - await test.step('Verify refresh button exists', async () => { - const refreshButton = page.getByRole('button', { name: /refresh/i }); - await expect(refreshButton).toBeVisible(); - }); + test('should show an error in each chart panel when its data cannot be loaded', async ({ page }) => { + await openDashboard( + page, + dashboardOptions({ timeline: SERVER_ERROR, topIps: SERVER_ERROR, scenarios: SERVER_ERROR }), + ); + + await expect(page.getByText('Failed to load timeline data.', { exact: true })).toBeVisible({ timeout: ERROR_TIMEOUT }); + await expect(page.getByText('Failed to load top IPs data.', { exact: true })).toBeVisible({ timeout: ERROR_TIMEOUT }); + await expect(page.getByText('Failed to load scenario data.', { exact: true })).toBeVisible({ timeout: ERROR_TIMEOUT }); }); + }); - test('should display chart sections', async ({ page }) => { - await test.step('Verify chart containers are rendered', async () => { - // Charts render as role="img" with aria-labels - const banTimeline = page.getByRole('img', { name: /ban.*timeline|timeline.*chart/i }); - const banTimelineVisible = await banTimeline.isVisible().catch(() => false); - - const topIPs = page.getByRole('img', { name: /top.*ip|attacking.*ip/i }); - const topIPsVisible = await topIPs.isVisible().catch(() => false); - - const scenarios = page.getByRole('img', { name: /scenario.*breakdown|scenario.*chart/i }); - const scenariosVisible = await scenarios.isVisible().catch(() => false); - - // At least one chart should render (even with error/empty state) - // Charts may not render if CrowdSec is not running - if (!banTimelineVisible && !topIPsVisible && !scenariosVisible) { - // Check for error or empty state messages instead - const errorOrEmpty = page.getByText(/error|no data|unavailable|failed/i).first(); - const hasMessage = await errorOrEmpty.isVisible().catch(() => false); - - if (!hasMessage) { - test.info().annotations.push({ - type: 'info', - description: 'Charts not rendered - CrowdSec may not be running or no data available', - }); - } - } - }); + test.describe('Active Decisions Table', () => { + test('should list the decisions sorted by alert count, highest first', async ({ page }) => { + await openDashboard(page); + + const table = activeDecisionsTable(page); + await expect(page.getByRole('heading', { name: 'Active Decisions' })).toBeVisible(); + await expect(table.getByRole('columnheader')).toHaveText(['IP', 'Alerts', 'Last Seen', 'Country']); + await expect.poll(() => firstColumn(table)).toEqual(['203.0.113.2', '203.0.113.1', '203.0.113.3']); + await expect(table.getByRole('columnheader', { name: 'Sort by Alerts' })).toHaveAttribute('aria-sort', 'descending'); }); - test('should display active decisions table', async ({ page }) => { - await test.step('Verify decisions table or empty state', async () => { - const table = page.getByRole('table'); - const tableVisible = await table.isVisible().catch(() => false); - - if (tableVisible) { - // If table is visible, verify it has column headers - const headers = page.getByRole('columnheader'); - const headerCount = await headers.count(); - expect(headerCount).toBeGreaterThan(0); - } else { - // Table may not render if no data or CrowdSec not running - const errorOrEmpty = page.getByText(/error|no.*decisions|no.*alerts|no data/i).first(); - const hasMessage = await errorOrEmpty.isVisible().catch(() => false); - - if (!hasMessage) { - test.info().annotations.push({ - type: 'info', - description: 'Active decisions table not visible - CrowdSec may not be running', - }); - } - } - }); + test('should show the country of each decision, or a dash when unknown', async ({ page }) => { + await openDashboard(page); + + const rows = activeDecisionsTable(page).getByRole('row'); + await expect(rows.filter({ hasText: '203.0.113.1' }).getByRole('cell').nth(3)).toHaveText('DE'); + await expect(rows.filter({ hasText: '203.0.113.2' }).getByRole('cell').nth(3)).toHaveText('US'); + await expect(rows.filter({ hasText: '203.0.113.3' }).getByRole('cell').nth(3)).toHaveText('—'); }); - test('should display alerts list section', async ({ page }) => { - await test.step('Verify alerts list or empty state', async () => { - const alertsList = page.getByTestId('alerts-list'); - await expect(alertsList).toBeVisible({ timeout: 10000 }); + test('should expose the exact last seen time on each row', async ({ page }) => { + await openDashboard(page); + + const row = activeDecisionsTable(page).getByRole('row').filter({ hasText: '203.0.113.1' }); + await expect(row.locator('time')).toHaveAttribute('datetime', '2020-01-01T00:00:00Z'); + }); + + test('should re-sort when a column header is activated', async ({ page }) => { + await openDashboard(page); + const table = activeDecisionsTable(page); + + await test.step('Sort by IP, highest first', async () => { + await table.getByRole('button', { name: 'Sort by IP' }).click(); + await expect(table.getByRole('columnheader', { name: 'Sort by IP' })).toHaveAttribute('aria-sort', 'descending'); + await expect(table.getByRole('columnheader', { name: 'Sort by Alerts' })).toHaveAttribute('aria-sort', 'none'); + await expect.poll(() => firstColumn(table)).toEqual(['203.0.113.3', '203.0.113.2', '203.0.113.1']); + }); + + await test.step('Activate the same header again to reverse the order', async () => { + await table.getByRole('button', { name: 'Sort by IP' }).click(); + await expect(table.getByRole('columnheader', { name: 'Sort by IP' })).toHaveAttribute('aria-sort', 'ascending'); + await expect.poll(() => firstColumn(table)).toEqual(['203.0.113.1', '203.0.113.2', '203.0.113.3']); }); }); - test('should display export button', async ({ page }) => { - await test.step('Verify export button is visible', async () => { - const exportBtn = page.getByRole('button', { name: /export/i }); - await expect(exportBtn).toBeVisible(); + test('should show an empty state when there are no decisions', async ({ page }) => { + await openDashboard(page, dashboardOptions({ topIps: { ips: [], range: '24h', cached: false } })); + + await expect(page.getByText('No active decisions.', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Sort by IP' })).toHaveCount(0); + }); + + test('should show an error when the decisions cannot be loaded', async ({ page }) => { + await openDashboard(page, dashboardOptions({ topIps: SERVER_ERROR })); + + await expect(page.getByText('Failed to load decisions.', { exact: true })).toBeVisible({ timeout: ERROR_TIMEOUT }); + }); + }); + + test.describe('Alerts List', () => { + test('should list the first page of alerts', async ({ page }) => { + const recorder = await openDashboard(page); + + const list = page.getByTestId('alerts-list'); + await expect(list.getByRole('heading', { name: 'Recent Alerts' })).toBeVisible(); + await expect(list.getByText('25 total', { exact: true })).toBeVisible(); + + const table = alertsTable(page); + await expect(table.getByRole('columnheader')).toHaveText(['IP', 'Scenario', 'Time', 'Events']); + await expect(table.getByRole('row')).toHaveCount(ALERT_PAGE_SIZE + 1); + const first = table.getByRole('row').nth(1); + await expect(first.getByRole('cell').nth(0)).toHaveText('198.51.100.1'); + await expect(first.getByRole('cell').nth(1)).toHaveText('scenario-1'); + await expect(first.getByRole('cell').nth(3)).toHaveText('2'); + await expect(first.locator('time')).toHaveAttribute('datetime', '2026-01-01T00:05:00Z'); + expect(recorder.alertQueries[0]).toEqual({ range: '24h', limit: '10', offset: '0' }); + }); + + test('should page through the alerts', async ({ page }) => { + const recorder = await openDashboard(page); + const pagination = page.getByRole('navigation', { name: 'Alerts pagination' }); + const previous = pagination.getByRole('button', { name: 'Previous page' }); + const next = pagination.getByRole('button', { name: 'Next page' }); + const table = alertsTable(page); + + await test.step('First page', async () => { + await expect(pagination.getByText('Page 1 of 3', { exact: true })).toBeVisible(); + await expect(previous).toBeDisabled(); + await expect(next).toBeEnabled(); }); - await test.step('Open export dropdown', async () => { - const exportBtn = page.getByRole('button', { name: /export/i }); - await exportBtn.click(); - - const menu = page.getByRole('menu'); - const menuVisible = await menu.isVisible().catch(() => false); - - if (menuVisible) { - const csvOption = page.getByRole('menuitem', { name: /csv/i }); - const jsonOption = page.getByRole('menuitem', { name: /json/i }); - await expect(csvOption).toBeVisible(); - await expect(jsonOption).toBeVisible(); - } else { - test.info().annotations.push({ - type: 'info', - description: 'Export dropdown menu did not open - may need interaction delay', - }); - } + await test.step('Second page', async () => { + await next.click(); + await expect(pagination.getByText('Page 2 of 3', { exact: true })).toBeVisible(); + await expect(table.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('198.51.100.11'); + await expect(previous).toBeEnabled(); + expect(recorder.alertQueries.at(-1)).toEqual({ range: '24h', limit: '10', offset: '10' }); }); + + await test.step('Last page holds the remaining alerts', async () => { + await next.click(); + await expect(pagination.getByText('Page 3 of 3', { exact: true })).toBeVisible(); + await expect(table.getByRole('row')).toHaveCount(5 + 1); + await expect(next).toBeDisabled(); + expect(recorder.alertQueries.at(-1)).toEqual({ range: '24h', limit: '10', offset: '20' }); + }); + + await test.step('Back to the previous page', async () => { + await previous.click(); + await expect(pagination.getByText('Page 2 of 3', { exact: true })).toBeVisible(); + await expect(table.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('198.51.100.11'); + }); + }); + + test('should not show pagination when everything fits on one page', async ({ page }) => { + await openDashboard( + page, + dashboardOptions({ + alerts: () => ({ alerts: [crowdsecFixtures.alert()], total: 1, source: 'lapi', cached: false }), + }), + ); + + await expect(page.getByTestId('alerts-list').getByText('1 total', { exact: true })).toBeVisible(); + await expect(alertsTable(page).getByRole('row')).toHaveCount(2); + await expect(page.getByRole('navigation', { name: 'Alerts pagination' })).toHaveCount(0); + }); + + test('should show an empty state when there are no alerts', async ({ page }) => { + await openDashboard( + page, + dashboardOptions({ alerts: () => ({ alerts: [], total: 0, source: 'lapi', cached: false }) }), + ); + + await expect(page.getByTestId('alerts-list')).toHaveText('No alerts for the selected period.'); + }); + + test('should show an error when the alerts cannot be loaded', async ({ page }) => { + await openDashboard(page, dashboardOptions({ alerts: SERVER_ERROR })); + + await expect(page.getByTestId('alerts-list')).toHaveText('Failed to load alerts.', { timeout: ERROR_TIMEOUT }); }); }); test.describe('Time Range Interaction', () => { - test.beforeEach(async ({ page }) => { - await page.getByRole('tab', { name: /dashboard/i }).click(); - await expect(page.getByTestId('dashboard-summary-cards')).toBeVisible({ timeout: 10000 }); - }); - - test('should update data when selecting different time range', async ({ page }) => { - await test.step('Select 7d time range', async () => { - const sevenDayButton = page.getByRole('tab', { name: /7d/i }); - const sevenDayVisible = await sevenDayButton.isVisible().catch(() => false); - - if (sevenDayVisible) { - await sevenDayButton.click(); - await expect(sevenDayButton).toHaveAttribute('aria-selected', 'true'); - } else { - test.info().annotations.push({ - type: 'info', - description: '7d time range button not found - may use different selector pattern', - }); - } + test('should expose the time range as a radio group', async ({ page }) => { + await openDashboard(page); + + await expect(timeRangeGroup(page)).toMatchAriaSnapshot(` + - radiogroup: + - radio "1H" + - radio "6H" + - radio "24H" [checked] + - radio "7D" + - radio "30D" + `); + }); + + test('should name the time range group in readable text', async ({ page }) => { + test.fixme(true, 'F3: security.crowdsec.dashboard.timeRange is an object in the translations, so the radiogroup name is an i18next error string instead of "Time range"'); + await openDashboard(page); + + await expect(page.getByRole('radiogroup', { name: 'Time range', exact: true })).toBeVisible(); + }); + + test('should request data for the selected time range', async ({ page }) => { + const recorder = await openDashboard(page); + const group = timeRangeGroup(page); + + await test.step('Defaults to 24 hours', async () => { + await expect(group.getByRole('radio', { name: '24H' })).toBeChecked(); + expect(recorder.dashboardQueries.summary[0]).toEqual({ range: '24h' }); + expect(recorder.dashboardQueries['top-ips'][0]).toEqual({ range: '24h', limit: '10' }); }); + + await test.step('Select 7 days', async () => { + await group.getByRole('radio', { name: '7D' }).click(); + await expect(group.getByRole('radio', { name: '7D' })).toBeChecked(); + await expect(group.getByRole('radio', { name: '24H' })).not.toBeChecked(); + await expect.poll(() => recorder.dashboardQueries.summary.at(-1)).toEqual({ range: '7d' }); + await expect.poll(() => recorder.dashboardQueries.timeline.at(-1)).toEqual({ range: '7d' }); + await expect.poll(() => recorder.dashboardQueries.scenarios.at(-1)).toEqual({ range: '7d' }); + await expect.poll(() => recorder.dashboardQueries['top-ips'].at(-1)).toEqual({ range: '7d', limit: '10' }); + await expect.poll(() => recorder.alertQueries.at(-1)).toEqual({ range: '7d', limit: '10', offset: '0' }); + }); + }); + + test('should move between time ranges with the arrow keys', async ({ page }) => { + await openDashboard(page); + const group = timeRangeGroup(page); + + await group.getByRole('radio', { name: '24H' }).focus(); + await page.keyboard.press('ArrowRight'); + await expect(group.getByRole('radio', { name: '7D' })).toBeChecked(); + await expect(group.getByRole('radio', { name: '7D' })).toBeFocused(); + + await page.keyboard.press('End'); + await expect(group.getByRole('radio', { name: '30D' })).toBeChecked(); + + await page.keyboard.press('Home'); + await expect(group.getByRole('radio', { name: '1H' })).toBeChecked(); + + await page.keyboard.press('ArrowLeft'); + await expect(group.getByRole('radio', { name: '30D' })).toBeChecked(); + }); + + test('should return to the first alerts page when the time range changes', async ({ page }) => { + const recorder = await openDashboard(page); + const pagination = page.getByRole('navigation', { name: 'Alerts pagination' }); + + await pagination.getByRole('button', { name: 'Next page' }).click(); + await expect(pagination.getByText('Page 2 of 3', { exact: true })).toBeVisible(); + + await timeRangeGroup(page).getByRole('radio', { name: '30D' }).click(); + await expect(pagination.getByText('Page 1 of 3', { exact: true })).toBeVisible(); + expect(recorder.alertQueries.at(-1)).toEqual({ range: '30d', limit: '10', offset: '0' }); }); }); test.describe('Refresh Functionality', () => { - test.beforeEach(async ({ page }) => { - await page.getByRole('tab', { name: /dashboard/i }).click(); - await expect(page.getByTestId('dashboard-summary-cards')).toBeVisible({ timeout: 10000 }); - }); - - test('should trigger data refresh when clicking refresh button', async ({ page }) => { - await test.step('Click refresh and verify loading state', async () => { - const refreshButton = page.getByRole('button', { name: /refresh/i }); - await expect(refreshButton).toBeVisible(); - - // Click refresh and expect API calls to be triggered - const responsePromise = page.waitForResponse( - resp => resp.url().includes('/crowdsec/dashboard') || resp.url().includes('/crowdsec/alerts'), - { timeout: 10000 } - ).catch(() => null); - - await refreshButton.click(); - - const response = await responsePromise; - if (response) { - // API call was made - refresh working - expect(response.status()).toBeLessThan(500); - } else { - test.info().annotations.push({ - type: 'info', - description: 'No dashboard API response detected after refresh - endpoints may not be implemented', - }); - } + test('should reload every panel when clicking refresh', async ({ page }) => { + const recorder = await openDashboard(page); + await expect.poll(() => recorder.alertQueries.length).toBe(1); + + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + + await expect.poll(() => recorder.dashboardQueries.summary.length).toBe(2); + await expect.poll(() => recorder.dashboardQueries.timeline.length).toBe(2); + await expect.poll(() => recorder.dashboardQueries['top-ips'].length).toBe(2); + await expect.poll(() => recorder.dashboardQueries.scenarios.length).toBe(2); + await expect.poll(() => recorder.alertQueries.length).toBe(2); + await expect(page.getByRole('button', { name: 'Refresh', exact: true })).toBeEnabled(); + }); + }); + + test.describe('Export', () => { + const today = () => new Date().toISOString().slice(0, 10); + + test('should offer CSV and JSON export formats', async ({ page }) => { + await openDashboard(page); + const exportButton = page.getByRole('button', { name: 'Export decisions' }); + + await expect(exportButton).toHaveAttribute('aria-expanded', 'false'); + await exportButton.click(); + await expect(exportButton).toHaveAttribute('aria-expanded', 'true'); + await expect(page.getByRole('menu', { name: 'Export format' })).toMatchAriaSnapshot(` + - menu "Export format": + - menuitem "Export as CSV" + - menuitem "Export as JSON" + `); + }); + + const FORMATS = [ + { label: 'Export as CSV', format: 'csv', contentType: 'text/csv', body: 'ip,reason\n203.0.113.2,probing\n' }, + { label: 'Export as JSON', format: 'json', contentType: 'application/json', body: '[{"ip":"203.0.113.2"}]' }, + ]; + for (const { label, format, contentType, body } of FORMATS) { + test(`should download the decisions as ${format.toUpperCase()}`, async ({ page }) => { + const recorder = await openDashboard(page, { + ...dashboardOptions(), + decisionsExport: { body, contentType }, + }); + + await page.getByRole('button', { name: 'Export decisions' }).click(); + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('menuitem', { name: label }).click(); + const download = await downloadPromise; + + expect(download.suggestedFilename()).toBe(`crowdsec-decisions-${today()}.${format}`); + expect(recorder.decisionsExportQueries).toEqual([{ format, range: '24h', source: 'all' }]); + await expect(page.getByRole('menu')).toHaveCount(0); + await expect(page.getByRole('alert')).toHaveCount(0); + }); + } + + test('should export the selected time range', async ({ page }) => { + const recorder = await openDashboard(page, { + ...dashboardOptions(), + decisionsExport: { body: 'ip\n', contentType: 'text/csv' }, }); + + await timeRangeGroup(page).getByRole('radio', { name: '7D' }).click(); + await page.getByRole('button', { name: 'Export decisions' }).click(); + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('menuitem', { name: 'Export as CSV' }).click(); + await downloadPromise; + + expect(recorder.decisionsExportQueries).toEqual([{ format: 'csv', range: '7d', source: 'all' }]); + }); + + test('should close the menu with Escape', async ({ page }) => { + await openDashboard(page); + const exportButton = page.getByRole('button', { name: 'Export decisions' }); + + await exportButton.click(); + await expect(page.getByRole('menu')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('menu')).toHaveCount(0); + await expect(exportButton).toBeFocused(); + }); + + test('should show an error when the export fails', async ({ page }) => { + await openDashboard(page, { ...dashboardOptions(), decisionsExport: SERVER_ERROR }); + + await page.getByRole('button', { name: 'Export decisions' }).click(); + await page.getByRole('menuitem', { name: 'Export as CSV' }).click(); + + await expect(page.getByRole('alert')).toHaveText('Export failed. Please try again.'); + }); + + test('should show an error when the export is empty', async ({ page }) => { + await openDashboard(page, { + ...dashboardOptions(), + decisionsExport: { body: '', contentType: 'text/csv' }, + }); + + await page.getByRole('button', { name: 'Export decisions' }).click(); + await page.getByRole('menuitem', { name: 'Export as CSV' }).click(); + + await expect(page.getByRole('alert')).toHaveText('Export failed. Please try again.'); }); }); }); diff --git a/tests/security/crowdsec-decisions.spec.ts b/tests/security/crowdsec-decisions.spec.ts index ff194326e..bfaae7575 100644 --- a/tests/security/crowdsec-decisions.spec.ts +++ b/tests/security/crowdsec-decisions.spec.ts @@ -1,286 +1,318 @@ /** * CrowdSec Banned IPs (Decisions) E2E Tests * - * Tests the CrowdSec banned IPs functionality on the main CrowdSec config page: - * - Viewing active bans (decisions) - * - Adding manual IP bans - * - Removing bans (unban) - * - Ban details and status + * Tests the "Banned IPs" card on /security/crowdsec against a stubbed CrowdSec API + * (see tests/utils/crowdsec-stubs.ts), so every assertion runs against known data: + * - Listing active bans, and the empty, disabled and error states + * - Banning an IP (modal, validation, exact request payload, outcomes) + * - Unbanning an IP (confirmation, request, outcomes) * - * NOTE: CrowdSec "Decisions" are managed via the "Banned IPs" card on /security/crowdsec - * There is no separate /security/crowdsec/decisions page - functionality is integrated. + * NOTE: CrowdSec "Decisions" are managed via the "Banned IPs" card on /security/crowdsec. + * There is no separate decisions page and the card has no search, filter or refresh control. * * @see /projects/Charon/docs/plans/current_spec.md */ +import type { Page } from '@playwright/test'; import { test, expect, loginUser } from '../fixtures/auth-fixtures'; +import { getToastLocator } from '../utils/ui-helpers'; import { waitForLoadingComplete } from '../utils/wait-helpers'; +import { + crowdsecFixtures, + stubCrowdSecApi, + type CrowdSecStubOptions, +} from '../utils/crowdsec-stubs'; + +const ERROR_TIMEOUT = 15_000; + +const DECISIONS = [ + crowdsecFixtures.decision({ + id: 'decision-1', + ip: '203.0.113.7', + reason: 'e2e brute force', + duration: '24h', + source: 'crowdsec', + }), + crowdsecFixtures.decision({ id: 'decision-2', ip: '203.0.113.8', reason: '', duration: '4h', source: '' }), +]; + +/** Installs the stubs (CrowdSec in local mode), loads the CrowdSec page and waits for the Banned IPs card. */ +async function openBannedIps(page: Page, options: CrowdSecStubOptions = {}) { + const recorder = await stubCrowdSecApi(page, { + crowdsecMode: 'local', + decisions: DECISIONS, + banApi: {}, + ...options, + }); + await page.goto('/security/crowdsec'); + await waitForLoadingComplete(page); + await expect(bannedIpsHeading(page)).toBeVisible(); + return recorder; +} + +const bannedIpsHeading = (page: Page) => page.getByRole('heading', { name: 'Banned IPs', level: 3 }); +const banDialog = (page: Page) => page.getByRole('dialog', { name: 'Ban IP Address' }); +const unbanDialog = (page: Page) => page.getByRole('dialog', { name: 'Confirm Unban' }); +/** Toasts prefix the message with an icon glyph, so they are matched on the message text within the live region. */ +const successToast = (page: Page, message: string) => getToastLocator(page, message, { type: 'success' }); +const errorToast = (page: Page, message: string) => getToastLocator(page, message, { type: 'error' }); +const decisionRow = (page: Page, ip: string) => page.getByRole('row').filter({ hasText: ip }); test.describe('CrowdSec Banned IPs Management', () => { test.beforeEach(async ({ page, adminUser }) => { await loginUser(page, adminUser); await waitForLoadingComplete(page); - await page.goto('/security/crowdsec'); - await waitForLoadingComplete(page); }); test.describe('Banned IPs Card', () => { - test('should display banned IPs section on CrowdSec config page', async ({ page }) => { - // Verify we're on the CrowdSec config page - await expect(page).toHaveURL('/security/crowdsec'); + test('should display the banned IPs card on the CrowdSec config page', async ({ page }) => { + await openBannedIps(page); - // Verify banned IPs section exists - const bannedIpsHeading = page.getByRole('heading', { name: /banned ips/i }); - await expect(bannedIpsHeading).toBeVisible(); + await expect(page).toHaveURL('/security/crowdsec'); + await expect(page.getByRole('button', { name: 'Ban IP', exact: true })).toBeEnabled(); }); - test('should show ban IP button when CrowdSec is enabled', async ({ page }) => { - // Check if CrowdSec is enabled (status card should show "Running") - const statusCard = page.locator('[class*="card"]').filter({ hasText: /status/i }); - const isRunning = await statusCard.getByText(/running|active/i).isVisible().catch(() => false); - - if (isRunning) { - // Ban IP button should be visible when CrowdSec is running - const banButton = page.getByRole('button', { name: /ban ip/i }); - await expect(banButton).toBeVisible(); - } else { - // Skip if CrowdSec is not enabled - // CrowdSec is not enabled - cannot test banned IPs functionality - } + test('should disable banning and explain why when CrowdSec is disabled', async ({ page }) => { + await openBannedIps(page, { crowdsecMode: 'disabled' }); + + await expect(page.getByRole('button', { name: 'Ban IP', exact: true })).toBeDisabled(); + await expect(page.getByText('Enable CrowdSec to manage banned IPs', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Unban' })).toHaveCount(0); }); }); - // Data-focused tests - require CrowdSec running and full implementation - test.describe('Banned IPs Data Operations (Requires CrowdSec Running)', () => { - test('should show active decisions if any exist', async ({ page }) => { - // Wait for decisions to load - await page.waitForResponse(resp => - resp.url().includes('/decisions') || resp.url().includes('/crowdsec'), - { timeout: 10000 } - ).catch(() => { - // API might not be called if no decisions - }); - - // Could be empty state or list of decisions - const emptyState = page.getByText(/no.*decisions|no.*bans|empty/i); - const decisionRows = page.locator('tr, [class*="row"]').filter({ - hasText: /\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|ban|captcha/i - }); - - const emptyVisible = await emptyState.isVisible().catch(() => false); - const rowCount = await decisionRows.count(); - - // Either empty state or some decisions - expect(emptyVisible || rowCount >= 0).toBeTruthy(); + test.describe('Active Decisions', () => { + test('should list each active ban with its details', async ({ page }) => { + await openBannedIps(page); + + await expect(page.getByRole('columnheader', { name: /^(IP|Reason|Duration|Banned At|Source|Actions)$/ })).toHaveText([ + 'IP', + 'Reason', + 'Duration', + 'Banned At', + 'Source', + 'Actions', + ]); + const first = decisionRow(page, '203.0.113.7').getByRole('cell'); + await expect(first.nth(0)).toHaveText('203.0.113.7'); + await expect(first.nth(1)).toHaveText('e2e brute force'); + await expect(first.nth(2)).toHaveText('24h'); + await expect(first.nth(3)).toHaveText(new Date('2026-01-01T00:00:00Z').toLocaleString()); + await expect(first.nth(4)).toHaveText('crowdsec'); + await expect(first.nth(5).getByRole('button', { name: 'Unban' })).toBeVisible(); }); - test('should display decision columns (IP, type, duration, reason)', async ({ page }) => { - const table = page.getByRole('table'); - const tableVisible = await table.isVisible().catch(() => false); - - if (tableVisible) { - await test.step('Verify table headers', async () => { - const headers = page.locator('th, [role="columnheader"]'); - const headerTexts = await headers.allTextContents(); - - // Headers might include IP, type, duration, scope, reason, origin - const headerString = headerTexts.join(' ').toLowerCase(); - const hasRelevantHeaders = headerString.includes('ip') || - headerString.includes('type') || - headerString.includes('scope') || - headerString.includes('origin'); - - expect(hasRelevantHeaders).toBeTruthy(); - }); - } + test('should show placeholders for a ban without a reason or source', async ({ page }) => { + await openBannedIps(page); + + const cells = decisionRow(page, '203.0.113.8').getByRole('cell'); + await expect(cells.nth(1)).toHaveText('-'); + await expect(cells.nth(2)).toHaveText('4h'); + await expect(cells.nth(4)).toHaveText('manual'); + }); + + test('should offer an unban action for every ban', async ({ page }) => { + await openBannedIps(page); + + await expect(page.getByRole('button', { name: 'Unban', exact: true })).toHaveCount(DECISIONS.length); + }); + + test('should show an empty state when nothing is banned', async ({ page }) => { + await openBannedIps(page, { decisions: [] }); + + await expect(page.getByText('No banned IPs', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Unban' })).toHaveCount(0); + }); + + test('should show an error when the bans cannot be loaded', async ({ page }) => { + await openBannedIps(page, { decisions: { failWith: 500 } }); + + await expect(page.getByText('Failed to load banned IPs', { exact: true })).toBeVisible({ timeout: ERROR_TIMEOUT }); + }); + + test('should make each ban row keyboard focusable', async ({ page }) => { + await openBannedIps(page); + const row = decisionRow(page, '203.0.113.7'); + + await row.focus(); + await expect(row).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(row.getByRole('button', { name: 'Unban' })).toBeFocused(); }); }); - test.describe('Add Decision (Ban IP) - Requires CrowdSec Running', () => { - test('should have add ban button', async ({ page }) => { - const addButton = page.getByRole('button', { name: /add|ban|new/i }); - const addButtonVisible = await addButton.isVisible().catch(() => false); - - if (addButtonVisible) { - await expect(addButton).toBeEnabled(); - } else { - test.info().annotations.push({ - type: 'info', - description: 'Add ban functionality might not be exposed in UI' - }); - } + test.describe('Ban IP', () => { + test('should open the ban dialog with a focused IP field and sensible defaults', async ({ page }) => { + await openBannedIps(page); + + await page.getByRole('button', { name: 'Ban IP', exact: true }).click(); + + const dialog = banDialog(page); + await expect(dialog).toBeVisible(); + await expect(dialog.getByLabel('IP Address')).toBeFocused(); + await expect(dialog.getByLabel('IP Address')).toHaveValue(''); + await expect(dialog.getByLabel('Duration')).toHaveValue('24h'); + await expect(dialog.getByLabel('Duration').getByRole('option')).toHaveText([ + '1 hour', + '4 hours', + '24 hours', + '7 days', + '30 days', + 'Permanent', + ]); + await expect(dialog.getByLabel('Reason')).toHaveValue(''); + await expect(dialog.getByRole('button', { name: 'Ban IP', exact: true })).toBeDisabled(); }); - test('should open ban modal on add button click', async ({ page }) => { - const addButton = page.getByRole('button', { name: /add.*ban|ban.*ip/i }); - const addButtonVisible = await addButton.isVisible().catch(() => false); - - if (addButtonVisible) { - await addButton.click(); - - await test.step('Verify ban modal opens', async () => { - const modal = page.getByRole('dialog'); - const modalVisible = await modal.isVisible().catch(() => false); - - if (modalVisible) { - // Modal should have IP input field - const ipInput = modal.getByPlaceholder(/ip|address/i).or( - modal.locator('input').first() - ); - await expect(ipInput).toBeVisible(); - - // Close modal - const closeButton = modal.getByRole('button', { name: /cancel|close/i }); - await closeButton.click(); - } - }); - } + test('should send the entered IP, duration and reason and report success', async ({ page }) => { + const stubs = await openBannedIps(page); + + await page.getByRole('button', { name: 'Ban IP', exact: true }).click(); + const dialog = banDialog(page); + await dialog.getByLabel('IP Address').fill('198.51.100.9'); + await dialog.getByLabel('Duration').selectOption({ label: '7 days' }); + await dialog.getByLabel('Reason').fill('e2e manual ban'); + await dialog.getByRole('button', { name: 'Ban IP', exact: true }).click(); + + await expect(successToast(page, 'IP 198.51.100.9 has been banned')).toBeVisible(); + await expect(dialog).toHaveCount(0); + expect(stubs.bans).toEqual([{ ip: '198.51.100.9', duration: '7d', reason: 'e2e manual ban' }]); }); - test('should validate IP address format', async ({ page }) => { - const addButton = page.getByRole('button', { name: /add.*ban|ban.*ip/i }); - const addButtonVisible = await addButton.isVisible().catch(() => false); + test('should reload the list after banning an IP', async ({ page }) => { + const stubs = await openBannedIps(page); + await expect.poll(() => stubs.decisionsRequests).toBe(1); - if (addButtonVisible) { - await addButton.click(); + await page.getByRole('button', { name: 'Ban IP', exact: true }).click(); + await banDialog(page).getByLabel('IP Address').fill('198.51.100.9'); + await banDialog(page).getByRole('button', { name: 'Ban IP', exact: true }).click(); - const modal = page.getByRole('dialog'); - const modalVisible = await modal.isVisible().catch(() => false); + await expect(successToast(page, 'IP 198.51.100.9 has been banned')).toBeVisible(); + await expect.poll(() => stubs.decisionsRequests).toBe(2); + }); - if (modalVisible) { - const ipInput = modal.locator('input').first(); + test('should submit with the keyboard from the IP field', async ({ page }) => { + const stubs = await openBannedIps(page); - await test.step('Enter invalid IP', async () => { - await ipInput.fill('invalid-ip'); + await page.getByRole('button', { name: 'Ban IP', exact: true }).click(); + await banDialog(page).getByLabel('IP Address').fill('198.51.100.10'); + await page.keyboard.press('Enter'); + + await expect(successToast(page, 'IP 198.51.100.10 has been banned')).toBeVisible(); + expect(stubs.bans).toEqual([{ ip: '198.51.100.10', duration: '24h', reason: '' }]); + }); - // Look for submit button - const submitButton = modal.getByRole('button', { name: /ban|submit|add/i }); - await submitButton.click(); + test('should keep the ban button disabled while the IP is blank', async ({ page }) => { + const stubs = await openBannedIps(page); - // Should show validation error - await page.waitForTimeout(500); - }); + await page.getByRole('button', { name: 'Ban IP', exact: true }).click(); + const dialog = banDialog(page); + const submit = dialog.getByRole('button', { name: 'Ban IP', exact: true }); - // Close modal - const closeButton = modal.getByRole('button', { name: /cancel|close/i }); - const closeVisible = await closeButton.isVisible().catch(() => false); - if (closeVisible) { - await closeButton.click(); - } - } - } + await dialog.getByLabel('IP Address').fill(' '); + await expect(submit).toBeDisabled(); + await dialog.getByLabel('IP Address').fill('198.51.100.9'); + await expect(submit).toBeEnabled(); + await dialog.getByLabel('IP Address').fill(''); + await expect(submit).toBeDisabled(); + expect(stubs.bans).toEqual([]); }); - }); - test.describe('Remove Decision (Unban) - Requires CrowdSec Running', () => { - test('should show unban action for each decision', async ({ page }) => { - // If there are decisions, each should have an unban action - const unbanButtons = page.getByRole('button', { name: /unban|remove|delete/i }); - const count = await unbanButtons.count(); + test('should show the server error and keep the dialog open when the ban is rejected', async ({ page }) => { + const stubs = await openBannedIps(page, { banApi: { banFailure: { failWith: 400 } } }); + + await page.getByRole('button', { name: 'Ban IP', exact: true }).click(); + const dialog = banDialog(page); + await dialog.getByLabel('IP Address').fill('not-an-ip'); + await dialog.getByRole('button', { name: 'Ban IP', exact: true }).click(); - // Just verify the selector works - actual decisions may or may not exist - expect(count >= 0).toBeTruthy(); + await expect(errorToast(page, 'ban rejected')).toBeVisible(); + await expect(getToastLocator(page, /has been banned/, { type: 'success' })).toHaveCount(0); + await expect(dialog).toBeVisible(); + await expect(dialog.getByLabel('IP Address')).toHaveValue('not-an-ip'); + expect(stubs.bans).toEqual([{ ip: 'not-an-ip', duration: '24h', reason: '' }]); }); - test('should confirm before unbanning', async ({ page }) => { - const unbanButton = page.getByRole('button', { name: /unban|remove/i }).first(); - const unbanVisible = await unbanButton.isVisible().catch(() => false); + test('should close the dialog without banning when cancelled', async ({ page }) => { + const stubs = await openBannedIps(page); - if (unbanVisible) { - await unbanButton.click(); + await page.getByRole('button', { name: 'Ban IP', exact: true }).click(); + await banDialog(page).getByLabel('IP Address').fill('198.51.100.9'); + await banDialog(page).getByRole('button', { name: 'Cancel' }).click(); + + await expect(banDialog(page)).toHaveCount(0); + expect(stubs.bans).toEqual([]); + }); - // Should show confirmation dialog - const confirmDialog = page.getByRole('dialog'); - const dialogVisible = await confirmDialog.isVisible().catch(() => false); + test('should close the dialog with Escape without banning', async ({ page }) => { + const stubs = await openBannedIps(page); - if (dialogVisible) { - // Cancel the action - const cancelButton = page.getByRole('button', { name: /cancel|no/i }); - await cancelButton.click(); - } - } + await page.getByRole('button', { name: 'Ban IP', exact: true }).click(); + await banDialog(page).getByLabel('IP Address').fill('198.51.100.9'); + await page.keyboard.press('Escape'); + await expect(banDialog(page)).toHaveCount(0); + expect(stubs.bans).toEqual([]); }); }); - test.describe('Filtering and Search - Requires CrowdSec Running', () => { - test('should have search/filter input', async ({ page }) => { - const searchInput = page.getByPlaceholder(/search|filter/i); - const searchVisible = await searchInput.isVisible().catch(() => false); + test.describe('Unban IP', () => { + test('should ask for confirmation naming the IP before unbanning', async ({ page }) => { + const stubs = await openBannedIps(page); - if (searchVisible) { - await expect(searchInput).toBeEnabled(); - } + await decisionRow(page, '203.0.113.7').getByRole('button', { name: 'Unban' }).click(); + + const dialog = unbanDialog(page); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText('Are you sure you want to unban 203.0.113.7?')).toBeVisible(); + await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused(); + expect(stubs.unbans).toEqual([]); }); - test('should filter decisions by type', async ({ page }) => { - const typeFilter = page.locator('select, [role="listbox"]').filter({ - hasText: /type|all|ban|captcha/i - }).first(); + test('should leave the ban in place when the confirmation is cancelled', async ({ page }) => { + const stubs = await openBannedIps(page); - const filterVisible = await typeFilter.isVisible().catch(() => false); + await decisionRow(page, '203.0.113.7').getByRole('button', { name: 'Unban' }).click(); + await unbanDialog(page).getByRole('button', { name: 'Cancel' }).click(); - if (filterVisible) { - await expect(typeFilter).toBeVisible(); - } + await expect(unbanDialog(page)).toHaveCount(0); + await expect(decisionRow(page, '203.0.113.7')).toBeVisible(); + expect(stubs.unbans).toEqual([]); }); - }); - test.describe('Refresh and Sync - Requires CrowdSec Running', () => { - test('should have refresh button', async ({ page }) => { - const refreshButton = page.getByRole('button', { name: /refresh|sync|reload/i }); - const refreshVisible = await refreshButton.isVisible().catch(() => false); - - if (refreshVisible) { - await test.step('Click refresh button', async () => { - await refreshButton.click(); - // Should trigger API call - await page.waitForTimeout(500); - }); - } + test('should unban the IP once confirmed and reload the list', async ({ page }) => { + const stubs = await openBannedIps(page); + await expect.poll(() => stubs.decisionsRequests).toBe(1); + + await decisionRow(page, '203.0.113.7').getByRole('button', { name: 'Unban' }).click(); + await unbanDialog(page).getByRole('button', { name: 'Unban', exact: true }).click(); + + await expect(successToast(page, 'IP 203.0.113.7 has been unbanned')).toBeVisible(); + await expect(unbanDialog(page)).toHaveCount(0); + expect(stubs.unbans).toEqual(['203.0.113.7']); + await expect.poll(() => stubs.decisionsRequests).toBe(2); }); - }); - test.describe('Navigation - Requires CrowdSec Running', () => { - test('should navigate back to CrowdSec config', async ({ page }) => { - const backLink = page.getByRole('link', { name: /crowdsec|back|config/i }); - const backVisible = await backLink.isVisible().catch(() => false); + test('should show the server error and keep the confirmation open when the unban fails', async ({ page }) => { + const stubs = await openBannedIps(page, { banApi: { unbanFailure: { failWith: 500 } } }); - if (backVisible) { - await backLink.click(); - await waitForLoadingComplete(page); - await expect(page).toHaveURL(/\/security\/crowdsec(?!\/decisions)/); - } + await decisionRow(page, '203.0.113.7').getByRole('button', { name: 'Unban' }).click(); + await unbanDialog(page).getByRole('button', { name: 'Unban', exact: true }).click(); + + await expect(errorToast(page, 'unban rejected')).toBeVisible(); + await expect(getToastLocator(page, /has been unbanned/, { type: 'success' })).toHaveCount(0); + await expect(unbanDialog(page)).toBeVisible(); + expect(stubs.unbans).toEqual(['203.0.113.7']); }); - }); - test.describe('Accessibility - Requires CrowdSec Running', () => { - test('should be keyboard navigable', async ({ page }) => { - // Focus on the page body first to ensure tab navigation starts from the top - await page.focus('body'); - await page.keyboard.press('Tab'); + test('should close the confirmation with Escape', async ({ page }) => { + const stubs = await openBannedIps(page); + + await decisionRow(page, '203.0.113.7').getByRole('button', { name: 'Unban' }).click(); + await expect(unbanDialog(page)).toBeVisible(); + await page.keyboard.press('Escape'); - // Some element should receive focus, but it might take a split second - // Using evaluate to check document.activeElement is often more reliable than :focus selector - // for rapid state changes in Playwright - await page.waitForFunction(() => { - const active = document.activeElement; - return active && active !== document.body; - }, { timeout: 2000 }).catch(() => { - // Fallback: just assert we didn't crash - console.log('Focus navigation check timed out - proceeding'); - }); - - const isFocusOnBody = await page.evaluate(() => document.activeElement === document.body); - - // If focus is still on body, it means no focusable elements are present or tab order is broken - // However, we relax this check to avoid flakiness in CI environments - if (!isFocusOnBody) { - const focusedVisible = await page.evaluate(() => { - const el = document.activeElement as HTMLElement; - return el && el.offsetParent !== null; // Simple visibility check - }); - expect(focusedVisible).toBeTruthy(); - } + await expect(unbanDialog(page)).toHaveCount(0); + expect(stubs.unbans).toEqual([]); }); }); }); diff --git a/tests/utils/crowdsec-stubs.ts b/tests/utils/crowdsec-stubs.ts index d0163c59c..56e4643b8 100644 --- a/tests/utils/crowdsec-stubs.ts +++ b/tests/utils/crowdsec-stubs.ts @@ -17,6 +17,15 @@ const END = String.raw`(?:\?.*)?$`; /** Anchored matchers for each CrowdSec admin endpoint. */ export const CROWDSEC_ROUTES = { + securityStatus: new RegExp(String.raw`/api/v1/security/status${END}`), + dashboardSummary: new RegExp(`${ADMIN}/dashboard/summary${END}`), + dashboardTimeline: new RegExp(`${ADMIN}/dashboard/timeline${END}`), + dashboardTopIps: new RegExp(`${ADMIN}/dashboard/top-ips${END}`), + dashboardScenarios: new RegExp(`${ADMIN}/dashboard/scenarios${END}`), + alerts: new RegExp(`${ADMIN}/alerts${END}`), + decisionsExport: new RegExp(`${ADMIN}/decisions/export${END}`), + ban: new RegExp(`${ADMIN}/ban${END}`), + unban: new RegExp(`${ADMIN}/ban/[^/?]+${END}`), featureFlags: new RegExp(String.raw`/api/v1/feature-flags${END}`), status: new RegExp(`${ADMIN}/status${END}`), decisions: new RegExp(`${ADMIN}/decisions${END}`), @@ -57,6 +66,40 @@ export interface CrowdSecDecisionFixture { source: string; } +export interface DashboardSummaryFixture { + total_decisions: number; + active_decisions: number; + unique_ips: number; + top_scenario: string; + decisions_trend: number; + range: string; + cached: boolean; + generated_at: string; +} + +export interface DashboardAlertFixture { + id: number; + scenario: string; + ip: string; + message: string; + events_count: number; + start_at: string; + stop_at: string; + created_at: string; + duration: string; + type: string; + origin: string; +} + +/** Makes a stubbed endpoint answer with an error status instead of data. */ +export interface StubFailure { + failWith: number; +} + +function isFailure(value: unknown): value is StubFailure { + return typeof value === 'object' && value !== null && 'failWith' in value; +} + /** Fixtures with sensible defaults; override per test. */ export const crowdsecFixtures = { runningStatus: (overrides: Partial = {}): CrowdSecProcessStatus => ({ @@ -82,6 +125,31 @@ export const crowdsecFixtures = { source: 'manual', ...overrides, }), + dashboardSummary: (overrides: Partial = {}): DashboardSummaryFixture => ({ + total_decisions: 1234, + active_decisions: 56, + unique_ips: 78, + top_scenario: 'crowdsecurity/http-probing', + decisions_trend: 12.5, + range: '24h', + cached: false, + generated_at: '2026-01-01T00:00:00Z', + ...overrides, + }), + alert: (overrides: Partial = {}): DashboardAlertFixture => ({ + id: 1, + scenario: 'crowdsecurity/ssh-bf', + ip: '198.51.100.10', + message: 'ssh brute force', + events_count: 7, + start_at: '2026-01-01T00:00:00Z', + stop_at: '2026-01-01T00:05:00Z', + created_at: '2026-01-01T00:05:00Z', + duration: '4h', + type: 'ban', + origin: 'crowdsec', + ...overrides, + }), diagnosticsConfig: (overrides: Record = {}): Record => ({ config_exists: true, config_valid: true, @@ -107,7 +175,25 @@ export interface CrowdSecStubOptions { /** Body for GET /console/status. */ consoleStatus?: ConsoleEnrollmentState; /** Body for GET /decisions. */ - decisions?: CrowdSecDecisionFixture[]; + decisions?: CrowdSecDecisionFixture[] | StubFailure; + /** + * Overrides `crowdsec.mode` in the real GET /security/status response, which gates the + * banned IPs and whitelist sections. Other fields stay as the backend reports them. + */ + crowdsecMode?: 'local' | 'disabled'; + /** Dashboard endpoint bodies; omitted entries hit the real backend. */ + dashboard?: { + summary?: DashboardSummaryFixture | StubFailure; + timeline?: { buckets: unknown[]; range: string; interval: string; cached: boolean } | StubFailure; + topIps?: { ips: unknown[]; range: string; cached: boolean } | StubFailure; + scenarios?: { scenarios: unknown[]; total: number; range: string; cached: boolean } | StubFailure; + /** Receives the query params of each request and returns the alerts page to serve. */ + alerts?: ((query: URLSearchParams) => { alerts: DashboardAlertFixture[]; total: number; source: string; cached: boolean }) | StubFailure; + }; + /** Body for GET /decisions/export (any format); a failure status is also accepted. */ + decisionsExport?: { body: string; contentType: string } | StubFailure; + /** Stubs POST /ban and DELETE /ban/:ip, recording what the UI sent. */ + banApi?: { banFailure?: StubFailure; unbanFailure?: StubFailure }; /** File names returned by GET /files. */ files?: string[]; /** Map of file path to content for GET /file?path=. Unknown paths get 404. */ @@ -122,6 +208,18 @@ export interface CrowdSecStubOptions { export interface CrowdSecStubRecorder { fileReads: string[]; exportRequests: number; + /** Number of GET /decisions requests. */ + decisionsRequests: number; + /** Query params of each GET /decisions/export request. */ + decisionsExportQueries: Record[]; + /** Query params of each dashboard request, keyed by endpoint (summary, timeline, top-ips, scenarios). */ + dashboardQueries: Record[]>; + /** Query params of each GET /alerts request. */ + alertQueries: Record[]; + /** JSON bodies of POST /ban requests. */ + bans: unknown[]; + /** IPs of DELETE /ban/:ip requests (decoded). */ + unbans: string[]; } async function fulfillJson(route: Route, json: unknown, status = 200): Promise { @@ -137,7 +235,26 @@ export async function stubCrowdSecApi( page: Page, options: CrowdSecStubOptions = {}, ): Promise { - const recorder: CrowdSecStubRecorder = { fileReads: [], exportRequests: 0 }; + const recorder: CrowdSecStubRecorder = { + fileReads: [], + exportRequests: 0, + decisionsRequests: 0, + decisionsExportQueries: [], + dashboardQueries: {}, + alertQueries: [], + bans: [], + unbans: [], + }; + + if (options.crowdsecMode !== undefined) { + const mode = options.crowdsecMode; + await page.route(CROWDSEC_ROUTES.securityStatus, async (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + const response = await route.fetch(); + const status = (await response.json()) as { crowdsec?: Record }; + await fulfillJson(route, { ...status, crowdsec: { ...status.crowdsec, mode, enabled: mode === 'local' } }); + }); + } if (options.consoleEnrollmentEnabled !== undefined) { const enabled = options.consoleEnrollmentEnabled; @@ -165,9 +282,13 @@ export async function stubCrowdSecApi( if (options.decisions) { const decisions = options.decisions; - await page.route(CROWDSEC_ROUTES.decisions, (route) => - route.request().method() === 'GET' ? fulfillJson(route, { decisions }) : route.fallback(), - ); + await page.route(CROWDSEC_ROUTES.decisions, (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + recorder.decisionsRequests += 1; + return isFailure(decisions) + ? fulfillJson(route, { error: 'stubbed failure' }, decisions.failWith) + : fulfillJson(route, { decisions }); + }); } if (options.files) { @@ -197,6 +318,65 @@ export async function stubCrowdSecApi( }); } + const dashboardRoutes = [ + ['summary', CROWDSEC_ROUTES.dashboardSummary, options.dashboard?.summary], + ['timeline', CROWDSEC_ROUTES.dashboardTimeline, options.dashboard?.timeline], + ['top-ips', CROWDSEC_ROUTES.dashboardTopIps, options.dashboard?.topIps], + ['scenarios', CROWDSEC_ROUTES.dashboardScenarios, options.dashboard?.scenarios], + ] as const; + for (const [name, matcher, body] of dashboardRoutes) { + if (!body) continue; + recorder.dashboardQueries[name] = []; + await page.route(matcher, (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + recorder.dashboardQueries[name].push(Object.fromEntries(new URL(route.request().url()).searchParams)); + return isFailure(body) + ? fulfillJson(route, { error: 'stubbed failure' }, body.failWith) + : fulfillJson(route, body); + }); + } + + if (options.dashboard?.alerts) { + const alerts = options.dashboard.alerts; + await page.route(CROWDSEC_ROUTES.alerts, (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + const query = new URL(route.request().url()).searchParams; + recorder.alertQueries.push(Object.fromEntries(query)); + return isFailure(alerts) + ? fulfillJson(route, { error: 'stubbed failure' }, alerts.failWith) + : fulfillJson(route, alerts(query)); + }); + } + + if (options.decisionsExport) { + const exported = options.decisionsExport; + await page.route(CROWDSEC_ROUTES.decisionsExport, async (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + recorder.decisionsExportQueries.push(Object.fromEntries(new URL(route.request().url()).searchParams)); + if (isFailure(exported)) return fulfillJson(route, { error: 'stubbed failure' }, exported.failWith); + return route.fulfill({ status: 200, contentType: exported.contentType, body: exported.body }); + }); + } + + if (options.banApi) { + const { banFailure, unbanFailure } = options.banApi; + await page.route(CROWDSEC_ROUTES.ban, (route) => { + if (route.request().method() !== 'POST') return route.fallback(); + recorder.bans.push(route.request().postDataJSON()); + return banFailure + ? fulfillJson(route, { error: 'ban rejected' }, banFailure.failWith) + : fulfillJson(route, { status: 'banned' }); + }); + await page.route(CROWDSEC_ROUTES.unban, (route) => { + if (route.request().method() !== 'DELETE') return route.fallback(); + const ip = new URL(route.request().url()).pathname.split('/').pop() ?? ''; + recorder.unbans.push(decodeURIComponent(ip)); + return unbanFailure + ? fulfillJson(route, { error: 'unban rejected' }, unbanFailure.failWith) + : fulfillJson(route, { status: 'unbanned' }); + }); + } + if (options.diagnosticsConfig) { const body = options.diagnosticsConfig; await page.route(CROWDSEC_ROUTES.diagnosticsConfig, (route) => fulfillJson(route, body)); From 10daf9d1ff7374f74ab0fae2baeff3f4385dea78 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Fri, 9 Oct 2026 22:20:16 +0000 Subject: [PATCH 12/22] test(e2e): deterministic crowdsec console enrollment Rewrite the console enrollment spec against a stubbed enrollment API so it no longer depends on the feature flag or on a CrowdSec engine. Covers each enrollment state (not enrolled, enrolling, pending acceptance, enrolled, failed) with its timestamps, heartbeat and masked last error, validation, the exact enroll, rotate, retry and re-enroll request payloads, refreshed status and server error outcomes, clearing the local state, and gating on local API readiness. The old real-backend contract checks for diagnostics and heartbeat are dropped here: diagnostics belongs to the diagnostics spec, and the UI reads heartbeat from the console status. --- .../crowdsec-console-enrollment.spec.ts | 792 +++++++++++------- 1 file changed, 499 insertions(+), 293 deletions(-) diff --git a/tests/security/crowdsec-console-enrollment.spec.ts b/tests/security/crowdsec-console-enrollment.spec.ts index aad91bbcb..e6099d1f1 100644 --- a/tests/security/crowdsec-console-enrollment.spec.ts +++ b/tests/security/crowdsec-console-enrollment.spec.ts @@ -1,18 +1,129 @@ /** * CrowdSec Console Enrollment E2E Tests * - * Tests the CrowdSec console enrollment functionality including: - * - Enrollment status API - * - Diagnostics connectivity status - * - Diagnostics config validation - * - Heartbeat status - * - UI enrollment section display + * The E2E container has no CrowdSec engine and the enrollment feature flag is off by + * default, so the enrollment API is stubbed (see tests/utils/crowdsec-stubs.ts) and the + * assertions run against known enrollment states: + * - What the card shows for each state (not enrolled, enrolling, pending acceptance, + * enrolled, failed) including timestamps, heartbeat and the last error + * - Validation before a request is sent + * - The exact enroll, rotate, retry and re-enroll requests + * - Outcomes: refreshed status, toasts and server errors + * - Clearing the local enrollment state + * - Gating on local API readiness + * + * The diagnostics endpoints are covered by crowdsec-diagnostics.spec.ts. The UI does not + * call the console heartbeat endpoint; heartbeat is read from the console status. * * @see /projects/Charon/docs/plans/crowdsec_enrollment_debug_spec.md */ +import type { Page } from '@playwright/test'; import { test, expect, loginUser } from '../fixtures/auth-fixtures'; +import { getToastLocator } from '../utils/ui-helpers'; import { waitForLoadingComplete } from '../utils/wait-helpers'; +import { + CROWDSEC_ROUTES, + crowdsecFixtures, + stubCrowdSecApi, + type ConsoleEnrollmentState, + type CrowdSecStubOptions, +} from '../utils/crowdsec-stubs'; + +/** The LAPI readiness warnings and gating only appear after the page's 3 second start-up grace period. */ +const GATING_TIMEOUT = 15_000; +const KEY = 'stub-enrollment-key'; + +const NOT_ENROLLED = crowdsecFixtures.notEnrolled(); +const ENROLLED = crowdsecFixtures.enrollment(); +const PENDING = crowdsecFixtures.enrollment({ status: 'pending_acceptance' }); +const ENROLLING = crowdsecFixtures.enrollment({ status: 'enrolling' }); +const FAILED = crowdsecFixtures.enrollment({ status: 'failed', last_error: 'key rejected by console' }); + +const card = (page: Page) => page.getByTestId('console-enrollment-card'); +const tokenField = (page: Page) => page.getByTestId('console-enrollment-token'); +const agentField = (page: Page) => page.getByTestId('console-agent-name'); +const tenantField = (page: Page) => page.getByTestId('console-tenant'); +const enrollButton = (page: Page) => card(page).getByRole('button', { name: 'Enroll', exact: true }); +const rotateButton = (page: Page) => card(page).getByRole('button', { name: 'Rotate Key', exact: true }); +const retryButton = (page: Page) => card(page).getByRole('button', { name: 'Retry Enrollment', exact: true }); +const hostname = (page: Page) => new URL(page.url()).hostname; + +/** Local time formatting exactly as the page renders a timestamp. */ +const localTime = (page: Page, iso: string) => page.evaluate((value) => new Date(value).toLocaleString(), iso); + +/** Installs the stubs (feature enabled, CrowdSec running) and loads the CrowdSec page. */ +async function openEnrollment( + page: Page, + state: ConsoleEnrollmentState, + options: CrowdSecStubOptions = {}, + beforeLoad?: () => Promise, +) { + const recorder = await stubCrowdSecApi(page, { + consoleEnrollmentEnabled: true, + consoleStatus: state, + status: crowdsecFixtures.runningStatus(), + ...options, + }); + await beforeLoad?.(); + await page.goto('/security/crowdsec'); + await waitForLoadingComplete(page); + await expect(card(page)).toBeVisible(); + return recorder; +} + +/** + * Serves `console/status` from a mutable state and records POST /console/enroll bodies and + * DELETE /console/enrollment calls. Registered after the base stubs so it takes precedence. + * `enrollResponse` is applied to the state when an enrollment succeeds. + */ +async function trackConsole( + page: Page, + initial: ConsoleEnrollmentState, + behaviour: { enrollResponse?: ConsoleEnrollmentState; enrollError?: { status: number; error: string } } = {}, +) { + const tracker = { state: initial, enrollments: [] as unknown[], clears: 0 }; + await page.route(CROWDSEC_ROUTES.consoleStatus, (route) => + route.request().method() === 'GET' + ? route.fulfill({ status: 200, contentType: 'application/json', json: tracker.state }) + : route.fallback(), + ); + await page.route(CROWDSEC_ROUTES.consoleEnroll, (route) => { + if (route.request().method() !== 'POST') return route.fallback(); + tracker.enrollments.push(route.request().postDataJSON()); + if (behaviour.enrollError) { + return route.fulfill({ + status: behaviour.enrollError.status, + contentType: 'application/json', + json: { error: behaviour.enrollError.error }, + }); + } + tracker.state = behaviour.enrollResponse ?? tracker.state; + return route.fulfill({ status: 200, contentType: 'application/json', json: tracker.state }); + }); + await page.route(CROWDSEC_ROUTES.consoleEnrollment, (route) => { + if (route.request().method() !== 'DELETE') return route.fallback(); + tracker.clears += 1; + tracker.state = NOT_ENROLLED; + return route.fulfill({ status: 200, contentType: 'application/json', json: { message: 'enrollment state cleared' } }); + }); + return tracker; +} + +/** Opens the page with the tracked console API in place of the plain status stub. */ +async function openTracked( + page: Page, + initial: ConsoleEnrollmentState, + behaviour: Parameters[2] = {}, + options: CrowdSecStubOptions = {}, +) { + let tracker!: Awaited>; + // Tracking routes must be registered after the base stubs so they take precedence. + await openEnrollment(page, initial, options, async () => { + tracker = await trackConsole(page, initial, behaviour); + }); + return tracker; +} test.describe('CrowdSec Console Enrollment', () => { test.beforeEach(async ({ page, adminUser }) => { @@ -20,367 +131,462 @@ test.describe('CrowdSec Console Enrollment', () => { await waitForLoadingComplete(page); }); - test.describe('Console Enrollment Status API', () => { - test('should fetch console enrollment status via API', async ({ request }) => { - await test.step('GET enrollment status endpoint', async () => { - const response = await request.get('/api/v1/admin/crowdsec/console/enrollment'); - - // Endpoint may not exist yet (return 404) or return enrollment status - if (response.status() === 404) { - test.info().annotations.push({ - type: 'info', - description: 'Console enrollment endpoint not implemented (404)', - }); - return; + test.describe('Enrollment States', () => { + const STATES = [ + { + name: 'not enrolled', + state: NOT_ENROLLED, + label: 'Status: not enrolled', + token: 'Not stored', + rotate: false, + retry: false, + reenroll: false, + pending: false, + }, + { + name: 'enrolling', + state: ENROLLING, + label: 'Status: enrolling', + token: 'Stored (masked)', + rotate: false, + retry: false, + reenroll: false, + pending: false, + }, + { + name: 'pending acceptance', + state: PENDING, + label: 'Status: pending acceptance', + token: 'Stored (masked)', + rotate: true, + retry: false, + reenroll: true, + pending: true, + }, + { + name: 'enrolled', + state: ENROLLED, + label: 'Status: enrolled', + token: 'Stored (masked)', + rotate: true, + retry: false, + reenroll: true, + pending: false, + }, + { + name: 'failed', + state: FAILED, + label: 'Status: degraded', + token: 'Stored (masked)', + rotate: true, + retry: true, + reenroll: false, + pending: false, + }, + ]; + + for (const { name, state, label, token, rotate, retry, reenroll, pending } of STATES) { + test(`should show the ${name} state and only the actions that apply`, async ({ page }) => { + await openEnrollment(page, state); + + await expect(page.getByTestId('console-status-label')).toHaveText(label); + await expect(page.getByTestId('console-token-state')).toHaveText(token); + await expect(enrollButton(page)).toBeVisible(); + if (rotate) { + await expect(rotateButton(page)).toBeEnabled(); + } else { + await expect(rotateButton(page)).toBeDisabled(); } + await expect(retryButton(page)).toHaveCount(retry ? 1 : 0); + await expect(page.getByTestId('reenroll-section')).toHaveCount(reenroll ? 1 : 0); + await expect(page.getByTestId('pending-acceptance-info')).toHaveCount(pending ? 1 : 0); + }); + } + + test('should show the agent, tenant and every timestamp of an enrolled agent', async ({ page }) => { + const enrollment = crowdsecFixtures.enrollment({ + last_attempt_at: '2026-01-01T00:00:00Z', + enrolled_at: '2026-01-01T00:01:00Z', + last_heartbeat_at: '2026-01-02T03:04:05Z', + correlation_id: 'corr-1234', + }); + await openEnrollment(page, enrollment); + + await expect(card(page).getByText(`Last heartbeat: ${await localTime(page, '2026-01-02T03:04:05Z')}`, { exact: true })).toBeVisible(); + await expect(card(page).getByText(`Last attempt: ${await localTime(page, '2026-01-01T00:00:00Z')}`, { exact: true })).toBeVisible(); + await expect(card(page).getByText(`Enrolled: ${await localTime(page, '2026-01-01T00:01:00Z')}`, { exact: true })).toBeVisible(); + await expect(card(page).getByText('Correlation ID: corr-1234', { exact: true })).toBeVisible(); + await expect(card(page).getByText('e2e-agent', { exact: true })).toBeVisible(); + await expect(card(page).getByText('e2e-tenant', { exact: true }).first()).toBeVisible(); + }); - expect(response.ok()).toBeTruthy(); + test('should show dashes for the times of an agent that was never enrolled', async ({ page }) => { + await openEnrollment(page, NOT_ENROLLED); - const status = await response.json(); + await expect(card(page).getByText('Last heartbeat: —', { exact: true })).toBeVisible(); + await expect(card(page).getByText('Last attempt: —', { exact: true })).toBeVisible(); + await expect(card(page).getByText('Enrolled: —', { exact: true })).toBeVisible(); + await expect(card(page).getByText(/Correlation ID/)).toHaveCount(0); + }); - // Verify response contains expected fields - expect(status).toHaveProperty('status'); - expect(['not_enrolled', 'enrolling', 'pending_acceptance', 'enrolled', 'failed']).toContain( - status.status - ); + test('should mask secret-looking words in the last error', async ({ page }) => { + await openEnrollment( + page, + crowdsecFixtures.enrollment({ status: 'failed', last_error: 'rejected key abcdefghij1234567890 by console' }), + ); - // Optional fields that should be present - if (status.status !== 'not_enrolled') { - expect(status).toHaveProperty('agent_name'); - expect(status).toHaveProperty('tenant'); - } + const error = page.getByTestId('console-status-error'); + await expect(error).toContainText('rejected key *** by console'); + await expect(error).not.toContainText('abcdefghij1234567890'); + }); - expect(status).toHaveProperty('last_attempt_at'); - expect(status).toHaveProperty('key_present'); - }); + test('should fall back to the not enrolled view when the status cannot be read', async ({ page }) => { + await openEnrollment(page, NOT_ENROLLED); + await page.route(CROWDSEC_ROUTES.consoleStatus, (route) => + route.fulfill({ status: 500, contentType: 'application/json', json: { error: 'failed to read enrollment status' } }), + ); + await page.reload(); + await waitForLoadingComplete(page); + + await expect(page.getByTestId('console-status-label')).toHaveText('Status: not enrolled'); + await expect(page.getByTestId('console-token-state')).toHaveText('—'); + await expect(rotateButton(page)).toBeDisabled(); }); - test('should fetch diagnostics connectivity status', async ({ request }) => { - await test.step('GET diagnostics connectivity endpoint', async () => { - const response = await request.get('/api/v1/admin/crowdsec/diagnostics/connectivity'); - - // Endpoint may not exist yet - if (response.status() === 404) { - test.info().annotations.push({ - type: 'info', - description: 'Diagnostics connectivity endpoint not implemented (404)', - }); - return; - } + test('should describe the card for assistive technology', async ({ page }) => { + await openEnrollment(page, NOT_ENROLLED); + + await expect(card(page)).toMatchAriaSnapshot(` + - heading "Console Enrollment" [level=3] + - textbox "Agent Name (optional)" + - textbox "Tenant/Organization (optional)" + - checkbox "I understand this will rotate my LAPI credentials if already enrolled" + - button "Enroll" [disabled] + - button "Rotate Key" [disabled] + `); + }); + }); - expect(response.ok()).toBeTruthy(); + test.describe('Token Handling', () => { + test('should mask the token as it is typed and allow revealing it', async ({ page }) => { + await openEnrollment(page, NOT_ENROLLED); - const connectivity = await response.json(); + await tokenField(page).fill(KEY); + await expect(tokenField(page)).toHaveAttribute('type', 'password'); - // Verify response contains expected boolean fields - expect(connectivity).toHaveProperty('lapi_running'); - expect(typeof connectivity.lapi_running).toBe('boolean'); + await card(page).getByRole('button', { name: 'Show password' }).click(); + await expect(tokenField(page)).toHaveAttribute('type', 'text'); + await expect(tokenField(page)).toHaveValue(KEY); + }); - expect(connectivity).toHaveProperty('lapi_ready'); - expect(typeof connectivity.lapi_ready).toBe('boolean'); + test('should keep Enroll disabled and explain why until a token is entered', async ({ page }) => { + await openEnrollment(page, NOT_ENROLLED); - expect(connectivity).toHaveProperty('capi_registered'); - expect(typeof connectivity.capi_registered).toBe('boolean'); + await expect(enrollButton(page)).toBeDisabled(); + await expect(enrollButton(page)).toHaveAttribute('title', 'Enrollment token is required'); - expect(connectivity).toHaveProperty('console_enrolled'); - expect(typeof connectivity.console_enrolled).toBe('boolean'); - }); + await tokenField(page).fill(' '); + await expect(enrollButton(page)).toBeDisabled(); + + await tokenField(page).fill(KEY); + await expect(enrollButton(page)).toBeEnabled(); + await expect(enrollButton(page)).not.toHaveAttribute('title'); }); + }); - test('should fetch diagnostics config validation', async ({ request }) => { - await test.step('GET diagnostics config endpoint', async () => { - const response = await request.get('/api/v1/admin/crowdsec/diagnostics/config'); - - // Endpoint may not exist yet - if (response.status() === 404) { - test.info().annotations.push({ - type: 'info', - description: 'Diagnostics config endpoint not implemented (404)', - }); - return; - } + test.describe('Validation', () => { + test('should require an agent name', async ({ page }) => { + const tracker = await openTracked(page, NOT_ENROLLED); - expect(response.ok()).toBeTruthy(); + await tokenField(page).fill(KEY); + await agentField(page).fill(''); + await tenantField(page).fill('e2e-tenant'); + await card(page).getByRole('checkbox', { name: /I understand this will rotate/ }).check(); + await enrollButton(page).click(); - const config = await response.json(); + await expect(card(page).getByText('Agent name is required', { exact: true })).toBeVisible(); + expect(tracker.enrollments).toEqual([]); + }); - // Verify response contains expected fields - expect(config).toHaveProperty('config_exists'); - expect(typeof config.config_exists).toBe('boolean'); + test('should require a tenant for a first enrollment', async ({ page }) => { + const tracker = await openTracked(page, NOT_ENROLLED); - expect(config).toHaveProperty('acquis_exists'); - expect(typeof config.acquis_exists).toBe('boolean'); + await tokenField(page).fill(KEY); + await card(page).getByRole('checkbox', { name: /I understand this will rotate/ }).check(); + await enrollButton(page).click(); - expect(config).toHaveProperty('lapi_port'); - expect(typeof config.lapi_port).toBe('string'); + await expect(card(page).getByText('Tenant / organization is required', { exact: true })).toBeVisible(); + expect(tracker.enrollments).toEqual([]); + }); - expect(config).toHaveProperty('errors'); - expect(Array.isArray(config.errors)).toBe(true); - }); + test('should list every missing field at once', async ({ page }) => { + const tracker = await openTracked(page, NOT_ENROLLED); + + await tokenField(page).fill(KEY); + await agentField(page).fill(''); + await enrollButton(page).click(); + + await expect(card(page).getByText('Agent name is required', { exact: true })).toBeVisible(); + await expect(card(page).getByText('Tenant / organization is required', { exact: true })).toBeVisible(); + await expect(card(page).getByText('You must acknowledge the console data-sharing notice', { exact: true })).toBeVisible(); + expect(tracker.enrollments).toEqual([]); }); - test('should fetch heartbeat status', async ({ request }) => { - await test.step('GET console heartbeat endpoint', async () => { - const response = await request.get('/api/v1/admin/crowdsec/console/heartbeat'); - - // Endpoint may not exist yet - if (response.status() === 404) { - test.info().annotations.push({ - type: 'info', - description: 'Console heartbeat endpoint not implemented (404)', - }); - return; - } + test('should enroll without a tenant because the field is labelled optional', async ({ page }) => { + test.fixme(true, 'F4: the tenant and agent name fields are labelled "(optional)" but validation rejects them when blank'); + const tracker = await openTracked(page, NOT_ENROLLED, { enrollResponse: PENDING }); - expect(response.ok()).toBeTruthy(); + await tokenField(page).fill(KEY); + await card(page).getByRole('checkbox', { name: /I understand this will rotate/ }).check(); + await enrollButton(page).click(); - const heartbeat = await response.json(); + await expect.poll(() => tracker.enrollments.length).toBe(1); + await expect(card(page).getByText('Tenant / organization is required')).toHaveCount(0); + }); - // Verify response contains expected fields - expect(heartbeat).toHaveProperty('status'); - expect(['not_enrolled', 'enrolling', 'pending_acceptance', 'enrolled', 'failed']).toContain( - heartbeat.status - ); + test('should enroll without an agent name because the field is labelled optional', async ({ page }) => { + test.fixme(true, 'F4: the tenant and agent name fields are labelled "(optional)" but validation rejects them when blank'); + const tracker = await openTracked(page, NOT_ENROLLED, { enrollResponse: PENDING }); - expect(heartbeat).toHaveProperty('last_heartbeat_at'); - // last_heartbeat_at can be null if not enrolled or not yet received - }); + await tokenField(page).fill(KEY); + await agentField(page).fill(''); + await tenantField(page).fill('e2e-tenant'); + await card(page).getByRole('checkbox', { name: /I understand this will rotate/ }).check(); + await enrollButton(page).click(); + + await expect.poll(() => tracker.enrollments.length).toBe(1); + await expect(card(page).getByText('Agent name is required')).toHaveCount(0); }); }); - test.describe('Console Enrollment UI', () => { - test('should display console enrollment section in UI when feature is enabled', async ({ - page, - }) => { - await test.step('Navigate to CrowdSec configuration', async () => { - await page.goto('/security/crowdsec'); - await waitForLoadingComplete(page); + test.describe('Enrolling', () => { + test('should send the trimmed values, mark the request as non-forced and show the pending state', async ({ page }) => { + const tracker = await openTracked(page, NOT_ENROLLED, { enrollResponse: PENDING }); + + await tokenField(page).fill(` ${KEY} `); + await agentField(page).fill(' e2e-agent '); + await tenantField(page).fill(' e2e-tenant '); + await card(page).getByRole('checkbox', { name: /I understand this will rotate/ }).check(); + await enrollButton(page).click(); + + await expect( + getToastLocator(page, 'Enrollment request sent! Accept the enrollment on app.crowdsec.net to complete registration.', { + type: 'success', + }), + ).toBeVisible(); + expect(tracker.enrollments).toEqual([ + { enrollment_key: KEY, tenant: 'e2e-tenant', agent_name: 'e2e-agent', force: false }, + ]); + + await test.step('The status is refreshed and the token is cleared', async () => { + await expect(page.getByTestId('console-status-label')).toHaveText('Status: pending acceptance'); + await expect(page.getByTestId('pending-acceptance-info')).toContainText('Action Required'); + await expect(tokenField(page)).toHaveValue(''); }); + }); - await test.step('Verify enrollment section visibility', async () => { - // Look for console enrollment section using various selectors - const enrollmentSection = page - .getByTestId('console-section') - .or(page.getByTestId('console-enrollment-section')) - .or(page.locator('[class*="card"]').filter({ hasText: /console.*enrollment|enroll/i })); - - const enrollmentVisible = await enrollmentSection.isVisible().catch(() => false); - - if (enrollmentVisible) { - await expect(enrollmentSection).toBeVisible(); + test('should default the agent name to the host name of the page', async ({ page }) => { + const tracker = await openTracked(page, NOT_ENROLLED, { enrollResponse: PENDING }); - // Check for token input - const tokenInput = page - .getByTestId('enrollment-token-input') - .or(page.getByTestId('crowdsec-token-input')) - .or(page.getByPlaceholder(/token|key/i)); + await expect(agentField(page)).toHaveValue(hostname(page)); + await tokenField(page).fill(KEY); + await tenantField(page).fill('e2e-tenant'); + await card(page).getByRole('checkbox', { name: /I understand this will rotate/ }).check(); + await enrollButton(page).click(); - const tokenInputVisible = await tokenInput.isVisible().catch(() => false); - if (tokenInputVisible) { - await expect(tokenInput).toBeVisible(); - } - } else { - // Feature might be disabled via feature flag - test.info().annotations.push({ - type: 'info', - description: - 'Console enrollment section not visible - feature may be disabled via feature flag', - }); - } - }); + await expect.poll(() => tracker.enrollments).toEqual([ + { enrollment_key: KEY, tenant: 'e2e-tenant', agent_name: hostname(page), force: false }, + ]); }); - test('should display enrollment status correctly', async ({ page }) => { - await test.step('Navigate to CrowdSec configuration', async () => { - await page.goto('/security/crowdsec'); - await waitForLoadingComplete(page); + test('should show the server error inline and as a toast, with secrets masked', async ({ page }) => { + const tracker = await openTracked(page, NOT_ENROLLED, { + enrollError: { status: 400, error: 'bad key abcdefghij1234567890 rejected' }, }); - await test.step('Verify enrollment status display', async () => { - // Look for status text that shows current enrollment state - const statusText = page - .getByTestId('console-token-state') - .or(page.getByTestId('enrollment-status')) - .or(page.locator('text=/not enrolled|pending.*acceptance|enrolled|enrolling/i')); - - const statusVisible = await statusText.first().isVisible().catch(() => false); - - if (statusVisible) { - // Verify one of the expected statuses is displayed - const possibleStatuses = [ - /not enrolled/i, - /pending.*acceptance/i, - /enrolled/i, - /enrolling/i, - /failed/i, - ]; - - let foundStatus = false; - for (const pattern of possibleStatuses) { - const statusMatch = page.getByText(pattern); - if (await statusMatch.first().isVisible().catch(() => false)) { - foundStatus = true; - break; - } - } - - if (!foundStatus) { - test.info().annotations.push({ - type: 'info', - description: 'No enrollment status text found in expected formats', - }); - } - } else { - test.info().annotations.push({ - type: 'info', - description: 'Enrollment status not visible - feature may not be implemented', - }); - } - }); + await tokenField(page).fill(KEY); + await tenantField(page).fill('e2e-tenant'); + await card(page).getByRole('checkbox', { name: /I understand this will rotate/ }).check(); + await enrollButton(page).click(); + + await expect(card(page).getByText('bad key *** rejected', { exact: true })).toBeVisible(); + await expect(getToastLocator(page, 'bad key *** rejected', { type: 'error' })).toBeVisible(); + await expect(getToastLocator(page, /Enrollment request sent/, { type: 'success' })).toHaveCount(0); + await expect(page.getByTestId('console-status-label')).toHaveText('Status: not enrolled'); + expect(tracker.enrollments).toHaveLength(1); }); + }); - test('should show enroll button when not enrolled', async ({ page, request }) => { - await test.step('Check enrollment status via API', async () => { - const response = await request.get('/api/v1/admin/crowdsec/console/enrollment'); + test.describe('Rotating the Key', () => { + test('should require a token before rotating', async ({ page }) => { + const tracker = await openTracked(page, ENROLLED); - if (response.status() === 404) { - test.info().annotations.push({ - type: 'skip', - description: 'Console enrollment API not implemented', - }); - return; - } + await rotateButton(page).click(); - const status = await response.json(); + await expect(card(page).getByText('Enrollment token is required', { exact: true })).toBeVisible(); + expect(tracker.enrollments).toEqual([]); + }); - if (status.status === 'not_enrolled') { - await page.goto('/security/crowdsec'); - await waitForLoadingComplete(page); + test('should send a forced request without asking for the acknowledgement', async ({ page }) => { + const tracker = await openTracked(page, ENROLLED, { enrollResponse: PENDING }); + + await tokenField(page).fill(KEY); + await agentField(page).fill('e2e-agent'); + await rotateButton(page).click(); + + await expect( + getToastLocator(page, 'Enrollment submitted! Accept the request on app.crowdsec.net to complete.', { + type: 'success', + }), + ).toBeVisible(); + await expect(card(page).getByText('You must acknowledge the console data-sharing notice')).toHaveCount(0); + expect(tracker.enrollments).toEqual([ + { enrollment_key: KEY, tenant: 'e2e-tenant', agent_name: 'e2e-agent', force: true }, + ]); + await expect(page.getByTestId('console-status-label')).toHaveText('Status: pending acceptance'); + }); - // Look for enroll button - const enrollButton = page.getByRole('button', { name: /enroll/i }); - const buttonVisible = await enrollButton.isVisible().catch(() => false); + test('should keep the enrolled agent name when rotating without editing it', async ({ page }) => { + test.fixme(true, 'F5: the agent name field is never filled from the enrolled status, so a rotation renames the agent to the page host name'); + const tracker = await openTracked(page, ENROLLED, { enrollResponse: PENDING }); - if (buttonVisible) { - await expect(enrollButton).toBeVisible(); - await expect(enrollButton).toBeEnabled(); - } - } else { - test.info().annotations.push({ - type: 'info', - description: `CrowdSec is already enrolled with status: ${status.status}`, - }); - } - }); + await expect(agentField(page)).toHaveValue('e2e-agent'); + await tokenField(page).fill(KEY); + await rotateButton(page).click(); + + await expect.poll(() => tracker.enrollments).toEqual([ + { enrollment_key: KEY, tenant: 'e2e-tenant', agent_name: 'e2e-agent', force: true }, + ]); }); + }); - test('should show agent name field when enrolling', async ({ page }) => { - await test.step('Navigate to CrowdSec configuration', async () => { - await page.goto('/security/crowdsec'); - await waitForLoadingComplete(page); - }); + test.describe('Retrying a Failed Enrollment', () => { + test('should require a token before retrying', async ({ page }) => { + const tracker = await openTracked(page, FAILED); - await test.step('Check for agent name input', async () => { - const agentNameInput = page - .getByTestId('agent-name-input') - .or(page.getByLabel(/agent.*name/i)) - .or(page.getByPlaceholder(/agent.*name/i)); + await retryButton(page).click(); - const inputVisible = await agentNameInput.isVisible().catch(() => false); + await expect(card(page).getByText('Enrollment token is required', { exact: true })).toBeVisible(); + expect(tracker.enrollments).toEqual([]); + }); - if (inputVisible) { - await expect(agentNameInput).toBeVisible(); - } else { - // Agent name input may only show when token is entered - test.info().annotations.push({ - type: 'info', - description: 'Agent name input not visible - may require token input first', - }); - } - }); + test('should send a forced request and move on to the pending state', async ({ page }) => { + const tracker = await openTracked(page, FAILED, { enrollResponse: PENDING }); + + await tokenField(page).fill(KEY); + await agentField(page).fill('e2e-agent'); + await retryButton(page).click(); + + await expect(page.getByTestId('console-status-label')).toHaveText('Status: pending acceptance'); + expect(tracker.enrollments).toEqual([ + { enrollment_key: KEY, tenant: 'e2e-tenant', agent_name: 'e2e-agent', force: true }, + ]); + await expect(retryButton(page)).toHaveCount(0); }); }); - test.describe('Enrollment Validation', () => { - test('should validate enrollment token format', async ({ page }) => { - await test.step('Navigate to CrowdSec configuration', async () => { - await page.goto('/security/crowdsec'); - await waitForLoadingComplete(page); - }); + test.describe('Re-enrollment', () => { + const section = (page: Page) => page.getByTestId('reenroll-section'); - await test.step('Check token input validation', async () => { - const tokenInput = page - .getByTestId('enrollment-token-input') - .or(page.getByTestId('crowdsec-token-input')) - .or(page.getByPlaceholder(/token|key/i)); + test('should reveal and hide the re-enrollment form', async ({ page }) => { + await openEnrollment(page, ENROLLED); - const inputVisible = await tokenInput.isVisible().catch(() => false); + await expect(section(page).getByRole('heading', { name: 'Re-enrollment Options' })).toBeVisible(); + await expect(section(page).getByLabel('New Enrollment Key')).toHaveCount(0); - if (!inputVisible) { - test.info().annotations.push({ - type: 'skip', - description: 'Token input not visible - console enrollment UI not implemented', - }); - return; - } + await section(page).getByRole('button', { name: 'Re-enroll with new key' }).click(); + await expect(section(page).getByLabel('New Enrollment Key')).toBeVisible(); + await expect(section(page).getByRole('button', { name: 'Re-enroll', exact: true })).toBeDisabled(); + + await section(page).getByRole('button', { name: 'Cancel' }).click(); + await expect(section(page).getByLabel('New Enrollment Key')).toHaveCount(0); + }); - // Try submitting empty token - const enrollButton = page.getByRole('button', { name: /enroll/i }); - const buttonVisible = await enrollButton.isVisible().catch(() => false); + test('should send a forced request with the new key and close the form', async ({ page }) => { + const tracker = await openTracked(page, ENROLLED, { enrollResponse: PENDING }); + + await section(page).getByRole('button', { name: 'Re-enroll with new key' }).click(); + await section(page).getByLabel('New Enrollment Key').fill('new-enrollment-key'); + await section(page).getByLabel('Agent Name (optional)').fill('e2e-agent'); + await section(page).getByRole('button', { name: 'Re-enroll', exact: true }).click(); + + await expect( + getToastLocator(page, 'Enrollment submitted! Accept the request on app.crowdsec.net to complete.', { + type: 'success', + }), + ).toBeVisible(); + expect(tracker.enrollments).toEqual([ + { enrollment_key: 'new-enrollment-key', tenant: 'e2e-tenant', agent_name: 'e2e-agent', force: true }, + ]); + await expect(section(page).getByLabel('New Enrollment Key')).toHaveCount(0); + }); - if (buttonVisible) { - await enrollButton.click(); + test('should link to the CrowdSec console for a new key', async ({ page }) => { + await openEnrollment(page, ENROLLED); - // Should show validation error - const errorText = page.getByText(/required|invalid|token/i); - const errorVisible = await errorText.first().isVisible().catch(() => false); + await expect(section(page).getByRole('link', { name: 'Get new enrollment key from Console' })).toHaveAttribute( + 'href', + 'https://app.crowdsec.net/security-engines', + ); + }); + }); - if (errorVisible) { - await expect(errorText.first()).toBeVisible(); - } - } + test.describe('Clearing the Enrollment State', () => { + const CONFIRMATION = 'This will clear local enrollment state. You may need to re-enroll. Continue?'; + + test('should leave the state untouched when the confirmation is declined', async ({ page }) => { + const tracker = await openTracked(page, ENROLLED); + + page.once('dialog', (dialog) => { + expect(dialog.type()).toBe('confirm'); + expect(dialog.message()).toBe(CONFIRMATION); + void dialog.dismiss(); }); + await page.getByRole('button', { name: 'Clear enrollment state' }).click(); + + await expect(page.getByTestId('console-status-label')).toHaveText('Status: enrolled'); + expect(tracker.clears).toBe(0); }); - test('should handle LAPI not running error gracefully', async ({ page, request }) => { - // LAPI availability enforced via CrowdSec internal checks. Verified in integration tests (backend/integration/). + test('should clear the state once confirmed and return to the not enrolled view', async ({ page }) => { + const tracker = await openTracked(page, ENROLLED); - await test.step('Attempt enrollment when LAPI is not running', async () => { - // This test would verify the error message when LAPI is not available - // Skipped because it requires stopping CrowdSec which affects other tests + page.once('dialog', (dialog) => { + expect(dialog.message()).toBe(CONFIRMATION); + void dialog.accept(); }); + await page.getByRole('button', { name: 'Clear enrollment state' }).click(); + + await expect(page.getByTestId('console-status-label')).toHaveText('Status: not enrolled'); + await expect(page.getByTestId('reenroll-section')).toHaveCount(0); + await expect(page.getByTestId('console-token-state')).toHaveText('Not stored'); + expect(tracker.clears).toBe(1); }); }); - test.describe('Enrollment Status Persistence', () => { - test('should persist enrollment status across page reloads', async ({ page, request }) => { - await test.step('Check initial status', async () => { - const response = await request.get('/api/v1/admin/crowdsec/console/enrollment'); - - if (response.status() === 404) { - test.info().annotations.push({ - type: 'skip', - description: 'Console enrollment API not implemented', - }); - return; - } + test.describe('Local API Readiness', () => { + const NOT_READY = crowdsecFixtures.runningStatus({ lapi_ready: false }); - const initialStatus = await response.json(); + test('should block enrolling and explain why while the local API is not ready', async ({ page }) => { + await openEnrollment(page, NOT_ENROLLED, { status: NOT_READY }); + await tokenField(page).fill(KEY); - await page.goto('/security/crowdsec'); - await waitForLoadingComplete(page); + await expect(enrollButton(page)).toBeDisabled({ timeout: GATING_TIMEOUT }); + await expect(enrollButton(page)).toHaveAttribute('title', 'LAPI must be ready to enroll'); + }); + + test('should block rotating the key while the local API is not ready', async ({ page }) => { + await openEnrollment(page, ENROLLED, { status: NOT_READY }); - // Reload page - await page.reload(); - await waitForLoadingComplete(page); + await expect(rotateButton(page)).toBeDisabled({ timeout: GATING_TIMEOUT }); + await expect(rotateButton(page)).toHaveAttribute('title', 'LAPI must be ready to rotate key'); + }); - // Verify status persists - const afterReloadResponse = await request.get('/api/v1/admin/crowdsec/console/enrollment'); - expect(afterReloadResponse.ok()).toBeTruthy(); + test('should block retrying while the local API is not ready', async ({ page }) => { + await openEnrollment(page, FAILED, { status: NOT_READY }); - const afterReloadStatus = await afterReloadResponse.json(); - expect(afterReloadStatus.status).toBe(initialStatus.status); - }); + await expect(retryButton(page)).toBeDisabled({ timeout: GATING_TIMEOUT }); + await expect(retryButton(page)).toHaveAttribute('title', 'LAPI must be ready to retry enrollment'); }); }); }); From 434e8eb22f11b8dca8ae7d57b2489360482553b5 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Fri, 9 Oct 2026 22:25:54 +0000 Subject: [PATCH 13/22] test(e2e): deterministic crowdsec diagnostics Import a known configuration before each diagnostics, export and file test and assert the exact responses. Replace the two diagnostics UI checks, which looked for text the page never renders, with stubbed Security page running and stopped states. Add a fixme for the export being gzipped twice when the client accepts compression. Retitle existing fixmes to reference #1530 and #1531. --- .../crowdsec-console-enrollment.spec.ts | 6 +- tests/security/crowdsec-dashboard.spec.ts | 2 +- tests/security/crowdsec-diagnostics.spec.ts | 623 ++++++------------ 3 files changed, 196 insertions(+), 435 deletions(-) diff --git a/tests/security/crowdsec-console-enrollment.spec.ts b/tests/security/crowdsec-console-enrollment.spec.ts index e6099d1f1..e5340f27b 100644 --- a/tests/security/crowdsec-console-enrollment.spec.ts +++ b/tests/security/crowdsec-console-enrollment.spec.ts @@ -333,7 +333,7 @@ test.describe('CrowdSec Console Enrollment', () => { }); test('should enroll without a tenant because the field is labelled optional', async ({ page }) => { - test.fixme(true, 'F4: the tenant and agent name fields are labelled "(optional)" but validation rejects them when blank'); + test.fixme(true, '#1531 F4: the tenant and agent name fields are labelled "(optional)" but validation rejects them when blank'); const tracker = await openTracked(page, NOT_ENROLLED, { enrollResponse: PENDING }); await tokenField(page).fill(KEY); @@ -345,7 +345,7 @@ test.describe('CrowdSec Console Enrollment', () => { }); test('should enroll without an agent name because the field is labelled optional', async ({ page }) => { - test.fixme(true, 'F4: the tenant and agent name fields are labelled "(optional)" but validation rejects them when blank'); + test.fixme(true, '#1531 F4: the tenant and agent name fields are labelled "(optional)" but validation rejects them when blank'); const tracker = await openTracked(page, NOT_ENROLLED, { enrollResponse: PENDING }); await tokenField(page).fill(KEY); @@ -447,7 +447,7 @@ test.describe('CrowdSec Console Enrollment', () => { }); test('should keep the enrolled agent name when rotating without editing it', async ({ page }) => { - test.fixme(true, 'F5: the agent name field is never filled from the enrolled status, so a rotation renames the agent to the page host name'); + test.fixme(true, '#1531 F5: the agent name field is never filled from the enrolled status, so a rotation renames the agent to the page host name'); const tracker = await openTracked(page, ENROLLED, { enrollResponse: PENDING }); await expect(agentField(page)).toHaveValue('e2e-agent'); diff --git a/tests/security/crowdsec-dashboard.spec.ts b/tests/security/crowdsec-dashboard.spec.ts index b4798b11c..d5554a564 100644 --- a/tests/security/crowdsec-dashboard.spec.ts +++ b/tests/security/crowdsec-dashboard.spec.ts @@ -415,7 +415,7 @@ test.describe('CrowdSec Dashboard @security', () => { }); test('should name the time range group in readable text', async ({ page }) => { - test.fixme(true, 'F3: security.crowdsec.dashboard.timeRange is an object in the translations, so the radiogroup name is an i18next error string instead of "Time range"'); + test.fixme(true, '#1530 F3: security.crowdsec.dashboard.timeRange is an object in the translations, so the radiogroup name is an i18next error string instead of "Time range"'); await openDashboard(page); await expect(page.getByRole('radiogroup', { name: 'Time range', exact: true })).toBeVisible(); diff --git a/tests/security/crowdsec-diagnostics.spec.ts b/tests/security/crowdsec-diagnostics.spec.ts index 302b4352e..6fca037c1 100644 --- a/tests/security/crowdsec-diagnostics.spec.ts +++ b/tests/security/crowdsec-diagnostics.spec.ts @@ -1,505 +1,266 @@ /** * CrowdSec Diagnostics E2E Tests * - * Tests the CrowdSec diagnostic functionality including: - * - Configuration file validation - * - Connectivity checks to CrowdSec services + * The diagnostics, export and file endpoints are served by the real backend, which has no + * CrowdSec engine in the E2E container (no cscli, nothing running). Each test first imports + * a known configuration archive so the files on disk are fixed, then asserts the exact + * response the backend produces for that state: + * - Configuration validation (diagnostics/config) + * - Connectivity checks (diagnostics/connectivity) * - Configuration export + * - Configuration file listing and reading + * + * The Security page is the only UI that reports the CrowdSec process state; its running and + * stopped indicators are covered with a stubbed status endpoint. * * @see /projects/Charon/docs/plans/crowdsec_enrollment_debug_spec.md */ +import { gunzipSync } from 'zlib'; +import type { APIRequestContext } from '@playwright/test'; import { test, expect, loginUser } from '../fixtures/auth-fixtures'; +import { createTarGz } from '../utils/archive-helpers'; +import { stubCrowdSecApi, crowdsecFixtures } from '../utils/crowdsec-stubs'; import { waitForLoadingComplete } from '../utils/wait-helpers'; +import { promises as fs } from 'fs'; + +const ADMIN = '/api/v1/admin/crowdsec'; +const CONFIG_YAML = `api: + server: + listen_uri: 127.0.0.1:8085 +`; +const ACQUIS_YAML = `source: file +filenames: + - /var/log/e2e.log +labels: + type: syslog +`; + +/** Replaces the CrowdSec configuration with a known one, so every test starts from the same files. */ +async function importKnownConfig( + request: APIRequestContext, + archivePath: string, + files: Record = { 'config.yaml': CONFIG_YAML, 'acquis.yaml': ACQUIS_YAML }, +) { + await createTarGz(files, archivePath); + const response = await request.post(`${ADMIN}/import`, { + multipart: { + file: { name: 'known.tar.gz', mimeType: 'application/gzip', buffer: await fs.readFile(archivePath) }, + }, + }); + expect(response.status()).toBe(200); +} test.describe('CrowdSec Diagnostics', () => { - test.beforeEach(async ({ page, adminUser }) => { + test.beforeEach(async ({ page, adminUser, request }, testInfo) => { await loginUser(page, adminUser); await waitForLoadingComplete(page); + await importKnownConfig(request, testInfo.outputPath('known.tar.gz')); }); test.describe('Configuration Validation', () => { - test('should validate CrowdSec configuration files via API', async ({ request }) => { - await test.step('GET diagnostics config endpoint', async () => { - const response = await request.get('/api/v1/admin/crowdsec/diagnostics/config'); - - // Endpoint may not exist yet - if (response.status() === 404) { - test.info().annotations.push({ - type: 'info', - description: 'Diagnostics config endpoint not implemented (404)', - }); - return; - } - - expect(response.ok()).toBeTruthy(); - - const config = await response.json(); - - // Verify config.yaml validation - expect(config).toHaveProperty('config_exists'); - expect(typeof config.config_exists).toBe('boolean'); - - if (config.config_exists) { - expect(config).toHaveProperty('config_valid'); - expect(typeof config.config_valid).toBe('boolean'); - } - - // Verify acquis.yaml validation - expect(config).toHaveProperty('acquis_exists'); - expect(typeof config.acquis_exists).toBe('boolean'); - - if (config.acquis_exists) { - expect(config).toHaveProperty('acquis_valid'); - expect(typeof config.acquis_valid).toBe('boolean'); - } - - // Verify LAPI port configuration - expect(config).toHaveProperty('lapi_port'); - - // Verify errors array - expect(config).toHaveProperty('errors'); - expect(Array.isArray(config.errors)).toBe(true); + test('should report both files present and the configured LAPI port', async ({ request }) => { + const response = await request.get(`${ADMIN}/diagnostics/config`); + expect(response.status()).toBe(200); + const config = await response.json(); + + expect(config).toMatchObject({ + config_exists: true, + acquis_exists: true, + acquis_valid: true, + lapi_port: '8085', }); }); - test('should report config.yaml exists when CrowdSec is initialized', async ({ request }) => { - await test.step('Check config file existence', async () => { - const response = await request.get('/api/v1/admin/crowdsec/diagnostics/config'); - - if (response.status() === 404) { - test.info().annotations.push({ - type: 'skip', - description: 'Diagnostics config endpoint not implemented', - }); - return; - } - - const config = await response.json(); - - // Check if CrowdSec is running - const statusResponse = await request.get('/api/v1/admin/crowdsec/status'); - if (statusResponse.ok()) { - const status = await statusResponse.json(); - - if (status.running) { - // If CrowdSec is running, config should exist - expect(config.config_exists).toBe(true); - } - } - }); + test('should report config.yaml invalid without the CrowdSec CLI, naming the failure', async ({ request }) => { + const response = await request.get(`${ADMIN}/diagnostics/config`); + expect(response.status()).toBe(200); + const config = await response.json(); + + // The E2E container has no cscli, so `cscli config check` cannot succeed. + expect(config.config_valid).toBe(false); + expect(config.errors).toHaveLength(1); + expect(config.errors[0]).toMatch(/^config\.yaml validation failed/); }); - test('should report LAPI port configuration', async ({ request }) => { - await test.step('Verify LAPI port in config', async () => { - const response = await request.get('/api/v1/admin/crowdsec/diagnostics/config'); + test('should report missing files and an empty LAPI port when only config.yaml is imported', async ({ request }, testInfo) => { + await importKnownConfig(request, testInfo.outputPath('config-only.tar.gz'), { + 'config.yaml': 'api:\n server:\n listen_uri: 0.0.0.0:8080\n', + }); - if (response.status() === 404) { - test.info().annotations.push({ - type: 'skip', - description: 'Diagnostics config endpoint not implemented', - }); - return; - } + const response = await request.get(`${ADMIN}/diagnostics/config`); + expect(response.status()).toBe(200); + const config = await response.json(); - const config = await response.json(); + expect(config).toMatchObject({ config_exists: true, acquis_exists: false, acquis_valid: false, lapi_port: '' }); + expect(config.errors).toContain('acquis.yaml not found'); + }); - // LAPI should be configured on port 8085 (not 8080 to avoid conflict with Charon) - if (config.lapi_port) { - expect(config.lapi_port).toBe('8085'); - } + test('should report an acquisition file without a datasource as invalid', async ({ request }, testInfo) => { + await importKnownConfig(request, testInfo.outputPath('bad-acquis.tar.gz'), { + 'config.yaml': CONFIG_YAML, + 'acquis.yaml': 'labels:\n type: syslog\n', }); + + const response = await request.get(`${ADMIN}/diagnostics/config`); + expect(response.status()).toBe(200); + const config = await response.json(); + + expect(config).toMatchObject({ acquis_exists: true, acquis_valid: false }); + expect(config.errors).toContain( + "acquis.yaml missing datasource configuration (expected 'source:' and 'filenames:' or 'filename:')", + ); }); }); test.describe('Connectivity Checks', () => { - test('should check connectivity to CrowdSec services', async ({ request }) => { - await test.step('GET diagnostics connectivity endpoint', async () => { - const response = await request.get('/api/v1/admin/crowdsec/diagnostics/connectivity'); - - if (response.status() === 404) { - test.info().annotations.push({ - type: 'info', - description: 'Diagnostics connectivity endpoint not implemented (404)', - }); - return; - } - - expect(response.ok()).toBeTruthy(); - - const connectivity = await response.json(); - - // All connectivity checks should return boolean values - const expectedChecks = [ - 'lapi_running', - 'lapi_ready', - 'capi_registered', - 'console_enrolled', - ]; - - for (const check of expectedChecks) { - expect(connectivity).toHaveProperty(check); - expect(typeof connectivity[check]).toBe('boolean'); - } - }); + test('should report every connectivity check as a boolean', async ({ request }) => { + const response = await request.get(`${ADMIN}/diagnostics/connectivity`); + expect(response.status()).toBe(200); + const connectivity = await response.json(); + + for (const check of [ + 'lapi_running', + 'lapi_ready', + 'capi_registered', + 'capi_reachable', + 'console_enrolled', + 'console_reachable', + ]) { + expect(typeof connectivity[check], check).toBe('boolean'); + } }); - test('should report LAPI status accurately', async ({ request }) => { - await test.step('Compare LAPI status between endpoints', async () => { - // Get status from main status endpoint - const statusResponse = await request.get('/api/v1/admin/crowdsec/status'); - - if (!statusResponse.ok()) { - test.info().annotations.push({ - type: 'skip', - description: 'CrowdSec status endpoint not available', - }); - return; - } - - const status = await statusResponse.json(); - - // Get connectivity diagnostics - const connectivityResponse = await request.get( - '/api/v1/admin/crowdsec/diagnostics/connectivity' - ); - - if (connectivityResponse.status() === 404) { - test.info().annotations.push({ - type: 'skip', - description: 'Diagnostics connectivity endpoint not implemented', - }); - return; - } - - const connectivity = await connectivityResponse.json(); - - // LAPI running status should be consistent between endpoints - expect(connectivity.lapi_running).toBe(status.running); - - if (status.running && status.lapi_ready !== undefined) { - expect(connectivity.lapi_ready).toBe(status.lapi_ready); - } - }); - }); + test('should report CrowdSec stopped and LAPI not ready when no engine is running', async ({ request }) => { + const connectivity = await (await request.get(`${ADMIN}/diagnostics/connectivity`)).json(); - test('should check CAPI registration status', async ({ request }) => { - await test.step('Verify CAPI registration check', async () => { - const response = await request.get('/api/v1/admin/crowdsec/diagnostics/connectivity'); - - if (response.status() === 404) { - test.info().annotations.push({ - type: 'skip', - description: 'Diagnostics connectivity endpoint not implemented', - }); - return; - } - - const connectivity = await response.json(); - - expect(connectivity).toHaveProperty('capi_registered'); - expect(typeof connectivity.capi_registered).toBe('boolean'); - - // If console is enrolled, CAPI must be registered - if (connectivity.console_enrolled) { - expect(connectivity.capi_registered).toBe(true); - } - }); + expect(connectivity.lapi_running).toBe(false); + expect(connectivity.lapi_ready).toBe(false); }); - test('should optionally report console reachability', async ({ request }) => { - // Diagnostic checks involving external connectivity can depend on network conditions - test.setTimeout(60000); + test('should agree with the status endpoint on whether CrowdSec is running', async ({ request }) => { + const statusResponse = await request.get(`${ADMIN}/status`); + expect(statusResponse.status()).toBe(200); + const status = await statusResponse.json(); + const connectivity = await (await request.get(`${ADMIN}/diagnostics/connectivity`)).json(); - await test.step('Check console API reachability', async () => { - await expect(async () => { - const response = await request.get('/api/v1/admin/crowdsec/diagnostics/connectivity'); + expect(connectivity.lapi_running).toBe(status.running); + expect(connectivity.lapi_ready).toBe(status.lapi_ready); + }); - if (response.status() === 404) { - // If endpoint is not implemented, we pass - return; - } + test('should report CAPI as not registered without the online credentials file', async ({ request }) => { + const connectivity = await (await request.get(`${ADMIN}/diagnostics/connectivity`)).json(); - expect(response.ok()).toBeTruthy(); + expect(connectivity.capi_registered).toBe(false); + expect(connectivity.capi_reachable).toBe(false); + }); - const connectivity = await response.json(); + test('should report CAPI as registered once the online credentials file exists', async ({ request }, testInfo) => { + await importKnownConfig(request, testInfo.outputPath('with-capi.tar.gz'), { + 'config.yaml': CONFIG_YAML, + 'acquis.yaml': ACQUIS_YAML, + 'online_api_credentials.yaml': 'url: https://api.crowdsec.net/\nlogin: e2e\npassword: e2e\n', + }); - // console_reachable and capi_reachable are optional but valuable - if (connectivity.console_reachable !== undefined) { - expect(typeof connectivity.console_reachable).toBe('boolean'); - } + const connectivity = await (await request.get(`${ADMIN}/diagnostics/connectivity`)).json(); - if (connectivity.capi_reachable !== undefined) { - expect(typeof connectivity.capi_reachable).toBe('boolean'); - } - }).toPass({ timeout: 30000 }); - }); + expect(connectivity.capi_registered).toBe(true); }); }); test.describe('Configuration Export', () => { - test('should export CrowdSec configuration', async ({ request }) => { - await test.step('GET export endpoint', async () => { - const response = await request.get('/api/v1/admin/crowdsec/export'); - - if (response.status() === 404) { - test.info().annotations.push({ - type: 'info', - description: 'Export endpoint not implemented (404)', - }); - return; - } - - expect(response.ok()).toBeTruthy(); - - // Verify response is gzip compressed - const contentType = response.headers()['content-type']; - expect(contentType).toContain('application/gzip'); - - // Verify content disposition header - const contentDisposition = response.headers()['content-disposition']; - expect(contentDisposition).toMatch(/attachment/); - expect(contentDisposition).toMatch(/crowdsec-config/); - expect(contentDisposition).toMatch(/\.tar\.gz/); - - // Verify response body is not empty - const body = await response.body(); - expect(body.length).toBeGreaterThan(0); - }); + test('should export the configuration as a timestamped gzip archive containing the imported files', async ({ request }) => { + // Identity encoding keeps the response-compression middleware out of the way (see the fixme below). + const response = await request.get(`${ADMIN}/export`, { headers: { 'Accept-Encoding': 'identity' } }); + expect(response.status()).toBe(200); + + expect(response.headers()['content-type']).toContain('application/gzip'); + expect(response.headers()['content-disposition']).toMatch( + /^attachment; filename=crowdsec-config-\d{8}-\d{6}\.tar\.gz$/, + ); + + const archive = gunzipSync(await response.body()).toString('latin1'); + expect(archive).toContain('config.yaml'); + expect(archive).toContain('acquis.yaml'); + expect(archive).toContain('listen_uri: 127.0.0.1:8085'); }); - test('should include filename with timestamp in export', async ({ request }) => { - await test.step('Verify export filename format', async () => { - const response = await request.get('/api/v1/admin/crowdsec/export'); + test('should export a single gzip layer to clients that accept compressed responses', async ({ request }) => { + test.fixme(true, 'F6: with Accept-Encoding gzip the export is gzipped a second time by the response middleware and sent without a Content-Encoding header, so the downloaded .tar.gz is double-compressed'); + const response = await request.get(`${ADMIN}/export`); + expect(response.status()).toBe(200); - if (response.status() === 404) { - test.info().annotations.push({ - type: 'skip', - description: 'Export endpoint not implemented', - }); - return; - } - - const contentDisposition = response.headers()['content-disposition']; - - // Filename should contain crowdsec-config and end with .tar.gz - expect(contentDisposition).toMatch(/filename[^;=\n]*=[^;=\n]*crowdsec-config/); - expect(contentDisposition).toMatch(/\.tar\.gz/); - }); + const body = await response.body(); + const archive = gunzipSync(body).toString('latin1'); + expect(archive).toContain('config.yaml'); }); }); test.describe('Configuration Files API', () => { - test('should list CrowdSec configuration files', async ({ request }) => { - await test.step('GET files list endpoint', async () => { - const response = await request.get('/api/v1/admin/crowdsec/files'); - - if (response.status() === 404) { - test.info().annotations.push({ - type: 'info', - description: 'Files list endpoint not implemented (404)', - }); - return; - } - - expect(response.ok()).toBeTruthy(); - - const files = await response.json(); - - expect(files).toHaveProperty('files'); - expect(Array.isArray(files.files)).toBe(true); - - // Verify essential config files are listed - const fileList = files.files as string[]; - - const hasConfigYaml = fileList.some((f) => f.includes('config.yaml') || f.includes('config/config.yaml')); - const hasAcquisYaml = fileList.some((f) => f.includes('acquis.yaml') || f.includes('config/acquis.yaml')); - - // ListFiles (backend/internal/api/handlers/crowdsec_handler.go) walks - // the whole CrowdSec DataDir, which can be non-empty for reasons - // unrelated to a completed local-install (e.g. hub_cache/ entries - // populated by a hub sync, or a bouncer_key file) even before - // config.yaml/acquis.yaml have ever been written. `fileList.length > - // 0` is therefore not a reliable proxy for "CrowdSec is installed" - - // confirmed live by inspecting a running E2E container mid-hub-sync - // (non-empty hub_cache/, no config/acquis yet). Gate the - // config/acquis expectation on the same `config_exists`/ - // `acquis_exists` signal the "Configuration Validation" describe - // block above already treats as authoritative for install state, - // instead of inferring installation from incidental file presence. - const diagResponse = await request.get('/api/v1/admin/crowdsec/diagnostics/config'); - if (diagResponse.ok()) { - const diag = await diagResponse.json(); - if (diag.config_exists || diag.acquis_exists) { - expect(hasConfigYaml || hasAcquisYaml).toBe(true); - } - } - }); - }); + test('should list the imported configuration files', async ({ request }) => { + const response = await request.get(`${ADMIN}/files`); + expect(response.status()).toBe(200); + const { files } = await response.json(); - test('should retrieve specific config file content', async ({ request }) => { - await test.step('GET specific file content', async () => { - // First get the file list - const listResponse = await request.get('/api/v1/admin/crowdsec/files'); - - if (listResponse.status() === 404) { - test.info().annotations.push({ - type: 'skip', - description: 'Files list endpoint not implemented', - }); - return; - } - - const files = await listResponse.json(); - const fileList = files.files as string[]; - - // Find config.yaml path - const configPath = fileList.find((f) => f.includes('config.yaml')); - - if (!configPath) { - test.info().annotations.push({ - type: 'info', - description: 'config.yaml not found in file list', - }); - return; - } - - // Retrieve file content - const contentResponse = await request.get( - `/api/v1/admin/crowdsec/file?path=${encodeURIComponent(configPath)}` - ); - - if (contentResponse.status() === 404) { - test.info().annotations.push({ - type: 'info', - description: 'File content retrieval not implemented', - }); - return; - } - - expect(contentResponse.ok()).toBeTruthy(); - - const content = await contentResponse.json(); - - expect(content).toHaveProperty('content'); - expect(typeof content.content).toBe('string'); - - // Verify config contains expected LAPI configuration - expect(content.content).toContain('listen_uri'); - }); + expect(files).toEqual(expect.arrayContaining(['config.yaml', 'acquis.yaml'])); }); - }); - test.describe('Diagnostics UI', () => { - test('should display CrowdSec status indicators', async ({ page }) => { - await test.step('Navigate to CrowdSec page', async () => { - await page.goto('/security/crowdsec'); - await waitForLoadingComplete(page); - }); + test('should return the content of config.yaml', async ({ request }) => { + const response = await request.get(`${ADMIN}/file?path=${encodeURIComponent('config.yaml')}`); + expect(response.status()).toBe(200); - await test.step('Verify status indicators are present', async () => { - // Look for status badges or indicators - const statusBadge = page.locator('[class*="badge"]').filter({ - hasText: /running|stopped|enabled|disabled|online|offline/i, - }); - - const statusVisible = await statusBadge.first().isVisible().catch(() => false); - - if (statusVisible) { - await expect(statusBadge.first()).toBeVisible(); - } else { - // Status may be displayed differently - const statusText = page.getByText(/crowdsec.*running|crowdsec.*stopped|lapi.*ready/i); - const textVisible = await statusText.first().isVisible().catch(() => false); - - if (!textVisible) { - test.info().annotations.push({ - type: 'info', - description: 'Status indicators not found in expected format', - }); - } - } - }); + expect((await response.json()).content).toBe(CONFIG_YAML); }); - test('should display LAPI ready status when CrowdSec is running', async ({ page, request }) => { - await test.step('Check CrowdSec status', async () => { - const statusResponse = await request.get('/api/v1/admin/crowdsec/status'); + test('should reject reading a file outside the configuration directory', async ({ request }) => { + const response = await request.get(`${ADMIN}/file?path=${encodeURIComponent('../../etc/passwd')}`); - if (!statusResponse.ok()) { - test.info().annotations.push({ - type: 'skip', - description: 'CrowdSec status endpoint not available', - }); - return; - } + expect(response.status()).toBe(400); + expect((await response.json()).error).toBe('invalid path'); + }); + }); - const status = await statusResponse.json(); + test.describe('Diagnostics UI', () => { + test('should show the CrowdSec process as running with its PID', async ({ page }) => { + await stubCrowdSecApi(page, { status: crowdsecFixtures.runningStatus({ pid: 4321 }) }); + await page.goto('/security'); + await waitForLoadingComplete(page); - await page.goto('/security/crowdsec'); - await waitForLoadingComplete(page); + await expect(page.getByText('Running (PID 4321)', { exact: true })).toBeVisible(); + await expect(page.getByTestId('toggle-crowdsec')).toBeChecked(); + }); - if (status.running && status.lapi_ready) { - // LAPI ready status should be visible - const lapiStatus = page.getByText(/lapi.*ready|local.*api.*ready/i); - const lapiVisible = await lapiStatus.isVisible().catch(() => false); + test('should show the CrowdSec process as stopped', async ({ page }) => { + await stubCrowdSecApi(page, { status: crowdsecFixtures.runningStatus({ running: false, pid: 0, lapi_ready: false }) }); + await page.goto('/security'); + await waitForLoadingComplete(page); - if (lapiVisible) { - await expect(lapiStatus).toBeVisible(); - } - } - }); + await expect(page.getByText('Process stopped', { exact: true })).toBeVisible(); + await expect(page.getByTestId('toggle-crowdsec')).not.toBeChecked(); }); }); test.describe('Error Handling', () => { - test('should handle CrowdSec not running gracefully', async ({ page, request }) => { - await test.step('Check diagnostics when CrowdSec may not be running', async () => { - const response = await request.get('/api/v1/admin/crowdsec/diagnostics/connectivity'); - - if (response.status() === 404) { - test.info().annotations.push({ - type: 'skip', - description: 'Diagnostics endpoint not implemented', - }); - return; - } - - // Even when CrowdSec is not running, endpoint should return valid response - expect(response.ok()).toBeTruthy(); - - const connectivity = await response.json(); - - // Response should indicate CrowdSec is not running if that's the case - if (!connectivity.lapi_running) { - expect(connectivity.lapi_ready).toBe(false); - } - }); - }); - - test('should report errors in diagnostics config validation', async ({ request }) => { - await test.step('Check for validation errors reporting', async () => { - const response = await request.get('/api/v1/admin/crowdsec/diagnostics/config'); + test('should still answer diagnostics with a complete report when CrowdSec is not running', async ({ request }) => { + const response = await request.get(`${ADMIN}/diagnostics/connectivity`); - if (response.status() === 404) { - test.info().annotations.push({ - type: 'skip', - description: 'Diagnostics config endpoint not implemented', - }); - return; - } + expect(response.status()).toBe(200); + const connectivity = await response.json(); + expect(connectivity.lapi_running).toBe(false); + expect(connectivity.lapi_ready).toBe(false); + }); - const config = await response.json(); + test('should list every missing file as a string error', async ({ request }, testInfo) => { + await importKnownConfig(request, testInfo.outputPath('empty-acquis.tar.gz'), { + 'config.yaml': CONFIG_YAML, + }); - // errors should always be an array (empty if no errors) - expect(config).toHaveProperty('errors'); - expect(Array.isArray(config.errors)).toBe(true); + const config = await (await request.get(`${ADMIN}/diagnostics/config`)).json(); - // Each error should be a string - for (const error of config.errors) { - expect(typeof error).toBe('string'); - } - }); + expect(config.errors).toEqual([ + expect.stringMatching(/^config\.yaml validation failed/), + 'acquis.yaml not found', + ]); }); }); }); From 2f4bb5d4f62889f3051e3b697f62ee3675691088 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Fri, 9 Oct 2026 23:05:03 +0000 Subject: [PATCH 14/22] fix: stop compressing the CrowdSec config export twice The export is already a .tar.gz, so it is excluded from the router-wide compression. Clients that accept gzip now receive one valid archive. --- .../api/routes/crowdsec_export_gzip_test.go | 72 +++++++++++++++++++ backend/internal/api/routes/routes.go | 12 +++- 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 backend/internal/api/routes/crowdsec_export_gzip_test.go diff --git a/backend/internal/api/routes/crowdsec_export_gzip_test.go b/backend/internal/api/routes/crowdsec_export_gzip_test.go new file mode 100644 index 000000000..2a5a65f60 --- /dev/null +++ b/backend/internal/api/routes/crowdsec_export_gzip_test.go @@ -0,0 +1,72 @@ +package routes + +import ( + "archive/tar" + "compress/gzip" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/Wikid82/charon/backend/internal/api/handlers" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// TestCrowdsecExport_NotDoubleCompressed guards against the router-wide gzip middleware +// wrapping the export handler's own tar.gz stream a second time. +func TestCrowdsecExport_NotDoubleCompressed(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(isolatedMemoryDSN(t)), &gorm.Config{}) + require.NoError(t, err) + + dataDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dataDir, "config"), 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(dataDir, "config", "config.yaml"), []byte("test: config\n"), 0o600)) + + h := handlers.NewCrowdsecHandler(db, nil, "/bin/false", dataDir) + t.Cleanup(func() { + if h.Security != nil { + h.Security.Close() + } + }) + + r := gin.New() + r.Use(compressionMiddleware()) + h.RegisterRoutes(r.Group("/api/v1")) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/export", http.NoBody) + req.Header.Set("Accept-Encoding", "gzip") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "application/gzip", w.Header().Get("Content-Type")) + require.Contains(t, w.Header().Get("Content-Disposition"), "attachment; filename=crowdsec-config-") + + // Decode transport compression exactly as a client would (honouring Content-Encoding). + var body io.Reader = w.Body + if w.Header().Get("Content-Encoding") == "gzip" { + zr, zerr := gzip.NewReader(body) + require.NoError(t, zerr) + body = zr + } + // What remains must be the tar.gz file itself. + gr, err := gzip.NewReader(body) + require.NoError(t, err, "download is not a single-layer tar.gz") + tr := tar.NewReader(gr) + var names []string + for { + hdr, nerr := tr.Next() + if nerr == io.EOF { + break + } + require.NoError(t, nerr) + names = append(names, hdr.Name) + } + require.Contains(t, names, filepath.Join("config", "config.yaml")) +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index de1321d25..fa4ca3184 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -210,7 +210,7 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg router.Use(middleware.EmergencyBypass(cfg.Security.ManagementCIDRs, db)) // Enable gzip compression for API responses (reduces payload size ~70%) - router.Use(gzip.Gzip(gzip.DefaultCompression)) + router.Use(compressionMiddleware()) // Apply security headers middleware globally // This sets CSP, HSTS, X-Frame-Options, etc. @@ -1253,3 +1253,13 @@ func logAuthThrottlePolicy(st middleware.AuthRateLimitStatus) { config.EnvAuthRateLimitEnabled) } } + +// precompressedPaths lists routes whose handlers already emit a gzip stream (tar.gz downloads). +// They must bypass the router-wide gzip middleware, otherwise the body is compressed twice +// and the downloaded archive cannot be extracted. +var precompressedPaths = []string{"/api/v1/admin/crowdsec/export"} + +// compressionMiddleware returns the router-wide gzip middleware, skipping precompressed downloads. +func compressionMiddleware() gin.HandlerFunc { + return gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths(precompressedPaths)) +} From 8764187a0ca5dd58a4b94e79a0544dc77820df2d Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Fri, 9 Oct 2026 23:09:24 +0000 Subject: [PATCH 15/22] fix: add the missing translation keys on the CrowdSec config page Add the missing CrowdSec config and whitelist keys to all five locales, use real keys for aria-labels instead of a dead fallback, give the dashboard time-range group a string label, and only mask token-like strings in console enrollment errors. A test now checks that every static key resolves and that locales stay in parity. --- .../crowdsec/DashboardTimeRangeSelector.tsx | 2 +- frontend/src/locales/de/translation.json | 65 +++++++++++++++++-- frontend/src/locales/en/translation.json | 44 +++++++++++-- frontend/src/locales/es/translation.json | 65 +++++++++++++++++-- frontend/src/locales/fr/translation.json | 65 +++++++++++++++++-- frontend/src/locales/zh/translation.json | 65 +++++++++++++++++-- frontend/src/pages/CrowdSecConfig.tsx | 11 ++-- .../__tests__/CrowdSecConfig.i18nKeys.test.ts | 56 ++++++++++++++++ .../CrowdSecConfig.whitelist.test.tsx | 6 +- .../utils/__tests__/sanitizeSecret.test.ts | 27 ++++++++ frontend/src/utils/sanitizeSecret.ts | 18 +++++ 11 files changed, 381 insertions(+), 43 deletions(-) create mode 100644 frontend/src/pages/__tests__/CrowdSecConfig.i18nKeys.test.ts create mode 100644 frontend/src/utils/__tests__/sanitizeSecret.test.ts create mode 100644 frontend/src/utils/sanitizeSecret.ts diff --git a/frontend/src/components/crowdsec/DashboardTimeRangeSelector.tsx b/frontend/src/components/crowdsec/DashboardTimeRangeSelector.tsx index 80897a3bf..0c0511fd3 100644 --- a/frontend/src/components/crowdsec/DashboardTimeRangeSelector.tsx +++ b/frontend/src/components/crowdsec/DashboardTimeRangeSelector.tsx @@ -57,7 +57,7 @@ export function DashboardTimeRangeSelector({ value, onChange }: DashboardTimeRan return (
{RANGES.map((range, i) => ( diff --git a/frontend/src/locales/de/translation.json b/frontend/src/locales/de/translation.json index 70b599b6c..9397b59da 100644 --- a/frontend/src/locales/de/translation.json +++ b/frontend/src/locales/de/translation.json @@ -384,8 +384,17 @@ "24h": "24h", "7d": "7d", "30d": "30d" - } - } + }, + "timeRangeLabel": "Zeitraum" + }, + "bouncerApiKey": "Bouncer-API-Schlüssel", + "keyCopied": "API-Schlüssel in die Zwischenablage kopiert", + "keyStoredAt": "Schlüssel gespeichert unter", + "noKeyConfigured": "Kein Bouncer-Schlüssel konfiguriert. Aktivieren Sie CrowdSec für die automatische Registrierung.", + "notRegistered": "Nicht registriert", + "registered": "Registriert", + "sourceEnvVar": "Aus Umgebungsvariable", + "sourceFile": "Aus Datei" }, "acl": { "title": "Zugriffskontrolle", @@ -1186,7 +1195,15 @@ "tenantLabel": "Mandant", "lastAttempt": "Letzter Versuch", "enrolledAt": "Registriert", - "correlationId": "Korrelations-ID" + "correlationId": "Korrelations-ID", + "lastError": "Letzter Fehler", + "ackText": "Mir ist bekannt, dass meine LAPI-Zugangsdaten rotiert werden, falls bereits registriert", + "description": "Verbinden Sie Ihre CrowdSec-LAPI mit der CrowdSec Console für zentrale Verwaltung und Warnungen.", + "enrollToken": "Registrierungstoken", + "lastHeartbeat": "Letzter Heartbeat", + "privacyNote": "Hinweis: Die Registrierung sendet Heartbeat-Daten und Metriken an CrowdSec. Details finden Sie in der Datenschutzrichtlinie von CrowdSec.", + "tenantHelper": "Optional: Mandanten-/Organisations-ID angeben, falls Sie ein Multi-Tenant-Setup verwenden", + "tokenHelper": "Holen Sie sich Ihr Registrierungstoken aus der CrowdSec Console" }, "reenroll": { "title": "Neu-Registrierungsoptionen", @@ -1209,7 +1226,8 @@ "description": "Exportieren oder importieren Sie CrowdSec-Konfigurationspakete (tar.gz/zip). Vor dem Import wird ein Backup erstellt.", "export": "Exportieren", "import": "Importieren", - "importing": "Importiere..." + "importing": "Importiere...", + "selectFile": "CrowdSec-Paketdatei auswählen" }, "presets": { "title": "Vorlagen", @@ -1243,12 +1261,14 @@ "filesystem": "Dateisystem", "loadCached": "Zwischengespeicherte Vorschau laden", "applyDisabled": "Anwenden deaktiviert, solange Hub offline ist.", - "noPresets": "Keine Vorlagen verfügbar. Stellen Sie sicher, dass Cerberus aktiviert ist." + "noPresets": "Keine Vorlagen verfügbar. Stellen Sie sicher, dass Cerberus aktiviert ist.", + "sortBy": "Presets sortieren" }, "files": { "title": "Konfigurationsdateien bearbeiten", "selectFile": "Datei auswählen...", - "refresh": "Aktualisieren" + "refresh": "Aktualisieren", + "content": "Dateiinhalt" }, "bannedIps": { "title": "Gesperrte IPs", @@ -1283,7 +1303,38 @@ "title": "Entsperrung bestätigen", "confirm": "Sind Sie sicher, dass Sie entsperren möchten", "submit": "Entsperren" - } + }, + "lapiInitializing": "CrowdSec läuft, aber die lokale API wird noch initialisiert.", + "lapiInitializingDesc": "Die Console-Registrierung ist verfügbar, sobald die lokale API bereit ist. Das kann nach dem Start von CrowdSec bis zu einer Minute dauern.", + "checkingAgain": "(wird erneut geprüft...)", + "notRunning": "CrowdSec läuft nicht", + "notRunningDesc": "Starten Sie CrowdSec, bevor Sie sich in der Console registrieren.", + "starting": "CrowdSec wird gestartet...", + "startFailed": "CrowdSec konnte nicht gestartet werden", + "startCrowdsec": "CrowdSec starten", + "goToSecurity": "Zu Sicherheit wechseln", + "whitelist": { + "tabLabel": "Whitelist", + "title": "IP-Whitelist", + "description": "IPs und CIDRs auf der Whitelist werden von CrowdSec nie blockiert, auch wenn sie Warnungen auslösen.", + "ipLabel": "IP oder CIDR", + "reasonLabel": "Grund", + "reasonPlaceholder": "Optionaler Grund", + "addMyIp": "Meine IP hinzufügen", + "none": "Keine Whitelist-Einträge", + "columnIp": "IP / CIDR", + "columnReason": "Grund", + "columnAdded": "Hinzugefügt", + "deleteAriaLabel": "Whitelist-Eintrag entfernen für", + "delete": "Löschen", + "deleteModal": { + "title": "Whitelist-Eintrag entfernen", + "body": "{{ip}} von der Whitelist entfernen? CrowdSec kann diese IP dann blockieren, wenn sie Warnungen auslöst.", + "submit": "Entfernen" + } + }, + "noConfig": "CrowdSec-Konfiguration im Sicherheitsstatus nicht gefunden", + "noteText": "CrowdSec wird über den Schalter auf der Seite gesteuert:" }, "smtp": { "title": "E-Mail (SMTP) Einstellungen", diff --git a/frontend/src/locales/en/translation.json b/frontend/src/locales/en/translation.json index c691b0d35..972457bca 100644 --- a/frontend/src/locales/en/translation.json +++ b/frontend/src/locales/en/translation.json @@ -488,7 +488,8 @@ "24h": "24h", "7d": "7d", "30d": "30d" - } + }, + "timeRangeLabel": "Time range" } }, "acl": { @@ -1524,7 +1525,8 @@ "tenantLabel": "Tenant", "lastAttempt": "Last attempt", "enrolledAt": "Enrolled", - "correlationId": "Correlation ID" + "correlationId": "Correlation ID", + "lastError": "Last error" }, "reenroll": { "title": "Re-enrollment Options", @@ -1547,7 +1549,8 @@ "description": "Export or import CrowdSec configuration packages (tar.gz/zip). A backup is created before importing.", "export": "Export", "import": "Import", - "importing": "Importing..." + "importing": "Importing...", + "selectFile": "Select CrowdSec package file" }, "presets": { "title": "Presets", @@ -1581,12 +1584,14 @@ "filesystem": "Filesystem", "loadCached": "Load cached preview", "applyDisabled": "Apply disabled while hub is offline.", - "noPresets": "No presets available. Ensure Cerberus is enabled." + "noPresets": "No presets available. Ensure Cerberus is enabled.", + "sortBy": "Sort presets" }, "files": { "title": "Edit Configuration Files", "selectFile": "Select a file...", - "refresh": "Refresh" + "refresh": "Refresh", + "content": "File content" }, "bannedIps": { "title": "Banned IPs", @@ -1621,6 +1626,35 @@ "title": "Confirm Unban", "confirm": "Are you sure you want to unban", "submit": "Unban" + }, + "lapiInitializing": "CrowdSec is running, but the Local API is still initializing.", + "lapiInitializingDesc": "Console enrollment will be available once the Local API is ready. This can take up to a minute after CrowdSec starts.", + "checkingAgain": "(checking again...)", + "notRunning": "CrowdSec is not running", + "notRunningDesc": "Start CrowdSec before enrolling in the Console.", + "starting": "Starting CrowdSec...", + "startFailed": "Failed to start CrowdSec", + "startCrowdsec": "Start CrowdSec", + "goToSecurity": "Go to Security", + "whitelist": { + "tabLabel": "Whitelist", + "title": "IP Whitelist", + "description": "Whitelisted IPs and CIDRs are never blocked by CrowdSec, even if they trigger alerts.", + "ipLabel": "IP or CIDR", + "reasonLabel": "Reason", + "reasonPlaceholder": "Optional reason", + "addMyIp": "Add My IP", + "none": "No whitelist entries", + "columnIp": "IP / CIDR", + "columnReason": "Reason", + "columnAdded": "Added", + "deleteAriaLabel": "Remove whitelist entry for", + "delete": "Delete", + "deleteModal": { + "title": "Remove Whitelist Entry", + "body": "Remove {{ip}} from the whitelist? CrowdSec may then block this IP if it triggers alerts.", + "submit": "Remove" + } } }, "smtp": { diff --git a/frontend/src/locales/es/translation.json b/frontend/src/locales/es/translation.json index 304474589..dee2659b9 100644 --- a/frontend/src/locales/es/translation.json +++ b/frontend/src/locales/es/translation.json @@ -384,8 +384,17 @@ "24h": "24h", "7d": "7d", "30d": "30d" - } - } + }, + "timeRangeLabel": "Rango de tiempo" + }, + "bouncerApiKey": "Clave API del bouncer", + "keyCopied": "Clave API copiada al portapapeles", + "keyStoredAt": "Clave almacenada en", + "noKeyConfigured": "No hay clave de bouncer configurada. Active CrowdSec para el registro automático.", + "notRegistered": "No registrado", + "registered": "Registrado", + "sourceEnvVar": "Desde variable de entorno", + "sourceFile": "Desde archivo" }, "acl": { "title": "Control de Acceso", @@ -1188,7 +1197,15 @@ "tenantLabel": "Inquilino", "lastAttempt": "Último intento", "enrolledAt": "Inscrito", - "correlationId": "ID de correlación" + "correlationId": "ID de correlación", + "lastError": "Último error", + "ackText": "Entiendo que esto rotará mis credenciales de LAPI si ya estoy registrado", + "description": "Conecte su LAPI de CrowdSec a la consola de CrowdSec para gestión centralizada y alertas.", + "enrollToken": "Token de registro", + "lastHeartbeat": "Último latido", + "privacyNote": "Nota: El registro envía datos de latido y métricas a CrowdSec. Consulte la política de privacidad de CrowdSec para más detalles.", + "tenantHelper": "Opcional: indique el ID de inquilino/organización si usa una configuración multiinquilino", + "tokenHelper": "Obtenga su token de registro en la consola de CrowdSec" }, "reenroll": { "title": "Opciones de reinscripción", @@ -1211,7 +1228,8 @@ "description": "Exporte o importe paquetes de configuración de CrowdSec (tar.gz/zip). Se crea una copia de seguridad antes de importar.", "export": "Exportar", "import": "Importar", - "importing": "Importando..." + "importing": "Importando...", + "selectFile": "Seleccionar archivo de paquete de CrowdSec" }, "presets": { "title": "Plantillas", @@ -1245,12 +1263,14 @@ "filesystem": "Sistema de archivos", "loadCached": "Cargar vista previa en caché", "applyDisabled": "Aplicar deshabilitado mientras el hub está fuera de línea.", - "noPresets": "No hay plantillas disponibles. Asegúrese de que Cerberus esté habilitado." + "noPresets": "No hay plantillas disponibles. Asegúrese de que Cerberus esté habilitado.", + "sortBy": "Ordenar presets" }, "files": { "title": "Editar archivos de configuración", "selectFile": "Seleccionar un archivo...", - "refresh": "Actualizar" + "refresh": "Actualizar", + "content": "Contenido del archivo" }, "bannedIps": { "title": "IPs bloqueadas", @@ -1285,7 +1305,38 @@ "title": "Confirmar desbloqueo", "confirm": "¿Está seguro de que desea desbloquear", "submit": "Desbloquear" - } + }, + "lapiInitializing": "CrowdSec se está ejecutando, pero la API local aún se está inicializando.", + "lapiInitializingDesc": "El registro en la consola estará disponible cuando la API local esté lista. Puede tardar hasta un minuto después de iniciar CrowdSec.", + "checkingAgain": "(comprobando de nuevo...)", + "notRunning": "CrowdSec no se está ejecutando", + "notRunningDesc": "Inicie CrowdSec antes de registrarse en la consola.", + "starting": "Iniciando CrowdSec...", + "startFailed": "No se pudo iniciar CrowdSec", + "startCrowdsec": "Iniciar CrowdSec", + "goToSecurity": "Ir a Seguridad", + "whitelist": { + "tabLabel": "Lista blanca", + "title": "Lista blanca de IP", + "description": "Las IP y CIDR de la lista blanca nunca son bloqueadas por CrowdSec, aunque generen alertas.", + "ipLabel": "IP o CIDR", + "reasonLabel": "Motivo", + "reasonPlaceholder": "Motivo opcional", + "addMyIp": "Añadir mi IP", + "none": "No hay entradas en la lista blanca", + "columnIp": "IP / CIDR", + "columnReason": "Motivo", + "columnAdded": "Añadida", + "deleteAriaLabel": "Eliminar entrada de la lista blanca de", + "delete": "Eliminar", + "deleteModal": { + "title": "Eliminar entrada de la lista blanca", + "body": "¿Eliminar {{ip}} de la lista blanca? CrowdSec podrá bloquear esta IP si genera alertas.", + "submit": "Eliminar" + } + }, + "noConfig": "No se encontró la configuración de CrowdSec en el estado de seguridad", + "noteText": "CrowdSec se controla mediante el interruptor de la página" }, "smtp": { "title": "Configuración de Correo (SMTP)", diff --git a/frontend/src/locales/fr/translation.json b/frontend/src/locales/fr/translation.json index 1d09953a4..fc87188b1 100644 --- a/frontend/src/locales/fr/translation.json +++ b/frontend/src/locales/fr/translation.json @@ -384,8 +384,17 @@ "24h": "24h", "7d": "7d", "30d": "30d" - } - } + }, + "timeRangeLabel": "Plage de temps" + }, + "bouncerApiKey": "Clé API du bouncer", + "keyCopied": "Clé API copiée dans le presse-papiers", + "keyStoredAt": "Clé stockée dans", + "noKeyConfigured": "Aucune clé de bouncer configurée. Activez CrowdSec pour l'enregistrement automatique.", + "notRegistered": "Non enregistré", + "registered": "Enregistré", + "sourceEnvVar": "Depuis une variable d'environnement", + "sourceFile": "Depuis un fichier" }, "acl": { "title": "Contrôle d'Accès", @@ -1188,7 +1197,15 @@ "tenantLabel": "Locataire", "lastAttempt": "Dernière tentative", "enrolledAt": "Inscrit", - "correlationId": "ID de corrélation" + "correlationId": "ID de corrélation", + "lastError": "Dernière erreur", + "ackText": "Je comprends que cela renouvellera mes identifiants LAPI si je suis déjà inscrit", + "description": "Connectez votre LAPI CrowdSec à la console CrowdSec pour une gestion centralisée et des alertes.", + "enrollToken": "Jeton d'inscription", + "lastHeartbeat": "Dernier signal de vie", + "privacyNote": "Remarque : l'inscription envoie des signaux de vie et des métriques à CrowdSec. Consultez la politique de confidentialité de CrowdSec pour plus de détails.", + "tenantHelper": "Facultatif : indiquez l'ID du locataire/de l'organisation si vous utilisez une configuration multi-locataire", + "tokenHelper": "Obtenez votre jeton d'inscription depuis la console CrowdSec" }, "reenroll": { "title": "Options de réinscription", @@ -1211,7 +1228,8 @@ "description": "Exportez ou importez des paquets de configuration CrowdSec (tar.gz/zip). Une sauvegarde est créée avant l'importation.", "export": "Exporter", "import": "Importer", - "importing": "Importation..." + "importing": "Importation...", + "selectFile": "Sélectionner le fichier de paquet CrowdSec" }, "presets": { "title": "Préréglages", @@ -1245,12 +1263,14 @@ "filesystem": "Système de fichiers", "loadCached": "Charger l'aperçu en cache", "applyDisabled": "Application désactivée tant que le hub est hors ligne.", - "noPresets": "Aucun préréglage disponible. Assurez-vous que Cerberus est activé." + "noPresets": "Aucun préréglage disponible. Assurez-vous que Cerberus est activé.", + "sortBy": "Trier les préréglages" }, "files": { "title": "Modifier les fichiers de configuration", "selectFile": "Sélectionner un fichier...", - "refresh": "Actualiser" + "refresh": "Actualiser", + "content": "Contenu du fichier" }, "bannedIps": { "title": "IPs bannies", @@ -1285,7 +1305,38 @@ "title": "Confirmer le débannissement", "confirm": "Êtes-vous sûr de vouloir débannir", "submit": "Débannir" - } + }, + "lapiInitializing": "CrowdSec est en cours d'exécution, mais l'API locale est encore en cours d'initialisation.", + "lapiInitializingDesc": "L'inscription à la console sera disponible dès que l'API locale sera prête. Cela peut prendre jusqu'à une minute après le démarrage de CrowdSec.", + "checkingAgain": "(nouvelle vérification...)", + "notRunning": "CrowdSec n'est pas en cours d'exécution", + "notRunningDesc": "Démarrez CrowdSec avant de vous inscrire à la console.", + "starting": "Démarrage de CrowdSec...", + "startFailed": "Échec du démarrage de CrowdSec", + "startCrowdsec": "Démarrer CrowdSec", + "goToSecurity": "Aller à Sécurité", + "whitelist": { + "tabLabel": "Liste blanche", + "title": "Liste blanche d'IP", + "description": "Les IP et CIDR de la liste blanche ne sont jamais bloqués par CrowdSec, même s'ils déclenchent des alertes.", + "ipLabel": "IP ou CIDR", + "reasonLabel": "Raison", + "reasonPlaceholder": "Raison facultative", + "addMyIp": "Ajouter mon IP", + "none": "Aucune entrée dans la liste blanche", + "columnIp": "IP / CIDR", + "columnReason": "Raison", + "columnAdded": "Ajoutée", + "deleteAriaLabel": "Supprimer l'entrée de la liste blanche pour", + "delete": "Supprimer", + "deleteModal": { + "title": "Supprimer l'entrée de la liste blanche", + "body": "Supprimer {{ip}} de la liste blanche ? CrowdSec pourra alors bloquer cette IP si elle déclenche des alertes.", + "submit": "Supprimer" + } + }, + "noConfig": "Configuration de CrowdSec introuvable dans l'état de sécurité", + "noteText": "CrowdSec est contrôlé via l'interrupteur de la page" }, "smtp": { "title": "Paramètres Email (SMTP)", diff --git a/frontend/src/locales/zh/translation.json b/frontend/src/locales/zh/translation.json index 66bc32305..35a42e0fd 100644 --- a/frontend/src/locales/zh/translation.json +++ b/frontend/src/locales/zh/translation.json @@ -384,8 +384,17 @@ "24h": "24小时", "7d": "7天", "30d": "30天" - } - } + }, + "timeRangeLabel": "时间范围" + }, + "bouncerApiKey": "Bouncer API 密钥", + "keyCopied": "API 密钥已复制到剪贴板", + "keyStoredAt": "密钥存储位置", + "noKeyConfigured": "未配置 Bouncer 密钥。启用 CrowdSec 以自动注册。", + "notRegistered": "未注册", + "registered": "已注册", + "sourceEnvVar": "来自环境变量", + "sourceFile": "来自文件" }, "acl": { "title": "访问控制", @@ -1187,7 +1196,15 @@ "tenantLabel": "租户", "lastAttempt": "最后尝试", "enrolledAt": "注册于", - "correlationId": "关联ID" + "correlationId": "关联ID", + "lastError": "上次错误", + "ackText": "我了解如果已注册,这将轮换我的 LAPI 凭据", + "description": "将您的 CrowdSec LAPI 连接到 CrowdSec 控制台,以进行集中管理和接收警报。", + "enrollToken": "注册令牌", + "lastHeartbeat": "上次心跳", + "privacyNote": "注意:注册会向 CrowdSec 发送心跳数据和指标。详情请参阅 CrowdSec 的隐私政策。", + "tenantHelper": "可选:如果使用多租户设置,请指定租户/组织 ID", + "tokenHelper": "从 CrowdSec 控制台获取您的注册令牌" }, "reenroll": { "title": "重新注册选项", @@ -1210,7 +1227,8 @@ "description": "导出或导入 CrowdSec 配置包(tar.gz/zip)。导入前会创建备份。", "export": "导出", "import": "导入", - "importing": "正在导入..." + "importing": "正在导入...", + "selectFile": "选择 CrowdSec 软件包文件" }, "presets": { "title": "预设", @@ -1244,12 +1262,14 @@ "filesystem": "文件系统", "loadCached": "加载缓存的预览", "applyDisabled": "Hub 离线时禁用应用。", - "noPresets": "没有可用的预设。请确保 Cerberus 已启用。" + "noPresets": "没有可用的预设。请确保 Cerberus 已启用。", + "sortBy": "排序预设" }, "files": { "title": "编辑配置文件", "selectFile": "选择文件...", - "refresh": "刷新" + "refresh": "刷新", + "content": "文件内容" }, "bannedIps": { "title": "已封禁 IP", @@ -1284,7 +1304,38 @@ "title": "确认解封", "confirm": "您确定要解封", "submit": "解封" - } + }, + "lapiInitializing": "CrowdSec 正在运行,但本地 API 仍在初始化。", + "lapiInitializingDesc": "本地 API 就绪后即可进行控制台注册。CrowdSec 启动后可能需要最多一分钟。", + "checkingAgain": "(正在重新检查...)", + "notRunning": "CrowdSec 未运行", + "notRunningDesc": "请先启动 CrowdSec,再注册到控制台。", + "starting": "正在启动 CrowdSec...", + "startFailed": "启动 CrowdSec 失败", + "startCrowdsec": "启动 CrowdSec", + "goToSecurity": "前往安全页面", + "whitelist": { + "tabLabel": "白名单", + "title": "IP 白名单", + "description": "白名单中的 IP 和 CIDR 即使触发警报也不会被 CrowdSec 拦截。", + "ipLabel": "IP 或 CIDR", + "reasonLabel": "原因", + "reasonPlaceholder": "可选原因", + "addMyIp": "添加我的 IP", + "none": "没有白名单条目", + "columnIp": "IP / CIDR", + "columnReason": "原因", + "columnAdded": "添加时间", + "deleteAriaLabel": "移除白名单条目:", + "delete": "删除", + "deleteModal": { + "title": "移除白名单条目", + "body": "要将 {{ip}} 从白名单中移除吗?如果该 IP 触发警报,CrowdSec 之后可能会拦截它。", + "submit": "移除" + } + }, + "noConfig": "在安全状态中未找到 CrowdSec 配置", + "noteText": "CrowdSec 由以下页面上的开关控制:" }, "smtp": { "title": "电子邮件 (SMTP) 设置", diff --git a/frontend/src/pages/CrowdSecConfig.tsx b/frontend/src/pages/CrowdSecConfig.tsx index 0dd0d30f0..24b1652a3 100644 --- a/frontend/src/pages/CrowdSecConfig.tsx +++ b/frontend/src/pages/CrowdSecConfig.tsx @@ -22,6 +22,7 @@ import { CROWDSEC_PRESETS, type CrowdsecPreset } from '../data/crowdsecPresets' import { useConsoleStatus, useEnrollConsole, useClearConsoleEnrollment } from '../hooks/useConsoleEnrollment' import { useWhitelistEntries, useAddWhitelist, useDeleteWhitelist } from '../hooks/useCrowdSecWhitelist' import { buildCrowdsecExportFilename, downloadCrowdsecExport, promptCrowdsecFilename } from '../utils/crowdsecExport' +import { sanitizeSecret } from '../utils/sanitizeSecret' import { toast } from '../utils/toast' /** Prefer the server's `error` message for Axios failures, falling back to the given text. */ @@ -297,8 +298,6 @@ export default function CrowdSecConfig() { const canRotateKey = normalizedConsoleStatus === 'enrolled' || normalizedConsoleStatus === 'degraded' || isConsolePendingAcceptance const consoleDocsHref = 'https://wikid82.github.io/charon/security/' - const sanitizeSecret = (msg: string) => msg.replace(/\b[A-Za-z0-9]{10,64}\b/g, '***') - const sanitizeErrorMessage = (err: unknown) => { if (isAxiosError(err)) { return sanitizeSecret(err.response?.data?.error || err.message) @@ -968,7 +967,7 @@ export default function CrowdSecConfig() { onChange={(e) => setFile(e.target.files?.[0] ?? null)} data-testid="import-file" accept=".tar.gz,.zip" - aria-label={t('crowdsecConfig.packages.selectFile') || "Select CrowdSec package"} + aria-label={t('crowdsecConfig.packages.selectFile')} />
@@ -994,7 +993,7 @@ export default function CrowdSecConfig() {