diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index 8c76ff60..aaa94520 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -1,897 +1,716 @@ -# CrowdSec IP Whitelist Management — Implementation Plan +# Specification: Integrate @axe-core/playwright for Automated Accessibility Testing -**Issue**: [#939 — CrowdSec IP Whitelist Management](https://github.com/owner/Charon/issues/939) -**Date**: 2026-05-20 -**Status**: Draft — Awaiting Approval -**Priority**: High -**Archived Previous Plan**: Coverage Improvement Plan (patch coverage ≥ 90%) → `docs/plans/archive/patch-coverage-improvement-plan-2026-05-02.md` +**Issue**: #929 +**Status**: Draft +**Created**: 2026-04-20 --- ## 1. Introduction -### 1.1 Overview +### Overview -CrowdSec enforces IP ban decisions by default. Operators need a way to permanently exempt known-good IPs (uptime monitors, internal subnets, VPN exits, partners) from ever being banned. CrowdSec handles this through its `whitelists` parser, which intercepts alert evaluation and suppresses bans for matching IPs/CIDRs before decisions are even written. +Integrate `@axe-core/playwright` into the existing Playwright E2E test suite to provide automated WCAG 2.2 Level AA accessibility scanning across all key application pages. The scans will run as part of CI, failing on critical/serious violations. -This feature gives Charon operators a first-class UI for managing those whitelist entries: add an IP or CIDR, give it a reason, and have Charon persist it in the database, render the required YAML parser file into the CrowdSec config tree, and signal CrowdSec to reload—all without manual file editing. +### Objectives -### 1.2 Objectives - -- Allow operators to add, view, and remove CrowdSec whitelist entries (IPs and CIDRs) through the Charon management UI. -- Persist entries in SQLite so they survive container restarts. -- Generate a `crowdsecurity/whitelists`-compatible YAML parser file on every mutating operation and on startup. -- Automatically install the `crowdsecurity/whitelists` hub parser so CrowdSec can process the file. -- Show the Whitelist tab only when CrowdSec is in `local` mode, consistent with other CrowdSec-only tabs. +1. Install and configure `@axe-core/playwright` as a dev dependency +2. Create a shared accessibility fixture and helper module +3. Add dedicated a11y spec files covering all primary application pages +4. Configure axe rules targeting WCAG 2.2 Level AA conformance +5. Fail CI on critical/serious violations while allowing a baseline for known issues +6. Surface results in Playwright HTML reports across all three browser projects --- ## 2. Research Findings -### 2.1 Existing CrowdSec Architecture +### 2.1 Existing Playwright Configuration -| Component | Location | Notes | -|---|---|---| -| Hub parser installer | `configs/crowdsec/install_hub_items.sh` | Run at container start; uses `cscli parsers install --force` | -| CrowdSec handler | `backend/internal/api/handlers/crowdsec_handler.go` | ~2750 LOC; `RegisterRoutes` at L2704 | -| Route registration | `backend/internal/api/routes/routes.go` | `crowdsecHandler.RegisterRoutes(management)` at ~L620 | -| CrowdSec startup | `backend/internal/services/crowdsec_startup.go` | `ReconcileCrowdSecOnStartup()` runs before process start | -| Security config | `backend/internal/models/security_config.go` | `CrowdSecMode`, `CrowdSecConfigDir` (via `cfg.Security.CrowdSecConfigDir`) | -| IP/CIDR helper | `backend/internal/security/whitelist.go` | `IsIPInCIDRList()` using `net.ParseIP` / `net.ParseCIDR` | -| AutoMigrate | `routes.go` ~L95–125 | `&models.ManualChallenge{}` is currently the last entry | +**File**: [playwright.config.js](../../playwright.config.js) -### 2.2 Gap Analysis +| Setting | Value | +|---------|-------| +| `testDir` | `./tests` | +| `timeout` | 60s (CI) / 90s (local) | +| `workers` | 1 (CI) / auto (local) | +| `retries` | 2 (CI) / 0 (local) | +| `fullyParallel` | `true` | +| `reporter` | `github` (CI) + `html` + optional coverage | +| `baseURL` | `http://127.0.0.1:8080` (Docker) or `http://localhost:5173` (coverage/Vite) | -- `crowdsecurity/whitelists` hub parser is **not** installed by `install_hub_items.sh` — the YAML file would be ignored by CrowdSec without it. -- No `CrowdSecWhitelist` model exists in `backend/internal/models/`. -- No whitelist service, handler methods, or API routes exist. -- No frontend tab, API client functions, or TanStack Query hooks exist. -- No E2E test spec covers whitelist management. +**Projects** (6 defined): -### 2.3 Relevant Patterns +| Project | Role | Dependencies | +|---------|------|-------------| +| `setup` | Authentication (auth.setup.ts) | None | +| `security-shard-setup` | Security shard init | `setup` | +| `security-tests` | Security enforcement (Chromium-only, serial) | `setup`, `security-shard-setup` | +| `security-teardown` | Disable security modules | Conditionally active | +| `chromium` | Non-security tests | `setup` (+ `security-tests` when enabled) | +| `firefox` | Non-security tests | `setup` (+ `security-tests` when enabled) | +| `webkit` | Non-security tests | `setup` (+ `security-tests` when enabled) | -**Model pattern** (from `access_list.go` + `security_config.go`): -```go -type Model struct { - ID uint `json:"-" gorm:"primaryKey"` - UUID string `json:"uuid" gorm:"uniqueIndex;not null"` - // domain fields - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} +**Key patterns**: + +- Auth state stored at `playwright/.auth/user.json` via `STORAGE_STATE` +- Coverage via `@bgotink/playwright-coverage` behind `PLAYWRIGHT_COVERAGE=1` +- Global setup in `tests/global-setup.ts` (health check, cleanup) +- All browser projects share `testMatch: /.*\.spec\.(ts|js)$/` with `testIgnore` for security dirs + +### 2.2 Existing E2E Test Files (93 spec files) + +**Core tests** (`tests/core/`): + +| File | Description | +|------|-------------| +| `dashboard.spec.ts` | Dashboard loading, summary cards, quick actions | +| `proxy-hosts.spec.ts` | Proxy host CRUD operations | +| `navigation.spec.ts` | Menu items, sidebar, breadcrumbs, keyboard nav | +| `certificates.spec.ts` | Certificate management | +| `multi-component-workflows.spec.ts` | Cross-feature workflows | +| `data-consistency.spec.ts` | Data integrity checks | +| `authentication.spec.ts` | Login/logout, session management | +| `caddy-import/*.spec.ts` | Caddy config import (5 files) | +| `admin-onboarding.spec.ts` | First-run setup flow | +| `domain-dns-management.spec.ts` | Domain and DNS management | + +**Settings tests** (`tests/settings/`): 10 spec files covering account settings, SMTP, notifications (Pushover, Ntfy, Telegram, Email, Slack), user lifecycle, user management. + +**Security tests** (`tests/security/`, `tests/security-enforcement/`): 28+ spec files covering CrowdSec, WAF, ACL, rate limiting, audit logs, encryption, RBAC, emergency operations. + +**Monitoring tests** (`tests/monitoring/`): `uptime-monitoring.spec.ts`, `create-monitor.spec.ts`. + +**Integration tests** (`tests/integration/`): 6 spec files covering import flows, proxy-DNS integration, proxy-certificates, backups. + +**Task tests** (`tests/tasks/`): Backups create/restore, Caddyfile import, logs viewing, long-running operations. + +**Other root-level tests**: `dns-provider-crud.spec.ts`, `dns-provider-types.spec.ts`, `manual-dns-provider.spec.ts`, `certificate-*.spec.ts`, `crowdsec-whitelist.spec.ts`, `modal-dropdown-triage.spec.ts`. + +### 2.3 Test Fixtures and Helpers + +| File | Purpose | +|------|---------| +| `tests/fixtures/test.ts` | Base test/expect re-export with conditional coverage instrumentation | +| `tests/fixtures/auth-fixtures.ts` | Extended fixtures: `adminUser`, `regularUser`, `guestUser`, `testData` (TestDataManager) | +| `tests/fixtures/certificates.ts` | Certificate-specific fixtures | +| `tests/fixtures/proxy-hosts.ts` | Proxy host fixtures | +| `tests/fixtures/security.ts` | Security test fixtures | +| `tests/fixtures/settings.ts` | Settings fixtures | +| `tests/fixtures/network.ts` | Network fixtures | +| `tests/fixtures/notifications.ts` | Notification test fixtures | +| `tests/fixtures/encryption.ts` | Encryption fixtures | +| `tests/fixtures/access-lists.ts` | ACL fixtures | +| `tests/fixtures/dns-providers.ts` | DNS provider fixtures | +| `tests/fixtures/test-data.ts` | Shared test data | +| `tests/utils/wait-helpers.ts` | `waitForLoadingComplete`, `waitForTableLoad` | +| `tests/utils/ui-helpers.ts` | UI interaction helpers | +| `tests/utils/api-helpers.ts` | API request helpers | +| `tests/utils/TestDataManager.ts` | Test data lifecycle management | +| `tests/constants.ts` | Shared constants (`STORAGE_STATE`) | + +**Import pattern**: Most tests import from `../fixtures/auth-fixtures` which re-exports `test`/`expect` from `./test.ts` (coverage-aware). + +### 2.4 Frontend Routes (All Navigable Pages) + +Extracted from [frontend/src/App.tsx](../../frontend/src/App.tsx): + +| Route | Page Component | Auth Required | Role | +|-------|---------------|---------------|------| +| `/login` | Login | No | — | +| `/setup` | Setup | No | — | +| `/accept-invite` | AcceptInvite | No | — | +| `/passthrough` | PassthroughLanding | Yes | Any | +| `/` | Dashboard | Yes | Any | +| `/proxy-hosts` | ProxyHosts | Yes | Any | +| `/remote-servers` | RemoteServers | Yes | Any | +| `/domains` | Domains | Yes | Any | +| `/certificates` | Certificates | Yes | Any | +| `/dns/providers` | DNSProviders | Yes | Any | +| `/dns/plugins` | Plugins | Yes | Any | +| `/security` | Security | Yes | Any | +| `/security/audit-logs` | AuditLogs | Yes | Any | +| `/security/access-lists` | AccessLists | Yes | Any | +| `/security/crowdsec` | CrowdSecConfig | Yes | Any | +| `/security/rate-limiting` | RateLimiting | Yes | Any | +| `/security/waf` | WafConfig | Yes | Any | +| `/security/headers` | SecurityHeaders | Yes | Any | +| `/security/encryption` | EncryptionManagement | Yes | Any | +| `/access-lists` | AccessLists | Yes | Any | +| `/uptime` | Uptime | Yes | Any | +| `/settings` | Settings > SystemSettings | Yes | admin/user | +| `/settings/system` | SystemSettings | Yes | admin/user | +| `/settings/notifications` | Notifications | Yes | admin/user | +| `/settings/smtp` | SMTPSettings | Yes | admin/user | +| `/settings/users` | UsersPage | Yes | admin | +| `/tasks` | Tasks > Backups | Yes | Any | +| `/tasks/backups` | Backups | Yes | Any | +| `/tasks/logs` | Logs | Yes | Any | +| `/tasks/import/caddyfile` | ImportCaddy | Yes | Any | +| `/tasks/import/crowdsec` | ImportCrowdSec | Yes | Any | +| `/tasks/import/npm` | ImportNPM | Yes | Any | +| `/tasks/import/json` | ImportJSON | Yes | Any | + +**Total unique pages to scan**: ~30 authenticated + 3 unauthenticated = **~33 pages**. + +### 2.5 CI Workflows + +**Primary workflow**: `.github/workflows/e2e-tests-split.yml` + +Architecture (15 total jobs): + +- **Build**: Single job builds Docker image, uploads as artifact +- **3 Security Enforcement jobs** (1 per browser, serial, 60min timeout) — runs `tests/security-enforcement/`, `tests/security/`, `tests/integration/multi-feature-workflows.spec.ts` +- **12 Non-Security jobs** (4 shards x 3 browsers, parallel, 60min timeout) — runs `tests/core`, `tests/dns-provider-*.spec.ts`, `tests/integration`, `tests/manual-dns-provider.spec.ts`, `tests/monitoring`, `tests/settings`, `tests/tasks` + +Triggered by: `workflow_call`, `workflow_dispatch`, `pull_request`. + +Non-security test directories explicitly listed in each browser job's Playwright invocation: + +``` +tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts +tests/integration tests/manual-dns-provider.spec.ts tests/monitoring +tests/settings tests/tasks ``` -**Service pattern** (from `access_list_service.go`): -```go -var ErrXxxNotFound = errors.New("xxx not found") +### 2.6 Package Configuration -type XxxService struct { db *gorm.DB } +**Current devDependencies** (from `package.json`): -func NewXxxService(db *gorm.DB) *XxxService { return &XxxService{db: db} } -``` +- `@playwright/test`: `^1.59.1` +- `@bgotink/playwright-coverage`: `^0.3.2` +- `dotenv`: `^17.4.2` +- `typescript`: `^6.0.3` +- `vite`: `^8.0.9` +- `vitest`: `^4.1.4` -**Handler error response pattern** (from `crowdsec_handler.go`): -```go -c.JSON(http.StatusBadRequest, gin.H{"error": "..."}) -c.JSON(http.StatusNotFound, gin.H{"error": "..."}) -c.JSON(http.StatusInternalServerError, gin.H{"error": "..."}) -``` +`@axe-core/playwright` is **not yet installed**. Latest version: `4.11.2`. -**Frontend API client pattern** (from `frontend/src/api/crowdsec.ts`): -```typescript -export const listXxx = async (): Promise => { - const resp = await client.get('/admin/crowdsec/xxx') - return resp.data -} -``` +### 2.7 Lefthook Configuration -**Frontend mutation pattern** (from `CrowdSecConfig.tsx`): -```typescript -const mutation = useMutation({ - mutationFn: (data) => apiCall(data), - onSuccess: () => { - toast.success('...') - queryClient.invalidateQueries({ queryKey: ['crowdsec-whitelist'] }) - }, - onError: (err) => toast.error(err instanceof Error ? err.message : '...'), -}) -``` - -### 2.4 CrowdSec Whitelist YAML Format - -CrowdSec's `crowdsecurity/whitelists` parser expects the following YAML structure at a path under the `parsers/s02-enrich/` directory: - -```yaml -name: charon-whitelist -description: "Charon-managed IP/CIDR whitelist" -filter: "evt.Meta.service == 'http'" -whitelist: - reason: "Charon managed whitelist" - ip: - - "1.2.3.4" - cidr: - - "10.0.0.0/8" - - "192.168.0.0/16" -``` - -For an empty whitelist, both `ip` and `cidr` must be present as empty lists (not omitted) to produce valid YAML that CrowdSec can parse without error. +Pre-commit hooks run in parallel: file hygiene, YAML check, shellcheck, actionlint, Go lint, frontend type-check, frontend lint, semgrep. No Playwright-related hooks. No changes needed for this feature. --- ## 3. Technical Specifications -### 3.1 Database Schema +### 3.1 Architecture Decision: Dedicated A11y Spec Files -**New model**: `backend/internal/models/crowdsec_whitelist.go` +**Decision**: Create **dedicated accessibility spec files** in a new `tests/a11y/` directory rather than embedding axe scans into existing spec files. -```go -package models +**Rationale**: -import "time" +| Approach | Pros | Cons | +|----------|------|------| +| **Dedicated specs** (chosen) | Clean separation of concerns; a11y failures don't mask functional failures; can be sharded independently; easy to skip/focus; clear ownership | Slight duplication of page navigation | +| Embedded in existing specs | No navigation duplication; tests a11y in real user flows | Mixes functional and a11y failures; harder to triage; slows all tests; harder to baseline/skip | -// CrowdSecWhitelist represents a single IP or CIDR exempted from CrowdSec banning. -type CrowdSecWhitelist struct { - ID uint `json:"-" gorm:"primaryKey"` - UUID string `json:"uuid" gorm:"uniqueIndex;not null"` - IPOrCIDR string `json:"ip_or_cidr" gorm:"not null;uniqueIndex"` - Reason string `json:"reason" gorm:"not null;default:''"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} -``` +The dedicated approach is preferred because: -**AutoMigrate registration** (`backend/internal/api/routes/routes.go`, append after `&models.ManualChallenge{}`): -```go -&models.CrowdSecWhitelist{}, -``` +1. A11y violations may be numerous initially and need a baseline — mixing them with functional tests would cause noise +2. Independent sharding means a11y tests don't slow down existing functional test shards +3. Clearer CI reporting: a11y failures are immediately identifiable in workflow job names -### 3.2 API Design +### 3.2 Shared Accessibility Fixture -All new endpoints live under the existing `/api/v1` prefix and are registered inside `CrowdsecHandler.RegisterRoutes(rg *gin.RouterGroup)`, following the same `rg.METHOD("/admin/crowdsec/...")` naming pattern as every other CrowdSec endpoint. - -#### Endpoint Table - -| Method | Path | Auth | Description | -|---|---|---|---| -| `GET` | `/api/v1/admin/crowdsec/whitelist` | Management | List all whitelist entries | -| `POST` | `/api/v1/admin/crowdsec/whitelist` | Management | Add a new entry | -| `DELETE` | `/api/v1/admin/crowdsec/whitelist/:uuid` | Management | Remove an entry by UUID | - -#### `GET /admin/crowdsec/whitelist` - -**Response 200**: -```json -{ - "whitelist": [ - { - "uuid": "a1b2c3d4-...", - "ip_or_cidr": "10.0.0.0/8", - "reason": "Internal subnet", - "created_at": "2026-05-20T12:00:00Z", - "updated_at": "2026-05-20T12:00:00Z" - } - ] -} -``` - -#### `POST /admin/crowdsec/whitelist` - -**Request body**: -```json -{ "ip_or_cidr": "10.0.0.0/8", "reason": "Internal subnet" } -``` - -**Response 201**: -```json -{ - "uuid": "a1b2c3d4-...", - "ip_or_cidr": "10.0.0.0/8", - "reason": "Internal subnet", - "created_at": "...", - "updated_at": "..." -} -``` - -**Error responses**: -- `400` — missing/invalid `ip_or_cidr` field, unparseable IP/CIDR -- `409` — duplicate entry (same `ip_or_cidr` already exists) -- `500` — database or YAML write failure - -#### `DELETE /admin/crowdsec/whitelist/:uuid` - -**Response 204** — no body - -**Error responses**: -- `404` — entry not found -- `500` — database or YAML write failure - -### 3.3 Service Design - -**New file**: `backend/internal/services/crowdsec_whitelist_service.go` - -```go -package services - -import ( - "context" - "errors" - "net" - "os" - "path/filepath" - "text/template" - - "github.com/google/uuid" - "gorm.io/gorm" - - "github.com/yourusername/charon/backend/internal/models" - "github.com/yourusername/charon/backend/internal/logger" -) - -var ( - ErrWhitelistNotFound = errors.New("whitelist entry not found") - ErrInvalidIPOrCIDR = errors.New("invalid IP address or CIDR notation") - ErrDuplicateEntry = errors.New("whitelist entry already exists") -) - -type CrowdSecWhitelistService struct { - db *gorm.DB - dataDir string -} - -func NewCrowdSecWhitelistService(db *gorm.DB, dataDir string) *CrowdSecWhitelistService { - return &CrowdSecWhitelistService{db: db, dataDir: dataDir} -} - -// List returns all whitelist entries ordered by creation time. -func (s *CrowdSecWhitelistService) List(ctx context.Context) ([]models.CrowdSecWhitelist, error) { ... } - -// Add validates, persists, and regenerates the YAML file. -func (s *CrowdSecWhitelistService) Add(ctx context.Context, ipOrCIDR, reason string) (*models.CrowdSecWhitelist, error) { ... } - -// Delete removes an entry by UUID and regenerates the YAML file. -func (s *CrowdSecWhitelistService) Delete(ctx context.Context, uuid string) error { ... } - -// WriteYAML renders all current entries to /parsers/s02-enrich/charon-whitelist.yaml -func (s *CrowdSecWhitelistService) WriteYAML(ctx context.Context) error { ... } -``` - -**Validation logic** in `Add()`: -1. Trim whitespace from `ipOrCIDR`. -2. Attempt `net.ParseIP(ipOrCIDR)` — if non-nil, it's a bare IP ✓ -3. Attempt `net.ParseCIDR(ipOrCIDR)` — if `err == nil`, it's a valid CIDR ✓; normalize host bits immediately: `ipOrCIDR = network.String()` (e.g., `"10.0.0.1/8"` → `"10.0.0.0/8"`). -4. If both fail → return `ErrInvalidIPOrCIDR` -5. Attempt DB insert; if GORM unique constraint error → return `ErrDuplicateEntry` -6. On success → call `WriteYAML(ctx)` (non-fatal on YAML error: log + return original entry) - -> **Note**: `Add()` and `Delete()` do **not** call `cscli hub reload`. Reload is the caller's responsibility (handled in `CrowdsecHandler.AddWhitelist` and `DeleteWhitelist` via `h.CmdExec`). - -**CIDR normalization snippet** (step 3): -```go -if ip, network, err := net.ParseCIDR(ipOrCIDR); err == nil { - _ = ip - ipOrCIDR = network.String() // normalizes "10.0.0.1/8" → "10.0.0.0/8" -} -``` - -**YAML generation** in `WriteYAML()`: - -Guard: if `s.dataDir == ""`, return `nil` immediately (no-op — used in unit tests that don't need file I/O). - -```go -const whitelistTmpl = `name: charon-whitelist -description: "Charon-managed IP/CIDR whitelist" -filter: "evt.Meta.service == 'http'" -whitelist: - reason: "Charon managed whitelist" - ip: -{{- range .IPs}} - - "{{.}}" -{{- end}} -{{- if not .IPs}} - [] -{{- end}} - cidr: -{{- range .CIDRs}} - - "{{.}}" -{{- end}} -{{- if not .CIDRs}} - [] -{{- end}} -` -``` - -Target file path: `/config/parsers/s02-enrich/charon-whitelist.yaml` - -Directory created with `os.MkdirAll(..., 0o750)` if absent. - -File written atomically: render to `.tmp` → `os.Rename(tmp, path)`. - -### 3.4 Handler Design - -**Additions to `CrowdsecHandler` struct**: -```go -type CrowdsecHandler struct { - // ... existing fields ... - WhitelistSvc *services.CrowdSecWhitelistService // NEW -} -``` - -**`NewCrowdsecHandler` constructor** — initialize `WhitelistSvc`: -```go -h := &CrowdsecHandler{ - // ... existing assignments ... -} -if db != nil { - h.WhitelistSvc = services.NewCrowdSecWhitelistService(db, dataDir) -} -return h -``` - -**Three new methods on `CrowdsecHandler`**: - -```go -// ListWhitelists handles GET /admin/crowdsec/whitelist -func (h *CrowdsecHandler) ListWhitelists(c *gin.Context) { - entries, err := h.WhitelistSvc.List(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list whitelist entries"}) - return - } - c.JSON(http.StatusOK, gin.H{"whitelist": entries}) -} - -// AddWhitelist handles POST /admin/crowdsec/whitelist -func (h *CrowdsecHandler) AddWhitelist(c *gin.Context) { - var req struct { - IPOrCIDR string `json:"ip_or_cidr" binding:"required"` - Reason string `json:"reason"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "ip_or_cidr is required"}) - return - } - entry, err := h.WhitelistSvc.Add(c.Request.Context(), req.IPOrCIDR, req.Reason) - if errors.Is(err, services.ErrInvalidIPOrCIDR) { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if errors.Is(err, services.ErrDuplicateEntry) { - c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) - return - } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add whitelist entry"}) - return - } - // Reload CrowdSec so the new entry takes effect immediately (non-fatal). - if reloadErr := h.CmdExec.Execute("cscli", "hub", "reload"); reloadErr != nil { - logger.Log().WithError(reloadErr).Warn("failed to reload CrowdSec after whitelist add (non-fatal)") - } - c.JSON(http.StatusCreated, entry) -} - -// DeleteWhitelist handles DELETE /admin/crowdsec/whitelist/:uuid -func (h *CrowdsecHandler) DeleteWhitelist(c *gin.Context) { - id := strings.TrimSpace(c.Param("uuid")) - if id == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "uuid required"}) - return - } - err := h.WhitelistSvc.Delete(c.Request.Context(), id) - if errors.Is(err, services.ErrWhitelistNotFound) { - c.JSON(http.StatusNotFound, gin.H{"error": "whitelist entry not found"}) - return - } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete whitelist entry"}) - return - } - // Reload CrowdSec so the removed entry is no longer exempt (non-fatal). - if reloadErr := h.CmdExec.Execute("cscli", "hub", "reload"); reloadErr != nil { - logger.Log().WithError(reloadErr).Warn("failed to reload CrowdSec after whitelist delete (non-fatal)") - } - c.Status(http.StatusNoContent) -} -``` - -**Route registration** (append inside `RegisterRoutes`, after existing decision/bouncer routes): -```go -// Whitelist management -rg.GET("/admin/crowdsec/whitelist", h.ListWhitelists) -rg.POST("/admin/crowdsec/whitelist", h.AddWhitelist) -rg.DELETE("/admin/crowdsec/whitelist/:uuid", h.DeleteWhitelist) -``` - -### 3.5 Startup Integration - -**File**: `backend/internal/services/crowdsec_startup.go` - -In `ReconcileCrowdSecOnStartup()`, before the CrowdSec process is started: - -```go -// Regenerate whitelist YAML to ensure it reflects the current DB state. -whitelistSvc := NewCrowdSecWhitelistService(db, dataDir) -if err := whitelistSvc.WriteYAML(ctx); err != nil { - logger.Log().WithError(err).Warn("failed to write CrowdSec whitelist YAML on startup (non-fatal)") -} -``` - -This is **non-fatal**: if the DB has no entries, WriteYAML still writes an empty whitelist file, which is valid. - -### 3.6 Hub Parser Installation - -**File**: `configs/crowdsec/install_hub_items.sh` - -Add after the existing `cscli parsers install` lines: - -```bash -cscli parsers install crowdsecurity/whitelists --force || echo "⚠️ Failed to install crowdsecurity/whitelists" -``` - -### 3.7 Frontend Design - -#### API Client (`frontend/src/api/crowdsec.ts`) - -Append the following types and functions: +**File**: `tests/fixtures/a11y.ts` ```typescript -export interface CrowdSecWhitelistEntry { - uuid: string - ip_or_cidr: string - reason: string - created_at: string - updated_at: string +// Signature (not implementation) +import { test as base } from './auth-fixtures'; +import AxeBuilder from '@axe-core/playwright'; + +interface A11yFixtures { + makeAxeBuilder: () => AxeBuilder; } -export interface AddWhitelistPayload { - ip_or_cidr: string - reason: string -} +export const test = base.extend({ + makeAxeBuilder: async ({ page }, use) => { + const makeAxeBuilder = () => + new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag22aa']) + .exclude('.chartjs-canvas'); // Exclude known third-party canvases + await use(makeAxeBuilder); + }, +}); -export const listWhitelists = async (): Promise => { - const resp = await client.get<{ whitelist: CrowdSecWhitelistEntry[] }>('/admin/crowdsec/whitelist') - return resp.data.whitelist -} - -export const addWhitelist = async (data: AddWhitelistPayload): Promise => { - const resp = await client.post('/admin/crowdsec/whitelist', data) - return resp.data -} - -export const deleteWhitelist = async (uuid: string): Promise => { - await client.delete(`/admin/crowdsec/whitelist/${uuid}`) -} +export { expect } from './auth-fixtures'; ``` -#### TanStack Query Hooks (`frontend/src/hooks/useCrowdSecWhitelist.ts`) +> **Note**: The `.chartjs-canvas` selector is a placeholder. Verify against the actual DOM before implementation. A more robust approach may be to target `canvas` elements within chart container elements (e.g., `.chart-container canvas`). + +**Key design points**: + +- Extends `auth-fixtures` to inherit `adminUser`, `regularUser`, `guestUser`, and `testData` fixtures through the full extension chain (`auth-fixtures` → `test.ts` → coverage-aware base) +- Factory function (`makeAxeBuilder`) allows per-test customization (`.exclude()`, `.disableRules()`) +- WCAG tags: `wcag2a` (Level A), `wcag2aa` (Level AA), `wcag22aa` (WCAG 2.2 AA-specific rules) +- Global exclusions for known third-party elements that can't be fixed upstream + +### 3.3 A11y Helper Module + +**File**: `tests/utils/a11y-helpers.ts` ```typescript -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { listWhitelists, addWhitelist, deleteWhitelist, AddWhitelistPayload } from '../api/crowdsec' -import { toast } from 'sonner' +// Signature (not implementation) +import type { AxeResults, Result } from 'axe-core'; -export const useWhitelistEntries = () => - useQuery({ - queryKey: ['crowdsec-whitelist'], - queryFn: listWhitelists, - }) +type ViolationImpact = 'critical' | 'serious' | 'moderate' | 'minor'; -export const useAddWhitelist = () => { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: (data: AddWhitelistPayload) => addWhitelist(data), - onSuccess: () => { - toast.success('Whitelist entry added') - queryClient.invalidateQueries({ queryKey: ['crowdsec-whitelist'] }) - }, - onError: (err: unknown) => { - toast.error(err instanceof Error ? err.message : 'Failed to add whitelist entry') - }, - }) +interface A11yAssertionOptions { + /** Impacts to fail on. Default: ['critical', 'serious'] */ + failOn?: ViolationImpact[]; + /** Known violations to skip (rule IDs) */ + knownViolations?: string[]; } -export const useDeleteWhitelist = () => { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: (uuid: string) => deleteWhitelist(uuid), - onSuccess: () => { - toast.success('Whitelist entry removed') - queryClient.invalidateQueries({ queryKey: ['crowdsec-whitelist'] }) - }, - onError: (err: unknown) => { - toast.error(err instanceof Error ? err.message : 'Failed to remove whitelist entry') - }, - }) -} +/** + * Filters axe results and returns only violations matching the fail criteria. + * Formats violations for readable Playwright HTML report output. + */ +export function getFailingViolations( + results: AxeResults, + options?: A11yAssertionOptions +): Result[]; + +/** + * Formats a violation for human-readable output in test reports. + */ +export function formatViolation(violation: Result): string; + +/** + * Standard assertion: expect zero critical/serious violations. + */ +export function expectNoA11yViolations( + results: AxeResults, + options?: A11yAssertionOptions +): void; ``` -#### CrowdSecConfig.tsx Changes +### 3.4 Known Violations Baseline -The `CrowdSecConfig.tsx` page uses a tab navigation pattern. The new "Whitelist" tab: +**File**: `tests/a11y/a11y-baseline.ts` -1. **Visibility**: Only render the tab when `isLocalMode === true` (same guard as Decisions tab). -2. **Tab value**: `"whitelist"` — append to the existing tab list. -3. **Tab panel content** (isolated component or inline JSX): - - **Add entry form**: `ip_or_cidr` text input + `reason` text input + "Add" button (disabled while `addMutation.isPending`). Validation error shown inline when backend returns 400/409. - - **Quick-add current IP**: A secondary "Add My IP" button that calls `GET /api/v1/system/my-ip` (existing endpoint) and pre-fills the `ip_or_cidr` field with the returned IP. - - **Entries table**: Columns — IP/CIDR, Reason, Added, Actions. Each row has a delete button with a confirmation dialog (matching the ban/unban modal pattern used for Decisions). - - **Empty state**: "No whitelist entries" message when the list is empty. - - **Loading state**: Skeleton rows while `useWhitelistEntries` is fetching. +A centralized baseline of known violations that should not block CI. This enables gradual remediation. -**Imports added to `CrowdSecConfig.tsx`**: ```typescript -import { useWhitelistEntries, useAddWhitelist, useDeleteWhitelist } from '../hooks/useCrowdSecWhitelist' +// Signature (not implementation) + +interface BaselineEntry { + ruleId: string; + pages: string[]; // Route patterns where this rule is expected to fail + reason: string; // Why this is baselined + ticket?: string; // Tracking issue for remediation + expiresAt?: string; // ISO date for periodic review (e.g., '2026-07-01') +} + +export const A11Y_BASELINE: BaselineEntry[]; ``` -### 3.8 Data Flow Diagram +> **Baseline review process**: Baseline entries should be periodically reviewed. Use the optional `expiresAt` field to flag entries for re-evaluation. Entries past their expiration date should be investigated and either remediated or renewed with justification. -``` -Operator adds IP in UI - │ - ▼ -POST /api/v1/admin/crowdsec/whitelist - │ - ▼ -CrowdsecHandler.AddWhitelist() - │ - ▼ -CrowdSecWhitelistService.Add() - ├── Validate IP/CIDR (net.ParseIP / net.ParseCIDR) - ├── Normalize CIDR host bits (network.String()) - ├── Insert into SQLite (models.CrowdSecWhitelist) - └── WriteYAML() → /config/parsers/s02-enrich/charon-whitelist.yaml - │ - ▼ -h.CmdExec.Execute("cscli", "hub", "reload") [non-fatal on error] - │ - ▼ -Return 201 to frontend - │ - ▼ -invalidateQueries(['crowdsec-whitelist']) - │ - ▼ -Table re-fetches and shows new entry -``` +### 3.5 axe-core Configuration -``` -Container restart - │ - ▼ -ReconcileCrowdSecOnStartup() - │ - ▼ -CrowdSecWhitelistService.WriteYAML() - └── Reads all DB entries → renders YAML - │ - ▼ -CrowdSec process starts - │ - ▼ -CrowdSec loads parsers/s02-enrich/charon-whitelist.yaml - └── crowdsecurity/whitelists parser activates - │ - ▼ -IPs/CIDRs in file are exempt from all ban decisions -``` +| Setting | Value | Rationale | +|---------|-------|-----------| +| `withTags` | `['wcag2a', 'wcag2aa', 'wcag22aa']` | Targets WCAG 2.2 Level AA conformance per project a11y instructions | +| Fail threshold | `critical` + `serious` impacts | Blocks CI on high-impact violations only | +| `moderate` / `minor` | Reported but non-blocking | Allows gradual improvement | +| Global excludes | Third-party canvases (Chart.js), Toaster containers | Cannot be fixed in application code | -### 3.9 Error Handling Matrix +### 3.6 Reporter Integration -| Scenario | Service Error | HTTP Status | Frontend Behavior | -|---|---|---|---| -| Blank `ip_or_cidr` | — | 400 | Inline validation (required field) | -| Malformed IP/CIDR | `ErrInvalidIPOrCIDR` | 400 | Toast: "Invalid IP address or CIDR notation" | -| Duplicate entry | `ErrDuplicateEntry` | 409 | Toast: "This IP/CIDR is already whitelisted" | -| DB unavailable | generic error | 500 | Toast: "Failed to add whitelist entry" | -| UUID not found on DELETE | `ErrWhitelistNotFound` | 404 | Toast: "Whitelist entry not found" | -| YAML write failure | logged, non-fatal | 201 (Add still succeeds) | No user-facing error; log warning | -| CrowdSec reload failure | logged, non-fatal | 201/204 (operation still succeeds) | No user-facing error; log warning | +Axe results will be surfaced in the Playwright HTML report via: -### 3.10 Security Considerations +1. **`test.info().attach()`**: Attach violation details as JSON artifacts to each test +2. **Formatted assertion messages**: `expect(failingViolations).toEqual([])` with a descriptive message showing rule ID, impact, affected nodes, and fix suggestions +3. **Traces**: Standard `on-first-retry` trace capture applies to a11y tests too -- **Input validation**: All `ip_or_cidr` values are validated server-side with `net.ParseIP` / `net.ParseCIDR` before persisting. Arbitrary strings are rejected. -- **Path traversal**: `WriteYAML` constructs the output path via `filepath.Join(s.dataDir, "config", "parsers", "s02-enrich", "charon-whitelist.yaml")`. `dataDir` is set at startup—not user-supplied at request time. -- **Privilege**: All three endpoints require management-level access (same as all other CrowdSec endpoints). -- **YAML injection**: Values are rendered through Go's `text/template` with explicit quoting of each entry; no raw string concatenation. -- **Log safety**: IPs are logged using the same structured field pattern used in existing CrowdSec handler methods (e.g., `logger.Log().WithField("ip", entry.IPOrCIDR).Info(...)`). +### 3.7 Pages to Scan + +**Priority Tier 1** (most user-facing, first commit): + +| Route | Description | +|-------|-------------| +| `/login` | Login page (unauthenticated — requires `storageState: { cookies: [], origins: [] }` to prevent redirect) | +| `/` | Dashboard | +| `/proxy-hosts` | Proxy host management | +| `/certificates` | Certificate management | +| `/dns/providers` | DNS provider management | +| `/settings` | System settings | +| `/settings/users` | User management | + +**Priority Tier 2** (second commit): + +| Route | Description | +|-------|-------------| +| `/security` | Security dashboard | +| `/security/access-lists` | Access list management | +| `/security/crowdsec` | CrowdSec configuration | +| `/security/waf` | WAF configuration | +| `/security/rate-limiting` | Rate limiting | +| `/security/headers` | Security headers | +| `/security/encryption` | Encryption management | +| `/security/audit-logs` | Audit logs | +| `/uptime` | Uptime monitoring | + +**Priority Tier 3** (third commit): + +| Route | Description | +|-------|-------------| +| `/tasks/backups` | Backup management | +| `/tasks/logs` | Log viewer | +| `/tasks/import/caddyfile` | Caddyfile import | +| `/tasks/import/crowdsec` | CrowdSec import | +| `/tasks/import/npm` | NPM import | +| `/tasks/import/json` | JSON import | +| `/domains` | Domain management | +| `/remote-servers` | Remote server management | +| `/settings/notifications` | Notification settings | +| `/settings/smtp` | SMTP configuration | +| `/setup` | Initial setup page (unauthenticated — requires `storageState: { cookies: [], origins: [] }`) | --- ## 4. Implementation Plan -### Phase 1 — Hub Parser Installation (Groundwork) +### Phase 1: Infrastructure Setup -**Files Changed**: -- `configs/crowdsec/install_hub_items.sh` +**Commit 1**: Install dependency and create shared fixtures/helpers -**Task 1.1**: Add `cscli parsers install crowdsecurity/whitelists --force` after the last parser install line (currently `crowdsecurity/syslog-logs`). +**Files created/modified**: -**Acceptance**: File change is syntactically valid bash; `shellcheck` passes. +| File | Action | Description | +|------|--------|-------------| +| `package.json` | Modified | Add `@axe-core/playwright` to `devDependencies` | +| `package-lock.json` | Modified | Lockfile update | +| `tests/fixtures/a11y.ts` | Created | Shared a11y test fixture with `makeAxeBuilder` factory | +| `tests/utils/a11y-helpers.ts` | Created | `getFailingViolations()`, `formatViolation()`, `expectNoA11yViolations()` | +| `tests/a11y/a11y-baseline.ts` | Created | Empty baseline array (initial state — no known violations) | ---- +**Validation gate**: `npm ci` succeeds; `npx tsc --noEmit` passes on new files; imports resolve correctly. -### Phase 2 — Database Model +### Phase 2: Tier 1 A11y Specs -**Files Changed**: -- `backend/internal/models/crowdsec_whitelist.go` _(new file)_ -- `backend/internal/api/routes/routes.go` _(append to AutoMigrate call)_ +**Commit 2**: Add accessibility tests for Tier 1 pages (login, dashboard, proxy-hosts, certificates, dns, settings, users) -**Task 2.1**: Create `crowdsec_whitelist.go` with the `CrowdSecWhitelist` struct per §3.1. +**Files created**: -**Task 2.2**: Append `&models.CrowdSecWhitelist{}` to the `db.AutoMigrate(...)` call in `routes.go`. +| File | Description | +|------|-------------| +| `tests/a11y/login.a11y.spec.ts` | Scans `/login` (unauthenticated — uses `test.use({ storageState: { cookies: [], origins: [] } })`) | +| `tests/a11y/dashboard.a11y.spec.ts` | Scans `/` | +| `tests/a11y/proxy-hosts.a11y.spec.ts` | Scans `/proxy-hosts` | +| `tests/a11y/certificates.a11y.spec.ts` | Scans `/certificates` | +| `tests/a11y/dns-providers.a11y.spec.ts` | Scans `/dns/providers` | +| `tests/a11y/settings.a11y.spec.ts` | Scans `/settings` and `/settings/users` | -**Validation Gate**: `go build ./backend/...` passes; GORM generates `crowdsec_whitelists` table on next startup. +**Test structure** (each authenticated spec file follows this pattern): ---- - -### Phase 3 — Whitelist Service - -**Files Changed**: -- `backend/internal/services/crowdsec_whitelist_service.go` _(new file)_ - -**Task 3.1**: Implement `CrowdSecWhitelistService` with `List`, `Add`, `Delete`, `WriteYAML` per §3.3. - -**Task 3.2**: Implement IP/CIDR validation in `Add()`: -- `net.ParseIP(ipOrCIDR) != nil` → valid bare IP -- `net.ParseCIDR(ipOrCIDR)` returns no error → valid CIDR -- Both fail → `ErrInvalidIPOrCIDR` - -**Task 3.3**: Implement `WriteYAML()`: -- Query all entries from DB. -- Partition into `ips` (bare IPs) and `cidrs` (CIDR notation) slices. -- Render template per §2.4. -- Atomic write: temp file → `os.Rename`. -- Create directory (`os.MkdirAll`) if not present. - -**Validation Gate**: `go test ./backend/internal/services/... -run TestCrowdSecWhitelist` passes. - ---- - -### Phase 4 — API Endpoints - -**Files Changed**: -- `backend/internal/api/handlers/crowdsec_handler.go` - -**Task 4.1**: Add `WhitelistSvc *services.CrowdSecWhitelistService` field to `CrowdsecHandler` struct. - -**Task 4.2**: Initialize `WhitelistSvc` in `NewCrowdsecHandler()` when `db != nil`. - -**Task 4.3**: Implement `ListWhitelists`, `AddWhitelist`, `DeleteWhitelist` methods per §3.4. - -**Task 4.4**: Register three routes in `RegisterRoutes()` per §3.4. - -**Task 4.5**: In `AddWhitelist` and `DeleteWhitelist`, after the service call returns without error, call `h.CmdExec.Execute("cscli", "hub", "reload")`. Log a warning on failure; do not change the HTTP response status (reload failure is non-fatal). - -**Validation Gate**: `go test ./backend/internal/api/handlers/... -run TestWhitelist` passes; `make lint-fast` clean. - ---- - -### Phase 5 — Startup Integration - -**Files Changed**: -- `backend/internal/services/crowdsec_startup.go` - -**Task 5.1**: In `ReconcileCrowdSecOnStartup()`, after the DB and config are loaded but before calling `h.Executor.Start()`, instantiate `CrowdSecWhitelistService` and call `WriteYAML(ctx)`. Log warning on error; do not abort startup. - -**Validation Gate**: `go test ./backend/internal/services/... -run TestReconcile` passes; existing reconcile tests still pass. - ---- - -### Phase 6 — Frontend API + Hooks - -**Files Changed**: -- `frontend/src/api/crowdsec.ts` -- `frontend/src/hooks/useCrowdSecWhitelist.ts` _(new file)_ - -**Task 6.1**: Add `CrowdSecWhitelistEntry`, `AddWhitelistPayload` types and `listWhitelists`, `addWhitelist`, `deleteWhitelist` functions to `crowdsec.ts` per §3.7. - -**Task 6.2**: Create `useCrowdSecWhitelist.ts` with `useWhitelistEntries`, `useAddWhitelist`, `useDeleteWhitelist` hooks per §3.7. - -**Validation Gate**: `pnpm test` (Vitest) passes; TypeScript compilation clean. - ---- - -### Phase 7 — Frontend UI - -**Files Changed**: -- `frontend/src/pages/CrowdSecConfig.tsx` - -**Task 7.1**: Import the three hooks from `useCrowdSecWhitelist.ts`. - -**Task 7.2**: Add `"whitelist"` to the tab list (visible only when `isLocalMode === true`). - -**Task 7.3**: Implement the Whitelist tab panel: -- Add-entry form with IP/CIDR + Reason inputs. -- "Add My IP" button: `GET /api/v1/system/my-ip` → pre-fill `ip_or_cidr`. -- Entries table with UUID key, IP/CIDR, Reason, created date, delete button. -- Delete confirmation dialog (reuse existing modal pattern). - -**Task 7.4**: Wire mutation errors to inline form validation messages (400/409 responses). - -**Validation Gate**: `pnpm test` passes; TypeScript clean; `make lint-fast` clean. - ---- - -### Phase 8 — Tests - -**Files Changed**: -- `backend/internal/services/crowdsec_whitelist_service_test.go` _(new file)_ -- `backend/internal/api/handlers/crowdsec_whitelist_handler_test.go` _(new file)_ -- `tests/crowdsec-whitelist.spec.ts` _(new file)_ - -**Task 8.1 — Service unit tests**: - -| Test | Scenario | -|---|---| -| `TestAdd_ValidIP_Success` | Bare IPv4 inserted; YAML file created | -| `TestAdd_ValidIPv6_Success` | Bare IPv6 inserted | -| `TestAdd_ValidCIDR_Success` | CIDR range inserted | -| `TestAdd_CIDRNormalization` | `"10.0.0.1/8"` stored as `"10.0.0.0/8"` | -| `TestAdd_InvalidIPOrCIDR_Error` | Returns `ErrInvalidIPOrCIDR` | -| `TestAdd_DuplicateEntry_Error` | Second identical insert returns `ErrDuplicateEntry` | -| `TestDelete_Success` | Entry removed; YAML regenerated | -| `TestDelete_NotFound_Error` | Returns `ErrWhitelistNotFound` | -| `TestList_Empty` | Returns empty slice | -| `TestList_Populated` | Returns all entries ordered by `created_at` | -| `TestWriteYAML_EmptyList` | Writes valid YAML with empty `ip: []` and `cidr: []` | -| `TestWriteYAML_MixedEntries` | IPs in `ip:` block; CIDRs in `cidr:` block | -| `TestWriteYAML_EmptyDataDir_NoOp` | `dataDir == ""` → returns `nil`, no file written | - -**Task 8.2 — Handler unit tests** (using in-memory SQLite + `mockAuthMiddleware`): - -| Test | Scenario | -|---|---| -| `TestListWhitelists_200` | Returns 200 with entries array | -| `TestAddWhitelist_201` | Valid payload → 201 | -| `TestAddWhitelist_400_MissingField` | Empty body → 400 | -| `TestAddWhitelist_400_InvalidIP` | Malformed IP → 400 | -| `TestAddWhitelist_409_Duplicate` | Duplicate → 409 | -| `TestDeleteWhitelist_204` | Valid UUID → 204 | -| `TestDeleteWhitelist_404` | Unknown UUID → 404 | - -**Task 8.3 — E2E Playwright tests** (`tests/crowdsec-whitelist.spec.ts`): +Authenticated pages rely on the stored auth state from `storageState: STORAGE_STATE` (configured in `playwright.config.js` via the `setup` project), matching the pattern used by all existing non-security tests. No manual `loginUser()` call is needed. ```typescript -import { test, expect } from '@playwright/test' +import { test, expect } from '../fixtures/a11y'; +import { waitForLoadingComplete } from '../utils/wait-helpers'; +import { expectNoA11yViolations } from '../utils/a11y-helpers'; + +test.describe('Accessibility: Dashboard', () => { + test.describe.configure({ mode: 'parallel' }); -test.describe('CrowdSec Whitelist Management', () => { test.beforeEach(async ({ page }) => { - await page.goto('http://localhost:8080') - await page.getByRole('link', { name: 'Security' }).click() - await page.getByRole('tab', { name: 'CrowdSec' }).click() - await page.getByRole('tab', { name: 'Whitelist' }).click() - }) + await page.goto('/'); + await waitForLoadingComplete(page); + }); - test('Whitelist tab only visible in local mode', async ({ page }) => { - await page.goto('http://localhost:8080') - await page.getByRole('link', { name: 'Security' }).click() - await page.getByRole('tab', { name: 'CrowdSec' }).click() - // When CrowdSec is not in local mode, the Whitelist tab must not exist - await expect(page.getByRole('tab', { name: 'Whitelist' })).toBeHidden() - }) - - test('displays empty state when no entries exist', async ({ page }) => { - await expect(page.getByText('No whitelist entries')).toBeVisible() - }) - - test('adds a valid IP address', async ({ page }) => { - await page.getByRole('textbox', { name: 'IP or CIDR' }).fill('203.0.113.5') - await page.getByRole('textbox', { name: 'Reason' }).fill('Uptime monitor') - await page.getByRole('button', { name: 'Add' }).click() - await expect(page.getByText('Whitelist entry added')).toBeVisible() - await expect(page.getByRole('cell', { name: '203.0.113.5' })).toBeVisible() - }) - - test('adds a valid CIDR range', async ({ page }) => { - await page.getByRole('textbox', { name: 'IP or CIDR' }).fill('10.0.0.0/8') - await page.getByRole('textbox', { name: 'Reason' }).fill('Internal subnet') - await page.getByRole('button', { name: 'Add' }).click() - await expect(page.getByText('Whitelist entry added')).toBeVisible() - await expect(page.getByRole('cell', { name: '10.0.0.0/8' })).toBeVisible() - }) - - test('"Add My IP" button pre-fills the detected client IP', async ({ page }) => { - await page.getByRole('button', { name: 'Add My IP' }).click() - const ipField = page.getByRole('textbox', { name: 'IP or CIDR' }) - const value = await ipField.inputValue() - // Value must be a non-empty valid IP - expect(value).toMatch(/^[\d.]+$|^[0-9a-fA-F:]+$/) - }) - - test('shows validation error for invalid input', async ({ page }) => { - await page.getByRole('textbox', { name: 'IP or CIDR' }).fill('not-an-ip') - await page.getByRole('button', { name: 'Add' }).click() - await expect(page.getByText('Invalid IP address or CIDR notation')).toBeVisible() - }) - - test('removes an entry via delete confirmation', async ({ page }) => { - // Seed an entry first - await page.getByRole('textbox', { name: 'IP or CIDR' }).fill('198.51.100.1') - await page.getByRole('button', { name: 'Add' }).click() - await expect(page.getByRole('cell', { name: '198.51.100.1' })).toBeVisible() - - // Delete it - await page.getByRole('row', { name: /198\.51\.100\.1/ }).getByRole('button', { name: 'Delete' }).click() - await page.getByRole('button', { name: 'Confirm' }).click() - await expect(page.getByText('Whitelist entry removed')).toBeVisible() - await expect(page.getByRole('cell', { name: '198.51.100.1' })).toBeHidden() - }) -}) + test('dashboard has no critical a11y violations', async ({ page, makeAxeBuilder }) => { + const results = await makeAxeBuilder().analyze(); + test.info().attach('a11y-results', { + body: JSON.stringify(results.violations, null, 2), + contentType: 'application/json', + }); + expectNoA11yViolations(results); + }); +}); ``` ---- +**Unauthenticated page pattern** (`/login`, `/setup`, `/accept-invite`): -### Phase 9 — Documentation +```typescript +import { test, expect } from '../fixtures/a11y'; +import { waitForLoadingComplete } from '../utils/wait-helpers'; +import { expectNoA11yViolations } from '../utils/a11y-helpers'; -**Files Changed**: -- `ARCHITECTURE.md` -- `docs/features/crowdsec-whitelist.md` _(new file, optional for this PR)_ +// Clear stored auth state to prevent redirect to dashboard +test.use({ storageState: { cookies: [], origins: [] } }); -**Task 9.1**: Update the CrowdSec row in the Cerberus security components table in `ARCHITECTURE.md` to mention whitelist management. +test.describe('Accessibility: Login', () => { + test.describe.configure({ mode: 'parallel' }); + + test('login page has no critical a11y violations', async ({ page, makeAxeBuilder }) => { + await page.goto('/login'); + await waitForLoadingComplete(page); + + const results = await makeAxeBuilder().analyze(); + test.info().attach('a11y-results', { + body: JSON.stringify(results.violations, null, 2), + contentType: 'application/json', + }); + expectNoA11yViolations(results); + }); +}); +``` + +> **Parallel mode**: Each a11y test is an independent page scan with no shared state, so `test.describe.configure({ mode: 'parallel' })` should be used in all a11y describe blocks to maximize throughput. +``` + +**Validation gate**: Tests run locally against Docker container. All pass or fail with only baseline-allowed violations. Verify HTML report contains a11y result attachments. + +### Phase 3: Tier 2 A11y Specs + +**Commit 3**: Add accessibility tests for security and monitoring pages + +**Files created**: + +| File | Description | +|------|-------------| +| `tests/a11y/security.a11y.spec.ts` | Scans `/security`, `/security/access-lists`, `/security/crowdsec`, `/security/waf`, `/security/rate-limiting`, `/security/headers`, `/security/encryption`, `/security/audit-logs` | +| `tests/a11y/uptime.a11y.spec.ts` | Scans `/uptime` | + +**Validation gate**: All new tests pass locally. Verify cross-browser with `--project=firefox --project=chromium --project=webkit`. + +### Phase 4: Tier 3 A11y Specs + +**Commit 4**: Add accessibility tests for tasks, domains, remote servers, notifications, SMTP, setup pages + +**Files created**: + +| File | Description | +|------|-------------| +| `tests/a11y/tasks.a11y.spec.ts` | Scans `/tasks/backups`, `/tasks/logs`, `/tasks/import/caddyfile`, `/tasks/import/crowdsec`, `/tasks/import/npm`, `/tasks/import/json` | +| `tests/a11y/domains.a11y.spec.ts` | Scans `/domains`, `/remote-servers` | +| `tests/a11y/notifications.a11y.spec.ts` | Scans `/settings/notifications`, `/settings/smtp` | +| `tests/a11y/setup.a11y.spec.ts` | Scans `/setup` (unauthenticated — uses `test.use({ storageState: { cookies: [], origins: [] } })`; requires fresh state or skips if already set up) | + +**Validation gate**: Full local run with all 3 browsers. + +### Phase 5: CI Integration + +**Commit 5**: Add `tests/a11y/` to CI workflow non-security shard test paths + +**Files modified**: + +| File | Change | +|------|--------| +| `.github/workflows/e2e-tests-split.yml` | Add `tests/a11y` to the non-security test directory list in all three browser jobs (`e2e-chromium`, `e2e-firefox`, `e2e-webkit`) | + +The change in each browser job's Playwright invocation adds `tests/a11y` to the directory list: + +```bash +# Before +npx playwright test \ + --project=chromium \ + --shard=${{ matrix.shard }}/${{ matrix.total-shards }} \ + tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts \ + tests/integration tests/manual-dns-provider.spec.ts tests/monitoring \ + tests/settings tests/tasks + +# After +npx playwright test \ + --project=chromium \ + --shard=${{ matrix.shard }}/${{ matrix.total-shards }} \ + tests/a11y tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts \ + tests/integration tests/manual-dns-provider.spec.ts tests/monitoring \ + tests/settings tests/tasks +``` + +**Validation gate**: Push to a feature branch. Verify all 15 CI jobs pass (or fail only on genuine a11y issues). Verify a11y tests appear in uploaded Playwright HTML report artifacts. + +> **Shard timing monitoring**: After rollout, monitor shard execution times across the 12 non-security jobs. If a11y tests create significant imbalance (one shard consistently slower), consider a dedicated a11y CI job with its own sharding. This is a "watch and react" item — no preemptive action needed. + +### Phase 6: Documentation + +**Commit 6**: Add documentation for the a11y testing setup + +**Files created/modified**: + +| File | Action | Description | +|------|--------|-------------| +| `tests/a11y/README.md` | Created | Documents how to run a11y tests, add new pages, manage the baseline, interpret results | --- -## 5. Acceptance Criteria +## 5. CI Integration Details -### Functional +### 5.1 Where A11y Tests Run -- [ ] Operator can add a bare IPv4 address (e.g., `203.0.113.5`) to the whitelist. -- [ ] Operator can add a bare IPv6 address (e.g., `2001:db8::1`) to the whitelist. -- [ ] Operator can add a CIDR range (e.g., `10.0.0.0/8`) to the whitelist. -- [ ] Adding an invalid IP/CIDR (e.g., `not-an-ip`) returns a 400 error with a clear message. -- [ ] Adding a duplicate entry returns a 409 conflict error. -- [ ] Operator can delete an entry; it disappears from the list. -- [ ] The Whitelist tab is only visible when CrowdSec is in `local` mode. -- [ ] After adding or deleting an entry, the whitelist YAML file is regenerated in `/config/parsers/s02-enrich/charon-whitelist.yaml`. -- [ ] Adding or removing a whitelist entry triggers `cscli hub reload` via `h.CmdExec` so changes take effect immediately without a container restart. -- [ ] On container restart, the YAML file is regenerated from DB entries before CrowdSec starts. -- [ ] **Admin IP protection**: The "Add My IP" button pre-fills the operator's current IP in the `ip_or_cidr` field; a Playwright E2E test verifies the button correctly pre-fills the detected client IP. +A11y tests join the **non-security shard** jobs. They: -### Technical +- Run across all 3 browsers (Chromium, Firefox, WebKit) +- Are distributed across the 4 shards per browser via Playwright's `--shard` flag +- Use the same Docker container (Cerberus OFF) +- Share the same auth setup dependency -- [ ] `go test ./backend/...` passes — no regressions. -- [ ] `pnpm test` (Vitest) passes. -- [ ] `make lint-fast` clean — no new lint findings. -- [ ] GORM Security Scanner returns zero CRITICAL/HIGH findings. -- [ ] Playwright E2E suite passes (Firefox, `--project=firefox`). -- [ ] `crowdsecurity/whitelists` parser is installed by `install_hub_items.sh`. +### 5.2 Sharding Impact + +Adding ~10 spec files to the non-security pool (currently ~50 spec files sharded 4 ways per browser) increases the per-shard workload by ~20%. Each axe scan takes 2-5 seconds per page, so the total added time per shard is approximately **10-30 seconds** — within acceptable tolerance given the 60-minute timeout. + +### 5.3 Failure Behavior + +| Impact Level | CI Behavior | +|-------------|-------------| +| `critical` | **Fails CI** — test assertion fails, shard exits non-zero | +| `serious` | **Fails CI** — test assertion fails, shard exits non-zero | +| `moderate` | **Reported only** — attached to HTML report as JSON, does not fail | +| `minor` | **Reported only** — attached to HTML report as JSON, does not fail | + +### 5.4 Baseline Workflow + +When a new genuine violation is discovered that cannot be immediately fixed: + +1. Create a GitHub issue tracking the remediation +2. Add the rule ID + page pattern to `tests/a11y/a11y-baseline.ts` with the issue reference +3. The `expectNoA11yViolations()` helper filters out baselined violations +4. When remediation is complete, remove the baseline entry — CI will now enforce the fix --- -## 6. Commit Slicing Strategy +## 6. Edge Cases and Considerations -**Decision**: Single PR with ordered logical commits. No scope overlap between commits; each commit leaves the codebase in a compilable state. +### 6.1 Performance -**Trigger reasons**: Cross-domain change (infra script + model + service + handler + startup + frontend) benefits from ordered commits for surgical rollback and focused review. +- axe-core injects a script (~500KB) into each page; this happens per `analyze()` call +- Expected overhead per scan: 2-5 seconds +- Total overhead for ~33 pages across 3 browsers: ~5-8 minutes of additional CI time distributed across 12 non-security shards +- **Mitigation**: Tests use `waitForLoadingComplete()` to ensure pages are fully rendered before scanning, avoiding incomplete DOM analysis -| # | Type | Commit Message | Files | Depends On | Validation Gate | -|---|---|---|---|---|---| -| 1 | `chore` | `install crowdsecurity/whitelists parser by default` | `configs/crowdsec/install_hub_items.sh` | — | `shellcheck` | -| 2 | `feat` | `add CrowdSecWhitelist model and automigrate registration` | `backend/internal/models/crowdsec_whitelist.go`, `backend/internal/api/routes/routes.go` | #1 | `go build ./backend/...` | -| 3 | `feat` | `add CrowdSecWhitelistService with YAML generation` | `backend/internal/services/crowdsec_whitelist_service.go` | #2 | `go test ./backend/internal/services/...` | -| 4 | `feat` | `add whitelist API endpoints to CrowdsecHandler` | `backend/internal/api/handlers/crowdsec_handler.go` | #3 | `go test ./backend/...` + `make lint-fast` | -| 5 | `feat` | `regenerate whitelist YAML on CrowdSec startup reconcile` | `backend/internal/services/crowdsec_startup.go` | #3 | `go test ./backend/internal/services/...` | -| 6 | `feat` | `add whitelist API client functions and TanStack hooks` | `frontend/src/api/crowdsec.ts`, `frontend/src/hooks/useCrowdSecWhitelist.ts` | #4 | `pnpm test` | -| 7 | `feat` | `add Whitelist tab to CrowdSecConfig UI` | `frontend/src/pages/CrowdSecConfig.tsx` | #6 | `pnpm test` + `make lint-fast` | -| 8 | `test` | `add whitelist service and handler unit tests` | `*_test.go` files | #4 | `go test ./backend/...` | -| 9 | `test` | `add E2E tests for CrowdSec whitelist management` | `tests/crowdsec-whitelist.spec.ts` | #7 | Playwright Firefox | -| 10 | `docs` | `update architecture docs for CrowdSec whitelist feature` | `ARCHITECTURE.md` | #7 | `make lint-fast` | +### 6.2 Dynamic Content and Loading States -**Rollback notes**: -- Commits 1–3 are pure additions (no existing code modified except the `AutoMigrate` list append in commit 2 and `install_hub_items.sh` in commit 1). Reverting them is safe. -- Commit 4 modifies `crowdsec_handler.go` by adding fields and methods without altering existing ones; reverting is mechanical. -- Commit 5 modifies `crowdsec_startup.go` — the added block is isolated in a clearly marked section; revert is a 5-line removal. -- Commits 6–7 are frontend-only; reverting has no backend impact. +- All scans MUST wait for loading states to complete (`waitForLoadingComplete()`) +- Pages with lazy-loaded content (modals, dropdowns) should be scanned in their default state first; modal-specific scans can be added as follow-up +- The `waitForTableLoad()` helper should be used for pages with data tables (proxy hosts, certificates, etc.) +- **Async data pages**: Pages that fetch data asynchronously (proxy-hosts, certificates, DNS providers, uptime monitors) should use `waitForTableLoad()` or equivalent waits to ensure the data-populated DOM is scanned, not the loading skeleton + +### 6.3 Browser-Specific Behavior + +axe-core produces consistent results across browsers because it analyzes the DOM/ARIA tree, not rendered pixels. However: + +- WebKit may have minor differences in ARIA attribute support +- Running across all 3 browsers catches rendering-layer a11y issues (e.g., focus visibility) that axe cannot detect +- If a violation appears in one browser but not others, investigate before baselining + +### 6.4 Third-Party Components + +| Component | Strategy | +|-----------|----------| +| Chart.js canvases | Exclude via `.exclude('.chartjs-canvas')` or equivalent selector — canvas elements have inherent a11y limitations | +| React Hot Toast | Exclude toaster container — controlled by library, has built-in ARIA | +| Code editors (if any) | Exclude via selector — third-party code editors have known a11y gaps | + +### 6.5 Gradual Rollout Strategy + +To avoid blocking CI with a flood of violations on first merge: + +1. **Commit 1-4**: Build the infrastructure and specs. Run locally, observe results. +2. **Before Commit 5** (CI integration): Populate `a11y-baseline.ts` with any critical/serious violations found during local testing. Create tracking issues for each. +3. **Commit 5**: CI integration — all tests pass because known violations are baselined. +4. **Post-merge**: Remediate baselined violations one by one. As each is fixed, remove from baseline. CI enforces the fix from that point forward. --- -## 7. Open Questions / Risks +## 7. Files Requiring Review for Updates -| Risk | Likelihood | Mitigation | -|---|---|---| -| CrowdSec does not hot-reload parser files — requires `cscli reload` or process restart | Resolved | `cscli hub reload` is called via `h.CmdExec.Execute(...)` in `AddWhitelist` and `DeleteWhitelist` after each successful `WriteYAML()`. Failure is non-fatal; logged as a warning. | -| `crowdsecurity/whitelists` parser path may differ across CrowdSec versions | Low | Use `/config/parsers/s02-enrich/` which is the canonical path; add a note to verify on version upgrades | -| Large whitelist files could cause CrowdSec performance issues | Very Low | Reasonable for typical use; document a soft limit recommendation (< 500 entries) in the UI | -| `dataDir` empty string in tests | Resolved | Guard added to `WriteYAML`: `if s.dataDir == "" { return nil }` — no-op when `dataDir` is unset | -| `CROWDSEC_TRUSTED_IPS` env var seeding | — | **Follow-up / future enhancement** (not in scope for this PR): if `CROWDSEC_TRUSTED_IPS` is set at runtime, parse comma-separated IPs and include them as read-only seed entries in the generated YAML (separate from DB-managed entries). Document in a follow-up issue. | +| File | Check | Action Required | +|------|-------|-----------------| +| `.gitignore` | No new generated files outside existing patterns | **No change needed** | +| `codecov.yml` | a11y tests are Playwright specs, already covered by E2E patterns | **No change needed** | +| `.dockerignore` | `tests/` is not copied into Docker image | **No change needed** | +| `Dockerfile` | Tests are not part of the Docker build | **No change needed** | +| `lefthook.yml` | No pre-commit a11y hooks needed | **No change needed** | +| `playwright.config.js` | a11y specs match existing `testMatch` and `testIgnore` patterns. The `tests/a11y/` directory is NOT in any ignore pattern. | **No change needed** | +| `tsconfig.json` (if any) | Ensure `@axe-core/playwright` types resolve | **Verify** — `@axe-core/playwright` ships with TypeScript declarations | +--- + +## 8. Commit Slicing Strategy + +**Approach**: Single PR with 6 ordered logical commits. + +**Trigger reasons**: Single feature scope, low cross-domain risk, incremental validation. + +### Commit 1: Infrastructure — install dependency and create shared fixtures + +- **Scope**: Package installation, fixture creation, helper module, baseline file +- **Files**: `package.json`, `package-lock.json`, `tests/fixtures/a11y.ts`, `tests/utils/a11y-helpers.ts`, `tests/a11y/a11y-baseline.ts` +- **Dependencies**: None +- **Validation**: `npm ci`, `npx tsc --noEmit`, import resolution + +### Commit 2: Tier 1 a11y specs — core pages + +- **Scope**: A11y tests for login, dashboard, proxy-hosts, certificates, DNS, settings +- **Files**: `tests/a11y/login.a11y.spec.ts`, `tests/a11y/dashboard.a11y.spec.ts`, `tests/a11y/proxy-hosts.a11y.spec.ts`, `tests/a11y/certificates.a11y.spec.ts`, `tests/a11y/dns-providers.a11y.spec.ts`, `tests/a11y/settings.a11y.spec.ts` +- **Dependencies**: Commit 1 +- **Validation**: `npx playwright test tests/a11y/ --project=firefox` + +### Commit 3: Tier 2 a11y specs — security and monitoring pages + +- **Scope**: A11y tests for security suite and uptime monitoring +- **Files**: `tests/a11y/security.a11y.spec.ts`, `tests/a11y/uptime.a11y.spec.ts` +- **Dependencies**: Commit 1 +- **Validation**: `npx playwright test tests/a11y/ --project=firefox` + +### Commit 4: Tier 3 a11y specs — tasks, domains, notifications, setup + +- **Scope**: Remaining page coverage +- **Files**: `tests/a11y/tasks.a11y.spec.ts`, `tests/a11y/domains.a11y.spec.ts`, `tests/a11y/notifications.a11y.spec.ts`, `tests/a11y/setup.a11y.spec.ts` +- **Dependencies**: Commit 1 +- **Validation**: Full a11y suite: `npx playwright test tests/a11y/ --project=chromium --project=firefox --project=webkit` + +### Commit 5: CI integration — add a11y tests to workflow + +- **Scope**: Add `tests/a11y` to non-security shard test paths in CI workflow +- **Files**: `.github/workflows/e2e-tests-split.yml` +- **Dependencies**: Commits 1-4 (all specs must pass first) +- **Validation**: Push to feature branch; all 15 CI jobs pass + +### Commit 6: Documentation + +- **Scope**: README for a11y test directory +- **Files**: `tests/a11y/README.md` +- **Dependencies**: Commits 1-5 +- **Validation**: Markdown lint passes + +### Rollback + +If the PR causes CI instability post-merge: + +1. **Immediate**: Revert Commit 5 only (removes `tests/a11y` from CI paths) — a11y tests still exist but don't run in CI +2. **Investigation**: Run a11y tests locally to identify flaky or environment-dependent failures +3. **Resolution**: Fix failures, re-add to CI + +--- + +## 9. Acceptance Criteria + +| # | Criterion | Verification | +|---|-----------|-------------| +| 1 | `@axe-core/playwright` installed as devDependency | `npm ls @axe-core/playwright` returns version | +| 2 | Shared fixture provides `makeAxeBuilder` factory | `tests/fixtures/a11y.ts` exports correctly | +| 3 | A11y scans cover all ~33 navigable pages | Count tests in `tests/a11y/` matches page list | +| 4 | WCAG 2.2 AA tags configured | `withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])` in fixture | +| 5 | CI fails on critical/serious violations | Inject a test violation, verify CI fails | +| 6 | Results visible in Playwright HTML report | Open report, verify JSON attachments on a11y tests | +| 7 | Works across Chromium, Firefox, WebKit | All 3 browser projects pass in CI | +| 8 | Baseline mechanism for known violations | Baselined violations do not fail CI | +| 9 | CI workflow updated to include `tests/a11y` | Verify in `.github/workflows/e2e-tests-split.yml` | +| 10 | No existing tests broken | All non-a11y CI jobs still pass | + +--- + +## 10. Risks and Mitigations + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| High number of initial violations blocks merge | High | Medium | Baseline mechanism (Section 6.5); populate before CI integration | +| axe scan flakiness in CI | Low | Medium | Retries (already configured: 2 in CI); `waitForLoadingComplete` before scans | +| Performance degradation of CI | Low | Low | ~10-30s additional per shard; well within 60min timeout | +| WebKit axe-core compatibility | Low | Low | axe-core is DOM-based, browser-agnostic; monitor for edge cases | +| Third-party component violations | Medium | Low | Global exclusions in fixture; documented in baseline | diff --git a/docs/plans/current_spec.md.bak3 b/docs/plans/current_spec.md.bak3 new file mode 100644 index 00000000..8c76ff60 --- /dev/null +++ b/docs/plans/current_spec.md.bak3 @@ -0,0 +1,897 @@ +# CrowdSec IP Whitelist Management — Implementation Plan + +**Issue**: [#939 — CrowdSec IP Whitelist Management](https://github.com/owner/Charon/issues/939) +**Date**: 2026-05-20 +**Status**: Draft — Awaiting Approval +**Priority**: High +**Archived Previous Plan**: Coverage Improvement Plan (patch coverage ≥ 90%) → `docs/plans/archive/patch-coverage-improvement-plan-2026-05-02.md` + +--- + +## 1. Introduction + +### 1.1 Overview + +CrowdSec enforces IP ban decisions by default. Operators need a way to permanently exempt known-good IPs (uptime monitors, internal subnets, VPN exits, partners) from ever being banned. CrowdSec handles this through its `whitelists` parser, which intercepts alert evaluation and suppresses bans for matching IPs/CIDRs before decisions are even written. + +This feature gives Charon operators a first-class UI for managing those whitelist entries: add an IP or CIDR, give it a reason, and have Charon persist it in the database, render the required YAML parser file into the CrowdSec config tree, and signal CrowdSec to reload—all without manual file editing. + +### 1.2 Objectives + +- Allow operators to add, view, and remove CrowdSec whitelist entries (IPs and CIDRs) through the Charon management UI. +- Persist entries in SQLite so they survive container restarts. +- Generate a `crowdsecurity/whitelists`-compatible YAML parser file on every mutating operation and on startup. +- Automatically install the `crowdsecurity/whitelists` hub parser so CrowdSec can process the file. +- Show the Whitelist tab only when CrowdSec is in `local` mode, consistent with other CrowdSec-only tabs. + +--- + +## 2. Research Findings + +### 2.1 Existing CrowdSec Architecture + +| Component | Location | Notes | +|---|---|---| +| Hub parser installer | `configs/crowdsec/install_hub_items.sh` | Run at container start; uses `cscli parsers install --force` | +| CrowdSec handler | `backend/internal/api/handlers/crowdsec_handler.go` | ~2750 LOC; `RegisterRoutes` at L2704 | +| Route registration | `backend/internal/api/routes/routes.go` | `crowdsecHandler.RegisterRoutes(management)` at ~L620 | +| CrowdSec startup | `backend/internal/services/crowdsec_startup.go` | `ReconcileCrowdSecOnStartup()` runs before process start | +| Security config | `backend/internal/models/security_config.go` | `CrowdSecMode`, `CrowdSecConfigDir` (via `cfg.Security.CrowdSecConfigDir`) | +| IP/CIDR helper | `backend/internal/security/whitelist.go` | `IsIPInCIDRList()` using `net.ParseIP` / `net.ParseCIDR` | +| AutoMigrate | `routes.go` ~L95–125 | `&models.ManualChallenge{}` is currently the last entry | + +### 2.2 Gap Analysis + +- `crowdsecurity/whitelists` hub parser is **not** installed by `install_hub_items.sh` — the YAML file would be ignored by CrowdSec without it. +- No `CrowdSecWhitelist` model exists in `backend/internal/models/`. +- No whitelist service, handler methods, or API routes exist. +- No frontend tab, API client functions, or TanStack Query hooks exist. +- No E2E test spec covers whitelist management. + +### 2.3 Relevant Patterns + +**Model pattern** (from `access_list.go` + `security_config.go`): +```go +type Model struct { + ID uint `json:"-" gorm:"primaryKey"` + UUID string `json:"uuid" gorm:"uniqueIndex;not null"` + // domain fields + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} +``` + +**Service pattern** (from `access_list_service.go`): +```go +var ErrXxxNotFound = errors.New("xxx not found") + +type XxxService struct { db *gorm.DB } + +func NewXxxService(db *gorm.DB) *XxxService { return &XxxService{db: db} } +``` + +**Handler error response pattern** (from `crowdsec_handler.go`): +```go +c.JSON(http.StatusBadRequest, gin.H{"error": "..."}) +c.JSON(http.StatusNotFound, gin.H{"error": "..."}) +c.JSON(http.StatusInternalServerError, gin.H{"error": "..."}) +``` + +**Frontend API client pattern** (from `frontend/src/api/crowdsec.ts`): +```typescript +export const listXxx = async (): Promise => { + const resp = await client.get('/admin/crowdsec/xxx') + return resp.data +} +``` + +**Frontend mutation pattern** (from `CrowdSecConfig.tsx`): +```typescript +const mutation = useMutation({ + mutationFn: (data) => apiCall(data), + onSuccess: () => { + toast.success('...') + queryClient.invalidateQueries({ queryKey: ['crowdsec-whitelist'] }) + }, + onError: (err) => toast.error(err instanceof Error ? err.message : '...'), +}) +``` + +### 2.4 CrowdSec Whitelist YAML Format + +CrowdSec's `crowdsecurity/whitelists` parser expects the following YAML structure at a path under the `parsers/s02-enrich/` directory: + +```yaml +name: charon-whitelist +description: "Charon-managed IP/CIDR whitelist" +filter: "evt.Meta.service == 'http'" +whitelist: + reason: "Charon managed whitelist" + ip: + - "1.2.3.4" + cidr: + - "10.0.0.0/8" + - "192.168.0.0/16" +``` + +For an empty whitelist, both `ip` and `cidr` must be present as empty lists (not omitted) to produce valid YAML that CrowdSec can parse without error. + +--- + +## 3. Technical Specifications + +### 3.1 Database Schema + +**New model**: `backend/internal/models/crowdsec_whitelist.go` + +```go +package models + +import "time" + +// CrowdSecWhitelist represents a single IP or CIDR exempted from CrowdSec banning. +type CrowdSecWhitelist struct { + ID uint `json:"-" gorm:"primaryKey"` + UUID string `json:"uuid" gorm:"uniqueIndex;not null"` + IPOrCIDR string `json:"ip_or_cidr" gorm:"not null;uniqueIndex"` + Reason string `json:"reason" gorm:"not null;default:''"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} +``` + +**AutoMigrate registration** (`backend/internal/api/routes/routes.go`, append after `&models.ManualChallenge{}`): +```go +&models.CrowdSecWhitelist{}, +``` + +### 3.2 API Design + +All new endpoints live under the existing `/api/v1` prefix and are registered inside `CrowdsecHandler.RegisterRoutes(rg *gin.RouterGroup)`, following the same `rg.METHOD("/admin/crowdsec/...")` naming pattern as every other CrowdSec endpoint. + +#### Endpoint Table + +| Method | Path | Auth | Description | +|---|---|---|---| +| `GET` | `/api/v1/admin/crowdsec/whitelist` | Management | List all whitelist entries | +| `POST` | `/api/v1/admin/crowdsec/whitelist` | Management | Add a new entry | +| `DELETE` | `/api/v1/admin/crowdsec/whitelist/:uuid` | Management | Remove an entry by UUID | + +#### `GET /admin/crowdsec/whitelist` + +**Response 200**: +```json +{ + "whitelist": [ + { + "uuid": "a1b2c3d4-...", + "ip_or_cidr": "10.0.0.0/8", + "reason": "Internal subnet", + "created_at": "2026-05-20T12:00:00Z", + "updated_at": "2026-05-20T12:00:00Z" + } + ] +} +``` + +#### `POST /admin/crowdsec/whitelist` + +**Request body**: +```json +{ "ip_or_cidr": "10.0.0.0/8", "reason": "Internal subnet" } +``` + +**Response 201**: +```json +{ + "uuid": "a1b2c3d4-...", + "ip_or_cidr": "10.0.0.0/8", + "reason": "Internal subnet", + "created_at": "...", + "updated_at": "..." +} +``` + +**Error responses**: +- `400` — missing/invalid `ip_or_cidr` field, unparseable IP/CIDR +- `409` — duplicate entry (same `ip_or_cidr` already exists) +- `500` — database or YAML write failure + +#### `DELETE /admin/crowdsec/whitelist/:uuid` + +**Response 204** — no body + +**Error responses**: +- `404` — entry not found +- `500` — database or YAML write failure + +### 3.3 Service Design + +**New file**: `backend/internal/services/crowdsec_whitelist_service.go` + +```go +package services + +import ( + "context" + "errors" + "net" + "os" + "path/filepath" + "text/template" + + "github.com/google/uuid" + "gorm.io/gorm" + + "github.com/yourusername/charon/backend/internal/models" + "github.com/yourusername/charon/backend/internal/logger" +) + +var ( + ErrWhitelistNotFound = errors.New("whitelist entry not found") + ErrInvalidIPOrCIDR = errors.New("invalid IP address or CIDR notation") + ErrDuplicateEntry = errors.New("whitelist entry already exists") +) + +type CrowdSecWhitelistService struct { + db *gorm.DB + dataDir string +} + +func NewCrowdSecWhitelistService(db *gorm.DB, dataDir string) *CrowdSecWhitelistService { + return &CrowdSecWhitelistService{db: db, dataDir: dataDir} +} + +// List returns all whitelist entries ordered by creation time. +func (s *CrowdSecWhitelistService) List(ctx context.Context) ([]models.CrowdSecWhitelist, error) { ... } + +// Add validates, persists, and regenerates the YAML file. +func (s *CrowdSecWhitelistService) Add(ctx context.Context, ipOrCIDR, reason string) (*models.CrowdSecWhitelist, error) { ... } + +// Delete removes an entry by UUID and regenerates the YAML file. +func (s *CrowdSecWhitelistService) Delete(ctx context.Context, uuid string) error { ... } + +// WriteYAML renders all current entries to /parsers/s02-enrich/charon-whitelist.yaml +func (s *CrowdSecWhitelistService) WriteYAML(ctx context.Context) error { ... } +``` + +**Validation logic** in `Add()`: +1. Trim whitespace from `ipOrCIDR`. +2. Attempt `net.ParseIP(ipOrCIDR)` — if non-nil, it's a bare IP ✓ +3. Attempt `net.ParseCIDR(ipOrCIDR)` — if `err == nil`, it's a valid CIDR ✓; normalize host bits immediately: `ipOrCIDR = network.String()` (e.g., `"10.0.0.1/8"` → `"10.0.0.0/8"`). +4. If both fail → return `ErrInvalidIPOrCIDR` +5. Attempt DB insert; if GORM unique constraint error → return `ErrDuplicateEntry` +6. On success → call `WriteYAML(ctx)` (non-fatal on YAML error: log + return original entry) + +> **Note**: `Add()` and `Delete()` do **not** call `cscli hub reload`. Reload is the caller's responsibility (handled in `CrowdsecHandler.AddWhitelist` and `DeleteWhitelist` via `h.CmdExec`). + +**CIDR normalization snippet** (step 3): +```go +if ip, network, err := net.ParseCIDR(ipOrCIDR); err == nil { + _ = ip + ipOrCIDR = network.String() // normalizes "10.0.0.1/8" → "10.0.0.0/8" +} +``` + +**YAML generation** in `WriteYAML()`: + +Guard: if `s.dataDir == ""`, return `nil` immediately (no-op — used in unit tests that don't need file I/O). + +```go +const whitelistTmpl = `name: charon-whitelist +description: "Charon-managed IP/CIDR whitelist" +filter: "evt.Meta.service == 'http'" +whitelist: + reason: "Charon managed whitelist" + ip: +{{- range .IPs}} + - "{{.}}" +{{- end}} +{{- if not .IPs}} + [] +{{- end}} + cidr: +{{- range .CIDRs}} + - "{{.}}" +{{- end}} +{{- if not .CIDRs}} + [] +{{- end}} +` +``` + +Target file path: `/config/parsers/s02-enrich/charon-whitelist.yaml` + +Directory created with `os.MkdirAll(..., 0o750)` if absent. + +File written atomically: render to `.tmp` → `os.Rename(tmp, path)`. + +### 3.4 Handler Design + +**Additions to `CrowdsecHandler` struct**: +```go +type CrowdsecHandler struct { + // ... existing fields ... + WhitelistSvc *services.CrowdSecWhitelistService // NEW +} +``` + +**`NewCrowdsecHandler` constructor** — initialize `WhitelistSvc`: +```go +h := &CrowdsecHandler{ + // ... existing assignments ... +} +if db != nil { + h.WhitelistSvc = services.NewCrowdSecWhitelistService(db, dataDir) +} +return h +``` + +**Three new methods on `CrowdsecHandler`**: + +```go +// ListWhitelists handles GET /admin/crowdsec/whitelist +func (h *CrowdsecHandler) ListWhitelists(c *gin.Context) { + entries, err := h.WhitelistSvc.List(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list whitelist entries"}) + return + } + c.JSON(http.StatusOK, gin.H{"whitelist": entries}) +} + +// AddWhitelist handles POST /admin/crowdsec/whitelist +func (h *CrowdsecHandler) AddWhitelist(c *gin.Context) { + var req struct { + IPOrCIDR string `json:"ip_or_cidr" binding:"required"` + Reason string `json:"reason"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "ip_or_cidr is required"}) + return + } + entry, err := h.WhitelistSvc.Add(c.Request.Context(), req.IPOrCIDR, req.Reason) + if errors.Is(err, services.ErrInvalidIPOrCIDR) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, services.ErrDuplicateEntry) { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add whitelist entry"}) + return + } + // Reload CrowdSec so the new entry takes effect immediately (non-fatal). + if reloadErr := h.CmdExec.Execute("cscli", "hub", "reload"); reloadErr != nil { + logger.Log().WithError(reloadErr).Warn("failed to reload CrowdSec after whitelist add (non-fatal)") + } + c.JSON(http.StatusCreated, entry) +} + +// DeleteWhitelist handles DELETE /admin/crowdsec/whitelist/:uuid +func (h *CrowdsecHandler) DeleteWhitelist(c *gin.Context) { + id := strings.TrimSpace(c.Param("uuid")) + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "uuid required"}) + return + } + err := h.WhitelistSvc.Delete(c.Request.Context(), id) + if errors.Is(err, services.ErrWhitelistNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "whitelist entry not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete whitelist entry"}) + return + } + // Reload CrowdSec so the removed entry is no longer exempt (non-fatal). + if reloadErr := h.CmdExec.Execute("cscli", "hub", "reload"); reloadErr != nil { + logger.Log().WithError(reloadErr).Warn("failed to reload CrowdSec after whitelist delete (non-fatal)") + } + c.Status(http.StatusNoContent) +} +``` + +**Route registration** (append inside `RegisterRoutes`, after existing decision/bouncer routes): +```go +// Whitelist management +rg.GET("/admin/crowdsec/whitelist", h.ListWhitelists) +rg.POST("/admin/crowdsec/whitelist", h.AddWhitelist) +rg.DELETE("/admin/crowdsec/whitelist/:uuid", h.DeleteWhitelist) +``` + +### 3.5 Startup Integration + +**File**: `backend/internal/services/crowdsec_startup.go` + +In `ReconcileCrowdSecOnStartup()`, before the CrowdSec process is started: + +```go +// Regenerate whitelist YAML to ensure it reflects the current DB state. +whitelistSvc := NewCrowdSecWhitelistService(db, dataDir) +if err := whitelistSvc.WriteYAML(ctx); err != nil { + logger.Log().WithError(err).Warn("failed to write CrowdSec whitelist YAML on startup (non-fatal)") +} +``` + +This is **non-fatal**: if the DB has no entries, WriteYAML still writes an empty whitelist file, which is valid. + +### 3.6 Hub Parser Installation + +**File**: `configs/crowdsec/install_hub_items.sh` + +Add after the existing `cscli parsers install` lines: + +```bash +cscli parsers install crowdsecurity/whitelists --force || echo "⚠️ Failed to install crowdsecurity/whitelists" +``` + +### 3.7 Frontend Design + +#### API Client (`frontend/src/api/crowdsec.ts`) + +Append the following types and functions: + +```typescript +export interface CrowdSecWhitelistEntry { + uuid: string + ip_or_cidr: string + reason: string + created_at: string + updated_at: string +} + +export interface AddWhitelistPayload { + ip_or_cidr: string + reason: string +} + +export const listWhitelists = async (): Promise => { + const resp = await client.get<{ whitelist: CrowdSecWhitelistEntry[] }>('/admin/crowdsec/whitelist') + return resp.data.whitelist +} + +export const addWhitelist = async (data: AddWhitelistPayload): Promise => { + const resp = await client.post('/admin/crowdsec/whitelist', data) + return resp.data +} + +export const deleteWhitelist = async (uuid: string): Promise => { + await client.delete(`/admin/crowdsec/whitelist/${uuid}`) +} +``` + +#### TanStack Query Hooks (`frontend/src/hooks/useCrowdSecWhitelist.ts`) + +```typescript +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { listWhitelists, addWhitelist, deleteWhitelist, AddWhitelistPayload } from '../api/crowdsec' +import { toast } from 'sonner' + +export const useWhitelistEntries = () => + useQuery({ + queryKey: ['crowdsec-whitelist'], + queryFn: listWhitelists, + }) + +export const useAddWhitelist = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (data: AddWhitelistPayload) => addWhitelist(data), + onSuccess: () => { + toast.success('Whitelist entry added') + queryClient.invalidateQueries({ queryKey: ['crowdsec-whitelist'] }) + }, + onError: (err: unknown) => { + toast.error(err instanceof Error ? err.message : 'Failed to add whitelist entry') + }, + }) +} + +export const useDeleteWhitelist = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (uuid: string) => deleteWhitelist(uuid), + onSuccess: () => { + toast.success('Whitelist entry removed') + queryClient.invalidateQueries({ queryKey: ['crowdsec-whitelist'] }) + }, + onError: (err: unknown) => { + toast.error(err instanceof Error ? err.message : 'Failed to remove whitelist entry') + }, + }) +} +``` + +#### CrowdSecConfig.tsx Changes + +The `CrowdSecConfig.tsx` page uses a tab navigation pattern. The new "Whitelist" tab: + +1. **Visibility**: Only render the tab when `isLocalMode === true` (same guard as Decisions tab). +2. **Tab value**: `"whitelist"` — append to the existing tab list. +3. **Tab panel content** (isolated component or inline JSX): + - **Add entry form**: `ip_or_cidr` text input + `reason` text input + "Add" button (disabled while `addMutation.isPending`). Validation error shown inline when backend returns 400/409. + - **Quick-add current IP**: A secondary "Add My IP" button that calls `GET /api/v1/system/my-ip` (existing endpoint) and pre-fills the `ip_or_cidr` field with the returned IP. + - **Entries table**: Columns — IP/CIDR, Reason, Added, Actions. Each row has a delete button with a confirmation dialog (matching the ban/unban modal pattern used for Decisions). + - **Empty state**: "No whitelist entries" message when the list is empty. + - **Loading state**: Skeleton rows while `useWhitelistEntries` is fetching. + +**Imports added to `CrowdSecConfig.tsx`**: +```typescript +import { useWhitelistEntries, useAddWhitelist, useDeleteWhitelist } from '../hooks/useCrowdSecWhitelist' +``` + +### 3.8 Data Flow Diagram + +``` +Operator adds IP in UI + │ + ▼ +POST /api/v1/admin/crowdsec/whitelist + │ + ▼ +CrowdsecHandler.AddWhitelist() + │ + ▼ +CrowdSecWhitelistService.Add() + ├── Validate IP/CIDR (net.ParseIP / net.ParseCIDR) + ├── Normalize CIDR host bits (network.String()) + ├── Insert into SQLite (models.CrowdSecWhitelist) + └── WriteYAML() → /config/parsers/s02-enrich/charon-whitelist.yaml + │ + ▼ +h.CmdExec.Execute("cscli", "hub", "reload") [non-fatal on error] + │ + ▼ +Return 201 to frontend + │ + ▼ +invalidateQueries(['crowdsec-whitelist']) + │ + ▼ +Table re-fetches and shows new entry +``` + +``` +Container restart + │ + ▼ +ReconcileCrowdSecOnStartup() + │ + ▼ +CrowdSecWhitelistService.WriteYAML() + └── Reads all DB entries → renders YAML + │ + ▼ +CrowdSec process starts + │ + ▼ +CrowdSec loads parsers/s02-enrich/charon-whitelist.yaml + └── crowdsecurity/whitelists parser activates + │ + ▼ +IPs/CIDRs in file are exempt from all ban decisions +``` + +### 3.9 Error Handling Matrix + +| Scenario | Service Error | HTTP Status | Frontend Behavior | +|---|---|---|---| +| Blank `ip_or_cidr` | — | 400 | Inline validation (required field) | +| Malformed IP/CIDR | `ErrInvalidIPOrCIDR` | 400 | Toast: "Invalid IP address or CIDR notation" | +| Duplicate entry | `ErrDuplicateEntry` | 409 | Toast: "This IP/CIDR is already whitelisted" | +| DB unavailable | generic error | 500 | Toast: "Failed to add whitelist entry" | +| UUID not found on DELETE | `ErrWhitelistNotFound` | 404 | Toast: "Whitelist entry not found" | +| YAML write failure | logged, non-fatal | 201 (Add still succeeds) | No user-facing error; log warning | +| CrowdSec reload failure | logged, non-fatal | 201/204 (operation still succeeds) | No user-facing error; log warning | + +### 3.10 Security Considerations + +- **Input validation**: All `ip_or_cidr` values are validated server-side with `net.ParseIP` / `net.ParseCIDR` before persisting. Arbitrary strings are rejected. +- **Path traversal**: `WriteYAML` constructs the output path via `filepath.Join(s.dataDir, "config", "parsers", "s02-enrich", "charon-whitelist.yaml")`. `dataDir` is set at startup—not user-supplied at request time. +- **Privilege**: All three endpoints require management-level access (same as all other CrowdSec endpoints). +- **YAML injection**: Values are rendered through Go's `text/template` with explicit quoting of each entry; no raw string concatenation. +- **Log safety**: IPs are logged using the same structured field pattern used in existing CrowdSec handler methods (e.g., `logger.Log().WithField("ip", entry.IPOrCIDR).Info(...)`). + +--- + +## 4. Implementation Plan + +### Phase 1 — Hub Parser Installation (Groundwork) + +**Files Changed**: +- `configs/crowdsec/install_hub_items.sh` + +**Task 1.1**: Add `cscli parsers install crowdsecurity/whitelists --force` after the last parser install line (currently `crowdsecurity/syslog-logs`). + +**Acceptance**: File change is syntactically valid bash; `shellcheck` passes. + +--- + +### Phase 2 — Database Model + +**Files Changed**: +- `backend/internal/models/crowdsec_whitelist.go` _(new file)_ +- `backend/internal/api/routes/routes.go` _(append to AutoMigrate call)_ + +**Task 2.1**: Create `crowdsec_whitelist.go` with the `CrowdSecWhitelist` struct per §3.1. + +**Task 2.2**: Append `&models.CrowdSecWhitelist{}` to the `db.AutoMigrate(...)` call in `routes.go`. + +**Validation Gate**: `go build ./backend/...` passes; GORM generates `crowdsec_whitelists` table on next startup. + +--- + +### Phase 3 — Whitelist Service + +**Files Changed**: +- `backend/internal/services/crowdsec_whitelist_service.go` _(new file)_ + +**Task 3.1**: Implement `CrowdSecWhitelistService` with `List`, `Add`, `Delete`, `WriteYAML` per §3.3. + +**Task 3.2**: Implement IP/CIDR validation in `Add()`: +- `net.ParseIP(ipOrCIDR) != nil` → valid bare IP +- `net.ParseCIDR(ipOrCIDR)` returns no error → valid CIDR +- Both fail → `ErrInvalidIPOrCIDR` + +**Task 3.3**: Implement `WriteYAML()`: +- Query all entries from DB. +- Partition into `ips` (bare IPs) and `cidrs` (CIDR notation) slices. +- Render template per §2.4. +- Atomic write: temp file → `os.Rename`. +- Create directory (`os.MkdirAll`) if not present. + +**Validation Gate**: `go test ./backend/internal/services/... -run TestCrowdSecWhitelist` passes. + +--- + +### Phase 4 — API Endpoints + +**Files Changed**: +- `backend/internal/api/handlers/crowdsec_handler.go` + +**Task 4.1**: Add `WhitelistSvc *services.CrowdSecWhitelistService` field to `CrowdsecHandler` struct. + +**Task 4.2**: Initialize `WhitelistSvc` in `NewCrowdsecHandler()` when `db != nil`. + +**Task 4.3**: Implement `ListWhitelists`, `AddWhitelist`, `DeleteWhitelist` methods per §3.4. + +**Task 4.4**: Register three routes in `RegisterRoutes()` per §3.4. + +**Task 4.5**: In `AddWhitelist` and `DeleteWhitelist`, after the service call returns without error, call `h.CmdExec.Execute("cscli", "hub", "reload")`. Log a warning on failure; do not change the HTTP response status (reload failure is non-fatal). + +**Validation Gate**: `go test ./backend/internal/api/handlers/... -run TestWhitelist` passes; `make lint-fast` clean. + +--- + +### Phase 5 — Startup Integration + +**Files Changed**: +- `backend/internal/services/crowdsec_startup.go` + +**Task 5.1**: In `ReconcileCrowdSecOnStartup()`, after the DB and config are loaded but before calling `h.Executor.Start()`, instantiate `CrowdSecWhitelistService` and call `WriteYAML(ctx)`. Log warning on error; do not abort startup. + +**Validation Gate**: `go test ./backend/internal/services/... -run TestReconcile` passes; existing reconcile tests still pass. + +--- + +### Phase 6 — Frontend API + Hooks + +**Files Changed**: +- `frontend/src/api/crowdsec.ts` +- `frontend/src/hooks/useCrowdSecWhitelist.ts` _(new file)_ + +**Task 6.1**: Add `CrowdSecWhitelistEntry`, `AddWhitelistPayload` types and `listWhitelists`, `addWhitelist`, `deleteWhitelist` functions to `crowdsec.ts` per §3.7. + +**Task 6.2**: Create `useCrowdSecWhitelist.ts` with `useWhitelistEntries`, `useAddWhitelist`, `useDeleteWhitelist` hooks per §3.7. + +**Validation Gate**: `pnpm test` (Vitest) passes; TypeScript compilation clean. + +--- + +### Phase 7 — Frontend UI + +**Files Changed**: +- `frontend/src/pages/CrowdSecConfig.tsx` + +**Task 7.1**: Import the three hooks from `useCrowdSecWhitelist.ts`. + +**Task 7.2**: Add `"whitelist"` to the tab list (visible only when `isLocalMode === true`). + +**Task 7.3**: Implement the Whitelist tab panel: +- Add-entry form with IP/CIDR + Reason inputs. +- "Add My IP" button: `GET /api/v1/system/my-ip` → pre-fill `ip_or_cidr`. +- Entries table with UUID key, IP/CIDR, Reason, created date, delete button. +- Delete confirmation dialog (reuse existing modal pattern). + +**Task 7.4**: Wire mutation errors to inline form validation messages (400/409 responses). + +**Validation Gate**: `pnpm test` passes; TypeScript clean; `make lint-fast` clean. + +--- + +### Phase 8 — Tests + +**Files Changed**: +- `backend/internal/services/crowdsec_whitelist_service_test.go` _(new file)_ +- `backend/internal/api/handlers/crowdsec_whitelist_handler_test.go` _(new file)_ +- `tests/crowdsec-whitelist.spec.ts` _(new file)_ + +**Task 8.1 — Service unit tests**: + +| Test | Scenario | +|---|---| +| `TestAdd_ValidIP_Success` | Bare IPv4 inserted; YAML file created | +| `TestAdd_ValidIPv6_Success` | Bare IPv6 inserted | +| `TestAdd_ValidCIDR_Success` | CIDR range inserted | +| `TestAdd_CIDRNormalization` | `"10.0.0.1/8"` stored as `"10.0.0.0/8"` | +| `TestAdd_InvalidIPOrCIDR_Error` | Returns `ErrInvalidIPOrCIDR` | +| `TestAdd_DuplicateEntry_Error` | Second identical insert returns `ErrDuplicateEntry` | +| `TestDelete_Success` | Entry removed; YAML regenerated | +| `TestDelete_NotFound_Error` | Returns `ErrWhitelistNotFound` | +| `TestList_Empty` | Returns empty slice | +| `TestList_Populated` | Returns all entries ordered by `created_at` | +| `TestWriteYAML_EmptyList` | Writes valid YAML with empty `ip: []` and `cidr: []` | +| `TestWriteYAML_MixedEntries` | IPs in `ip:` block; CIDRs in `cidr:` block | +| `TestWriteYAML_EmptyDataDir_NoOp` | `dataDir == ""` → returns `nil`, no file written | + +**Task 8.2 — Handler unit tests** (using in-memory SQLite + `mockAuthMiddleware`): + +| Test | Scenario | +|---|---| +| `TestListWhitelists_200` | Returns 200 with entries array | +| `TestAddWhitelist_201` | Valid payload → 201 | +| `TestAddWhitelist_400_MissingField` | Empty body → 400 | +| `TestAddWhitelist_400_InvalidIP` | Malformed IP → 400 | +| `TestAddWhitelist_409_Duplicate` | Duplicate → 409 | +| `TestDeleteWhitelist_204` | Valid UUID → 204 | +| `TestDeleteWhitelist_404` | Unknown UUID → 404 | + +**Task 8.3 — E2E Playwright tests** (`tests/crowdsec-whitelist.spec.ts`): + +```typescript +import { test, expect } from '@playwright/test' + +test.describe('CrowdSec Whitelist Management', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:8080') + await page.getByRole('link', { name: 'Security' }).click() + await page.getByRole('tab', { name: 'CrowdSec' }).click() + await page.getByRole('tab', { name: 'Whitelist' }).click() + }) + + test('Whitelist tab only visible in local mode', async ({ page }) => { + await page.goto('http://localhost:8080') + await page.getByRole('link', { name: 'Security' }).click() + await page.getByRole('tab', { name: 'CrowdSec' }).click() + // When CrowdSec is not in local mode, the Whitelist tab must not exist + await expect(page.getByRole('tab', { name: 'Whitelist' })).toBeHidden() + }) + + test('displays empty state when no entries exist', async ({ page }) => { + await expect(page.getByText('No whitelist entries')).toBeVisible() + }) + + test('adds a valid IP address', async ({ page }) => { + await page.getByRole('textbox', { name: 'IP or CIDR' }).fill('203.0.113.5') + await page.getByRole('textbox', { name: 'Reason' }).fill('Uptime monitor') + await page.getByRole('button', { name: 'Add' }).click() + await expect(page.getByText('Whitelist entry added')).toBeVisible() + await expect(page.getByRole('cell', { name: '203.0.113.5' })).toBeVisible() + }) + + test('adds a valid CIDR range', async ({ page }) => { + await page.getByRole('textbox', { name: 'IP or CIDR' }).fill('10.0.0.0/8') + await page.getByRole('textbox', { name: 'Reason' }).fill('Internal subnet') + await page.getByRole('button', { name: 'Add' }).click() + await expect(page.getByText('Whitelist entry added')).toBeVisible() + await expect(page.getByRole('cell', { name: '10.0.0.0/8' })).toBeVisible() + }) + + test('"Add My IP" button pre-fills the detected client IP', async ({ page }) => { + await page.getByRole('button', { name: 'Add My IP' }).click() + const ipField = page.getByRole('textbox', { name: 'IP or CIDR' }) + const value = await ipField.inputValue() + // Value must be a non-empty valid IP + expect(value).toMatch(/^[\d.]+$|^[0-9a-fA-F:]+$/) + }) + + test('shows validation error for invalid input', async ({ page }) => { + await page.getByRole('textbox', { name: 'IP or CIDR' }).fill('not-an-ip') + await page.getByRole('button', { name: 'Add' }).click() + await expect(page.getByText('Invalid IP address or CIDR notation')).toBeVisible() + }) + + test('removes an entry via delete confirmation', async ({ page }) => { + // Seed an entry first + await page.getByRole('textbox', { name: 'IP or CIDR' }).fill('198.51.100.1') + await page.getByRole('button', { name: 'Add' }).click() + await expect(page.getByRole('cell', { name: '198.51.100.1' })).toBeVisible() + + // Delete it + await page.getByRole('row', { name: /198\.51\.100\.1/ }).getByRole('button', { name: 'Delete' }).click() + await page.getByRole('button', { name: 'Confirm' }).click() + await expect(page.getByText('Whitelist entry removed')).toBeVisible() + await expect(page.getByRole('cell', { name: '198.51.100.1' })).toBeHidden() + }) +}) +``` + +--- + +### Phase 9 — Documentation + +**Files Changed**: +- `ARCHITECTURE.md` +- `docs/features/crowdsec-whitelist.md` _(new file, optional for this PR)_ + +**Task 9.1**: Update the CrowdSec row in the Cerberus security components table in `ARCHITECTURE.md` to mention whitelist management. + +--- + +## 5. Acceptance Criteria + +### Functional + +- [ ] Operator can add a bare IPv4 address (e.g., `203.0.113.5`) to the whitelist. +- [ ] Operator can add a bare IPv6 address (e.g., `2001:db8::1`) to the whitelist. +- [ ] Operator can add a CIDR range (e.g., `10.0.0.0/8`) to the whitelist. +- [ ] Adding an invalid IP/CIDR (e.g., `not-an-ip`) returns a 400 error with a clear message. +- [ ] Adding a duplicate entry returns a 409 conflict error. +- [ ] Operator can delete an entry; it disappears from the list. +- [ ] The Whitelist tab is only visible when CrowdSec is in `local` mode. +- [ ] After adding or deleting an entry, the whitelist YAML file is regenerated in `/config/parsers/s02-enrich/charon-whitelist.yaml`. +- [ ] Adding or removing a whitelist entry triggers `cscli hub reload` via `h.CmdExec` so changes take effect immediately without a container restart. +- [ ] On container restart, the YAML file is regenerated from DB entries before CrowdSec starts. +- [ ] **Admin IP protection**: The "Add My IP" button pre-fills the operator's current IP in the `ip_or_cidr` field; a Playwright E2E test verifies the button correctly pre-fills the detected client IP. + +### Technical + +- [ ] `go test ./backend/...` passes — no regressions. +- [ ] `pnpm test` (Vitest) passes. +- [ ] `make lint-fast` clean — no new lint findings. +- [ ] GORM Security Scanner returns zero CRITICAL/HIGH findings. +- [ ] Playwright E2E suite passes (Firefox, `--project=firefox`). +- [ ] `crowdsecurity/whitelists` parser is installed by `install_hub_items.sh`. + +--- + +## 6. Commit Slicing Strategy + +**Decision**: Single PR with ordered logical commits. No scope overlap between commits; each commit leaves the codebase in a compilable state. + +**Trigger reasons**: Cross-domain change (infra script + model + service + handler + startup + frontend) benefits from ordered commits for surgical rollback and focused review. + +| # | Type | Commit Message | Files | Depends On | Validation Gate | +|---|---|---|---|---|---| +| 1 | `chore` | `install crowdsecurity/whitelists parser by default` | `configs/crowdsec/install_hub_items.sh` | — | `shellcheck` | +| 2 | `feat` | `add CrowdSecWhitelist model and automigrate registration` | `backend/internal/models/crowdsec_whitelist.go`, `backend/internal/api/routes/routes.go` | #1 | `go build ./backend/...` | +| 3 | `feat` | `add CrowdSecWhitelistService with YAML generation` | `backend/internal/services/crowdsec_whitelist_service.go` | #2 | `go test ./backend/internal/services/...` | +| 4 | `feat` | `add whitelist API endpoints to CrowdsecHandler` | `backend/internal/api/handlers/crowdsec_handler.go` | #3 | `go test ./backend/...` + `make lint-fast` | +| 5 | `feat` | `regenerate whitelist YAML on CrowdSec startup reconcile` | `backend/internal/services/crowdsec_startup.go` | #3 | `go test ./backend/internal/services/...` | +| 6 | `feat` | `add whitelist API client functions and TanStack hooks` | `frontend/src/api/crowdsec.ts`, `frontend/src/hooks/useCrowdSecWhitelist.ts` | #4 | `pnpm test` | +| 7 | `feat` | `add Whitelist tab to CrowdSecConfig UI` | `frontend/src/pages/CrowdSecConfig.tsx` | #6 | `pnpm test` + `make lint-fast` | +| 8 | `test` | `add whitelist service and handler unit tests` | `*_test.go` files | #4 | `go test ./backend/...` | +| 9 | `test` | `add E2E tests for CrowdSec whitelist management` | `tests/crowdsec-whitelist.spec.ts` | #7 | Playwright Firefox | +| 10 | `docs` | `update architecture docs for CrowdSec whitelist feature` | `ARCHITECTURE.md` | #7 | `make lint-fast` | + +**Rollback notes**: +- Commits 1–3 are pure additions (no existing code modified except the `AutoMigrate` list append in commit 2 and `install_hub_items.sh` in commit 1). Reverting them is safe. +- Commit 4 modifies `crowdsec_handler.go` by adding fields and methods without altering existing ones; reverting is mechanical. +- Commit 5 modifies `crowdsec_startup.go` — the added block is isolated in a clearly marked section; revert is a 5-line removal. +- Commits 6–7 are frontend-only; reverting has no backend impact. + +--- + +## 7. Open Questions / Risks + +| Risk | Likelihood | Mitigation | +|---|---|---| +| CrowdSec does not hot-reload parser files — requires `cscli reload` or process restart | Resolved | `cscli hub reload` is called via `h.CmdExec.Execute(...)` in `AddWhitelist` and `DeleteWhitelist` after each successful `WriteYAML()`. Failure is non-fatal; logged as a warning. | +| `crowdsecurity/whitelists` parser path may differ across CrowdSec versions | Low | Use `/config/parsers/s02-enrich/` which is the canonical path; add a note to verify on version upgrades | +| Large whitelist files could cause CrowdSec performance issues | Very Low | Reasonable for typical use; document a soft limit recommendation (< 500 entries) in the UI | +| `dataDir` empty string in tests | Resolved | Guard added to `WriteYAML`: `if s.dataDir == "" { return nil }` — no-op when `dataDir` is unset | +| `CROWDSEC_TRUSTED_IPS` env var seeding | — | **Follow-up / future enhancement** (not in scope for this PR): if `CROWDSEC_TRUSTED_IPS` is set at runtime, parse comma-separated IPs and include them as read-only seed entries in the generated YAML (separate from DB-managed entries). Document in a follow-up issue. | +