`, `| `, ` | ` with proper headers. Sort controls use `aria-sort`.
-- **Time Range Selector:** Uses Radix Tabs pattern (roving tabindex, arrow key navigation).
-- **Color:** All chart colors meet WCAG 2.2 AA contrast (4.5:1 minimum). Patterns/shapes supplement color for colorblind users.
-- **Keyboard:** All interactive elements (tabs, buttons, table rows) keyboard navigable. Focus indicators visible.
-- **Screen Reader:** Summary cards use `aria-live="polite"` for dynamic updates when time range changes.
-
----
-
-## 6. Implementation Phases
-
-### Phase 1 / PR-1: Backend Aggregation APIs + Model Enrichment
-
-**Scope:** Backend only. No frontend changes.
-
-**Files to create:**
-
-| File | Description |
-|------|-------------|
-| `backend/internal/api/handlers/crowdsec_dashboard.go` | New handler file with all 6 dashboard/export endpoints |
-| `backend/internal/api/handlers/crowdsec_dashboard_cache.go` | In-memory cache with TTL |
-| `backend/internal/api/handlers/crowdsec_dashboard_test.go` | Unit tests for all new endpoints |
-
-**Files to modify:**
-
-| File | Change |
-|------|--------|
-| `backend/internal/models/security_decision.go` | Add `Scenario`, `Country`, `ExpiresAt` fields |
-| `backend/internal/api/handlers/crowdsec_handler.go` | In `RegisterRoutes`, add 6 new route registrations |
-| `backend/internal/api/handlers/crowdsec_handler.go` | In `BanIP`, after successful `cscli decisions add`, call `h.Security.LogDecision()` to persist a `SecurityDecision` record with `Source: "crowdsec"`, `Action: "block"`, `Scenario: "manual"`, `ExpiresAt` computed from the duration parameter, and `Country: ""` (empty for manual bans). This ensures manual bans appear in dashboard aggregations. |
-| `backend/internal/api/handlers/crowdsec_handler.go` | In `UnbanIP`, after successful unban, call `h.dashCache.Invalidate("dashboard")` to clear cached dashboard data |
-
-**New Route Registrations (in `RegisterRoutes`):**
-
-```go
-// Dashboard analytics endpoints (Issue #26)
-rg.GET("/admin/crowdsec/dashboard/summary", h.DashboardSummary)
-rg.GET("/admin/crowdsec/dashboard/timeline", h.DashboardTimeline)
-rg.GET("/admin/crowdsec/dashboard/top-ips", h.DashboardTopIPs)
-rg.GET("/admin/crowdsec/dashboard/scenarios", h.DashboardScenarios)
-rg.GET("/admin/crowdsec/alerts", h.ListAlerts)
-rg.GET("/admin/crowdsec/decisions/export", h.ExportDecisions)
-```
-
-**Key Functions:**
-
-```go
-func (h *CrowdsecHandler) DashboardSummary(c *gin.Context)
-func (h *CrowdsecHandler) DashboardTimeline(c *gin.Context)
-func (h *CrowdsecHandler) DashboardTopIPs(c *gin.Context)
-func (h *CrowdsecHandler) DashboardScenarios(c *gin.Context)
-func (h *CrowdsecHandler) ListAlerts(c *gin.Context)
-func (h *CrowdsecHandler) ExportDecisions(c *gin.Context)
-func parseTimeRange(rangeStr string) (time.Time, error)
-func (c *dashboardCache) Get(key string) (interface{}, bool)
-func (c *dashboardCache) Set(key string, data interface{}, ttl time.Duration)
-func (c *dashboardCache) Invalidate(prefixes ...string)
-```
-
-**Database Indexes:** GORM will create single-column indexes via struct tags and composite indexes via `compositeIndex` tags (see Section 3.3). Verify after migration:
-
-- **Single-column:** `idx_security_decisions_scenario`, `idx_security_decisions_country`, `idx_security_decisions_expires_at`
-- **Composite:** `idx_sd_source_created`, `idx_sd_source_scenario_created`, `idx_sd_source_ip_created`
-
-See Appendix C for verification queries.
-
-**Acceptance Criteria:**
-
-- [ ] All 6 endpoints return valid JSON with correct schemas
-- [ ] `parseTimeRange` rejects invalid inputs (returns 400)
-- [ ] Aggregation queries use parameterized GORM (no raw SQL concatenation)
-- [ ] Cache returns stale data within TTL, fresh data after TTL expires
-- [ ] Export produces valid CSV with proper escaping and JSON
-- [ ] Alerts endpoint falls back to `cscli alerts list` when LAPI unreachable
-- [ ] No LAPI keys in any response body
-- [ ] Unit test coverage ≥ 85%
-- [ ] GORM security scanner passes with 0 CRITICAL/HIGH
-
----
-
-### Phase 2 / PR-2: Frontend Dashboard Page with Charts
-
-**Scope:** Frontend only (depends on PR-1 being merged).
-
-**Files to create:**
-
-| File | Description |
-|------|-------------|
-| `frontend/src/pages/CrowdSecDashboard.tsx` | Dashboard tab orchestrator |
-| `frontend/src/components/crowdsec/DashboardSummaryCards.tsx` | 4 stat cards |
-| `frontend/src/components/crowdsec/BanTimelineChart.tsx` | Recharts AreaChart |
-| `frontend/src/components/crowdsec/TopAttackingIPsChart.tsx` | Recharts BarChart |
-| `frontend/src/components/crowdsec/ScenarioBreakdownChart.tsx` | Recharts PieChart |
-| `frontend/src/components/crowdsec/ActiveDecisionsTable.tsx` | Enhanced decisions table |
-| `frontend/src/components/crowdsec/DashboardTimeRangeSelector.tsx` | Time range toggle |
-| `frontend/src/api/crowdsecDashboard.ts` | API client functions |
-| `frontend/src/hooks/useCrowdsecDashboard.ts` | React Query hooks |
-| `frontend/src/components/crowdsec/__tests__/DashboardSummaryCards.test.tsx` | Unit tests |
-| `frontend/src/components/crowdsec/__tests__/BanTimelineChart.test.tsx` | Unit tests |
-| `frontend/src/components/crowdsec/__tests__/TopAttackingIPsChart.test.tsx` | Unit tests |
-| `frontend/src/components/crowdsec/__tests__/ScenarioBreakdownChart.test.tsx` | Unit tests |
-| `frontend/src/components/crowdsec/__tests__/ActiveDecisionsTable.test.tsx` | Unit tests |
-| `frontend/src/hooks/__tests__/useCrowdsecDashboard.test.ts` | Hook tests |
-| `tests/security/crowdsec-dashboard.spec.ts` | Playwright E2E tests (new file, follows existing `tests/security/crowdsec-*.spec.ts` convention) |
-
-**Files to modify:**
-
-| File | Change |
-|------|--------|
-| `frontend/package.json` | Add `recharts` dependency |
-| `frontend/src/pages/CrowdSecConfig.tsx` | Wrap content in Radix Tabs; add Dashboard tab |
-
-**Acceptance Criteria:**
-
-- [ ] Dashboard tab renders when CrowdSec page visited and tab clicked
-- [ ] All 4 summary cards display data from `/dashboard/summary`
-- [ ] Timeline chart renders with Recharts AreaChart
-- [ ] Top IPs chart renders as horizontal BarChart
-- [ ] Scenario breakdown renders as PieChart/DonutChart
-- [ ] Time range selector switches all charts simultaneously
-- [ ] Loading states show skeletons (consistent with existing patterns)
-- [ ] Error states show user-friendly messages
-- [ ] Vitest unit test coverage ≥ 85%
-- [ ] Playwright E2E tests pass on Firefox, Chromium, WebKit
-- [ ] All interactive elements keyboard navigable
-- [ ] Chart colors meet WCAG 2.2 AA contrast requirements
-- [ ] Tab labels use i18n: `t('crowdsec.tabs.config')` and `t('crowdsec.tabs.dashboard')` with keys added to all locale files
-- [ ] `CrowdSecDashboard` is loaded via `React.lazy()` with `` fallback to avoid loading Recharts bundle on the Configuration tab
-
----
-
-### Phase 3 / PR-3: Alerts Feed, Enhanced Notifications, Ban Export
-
-**Scope:** Frontend alerts UI + notification enrichment + export functionality.
-
-**Files to create:**
-
-| File | Description |
-|------|-------------|
-| `frontend/src/components/crowdsec/AlertsList.tsx` | Alerts feed with filters |
-| `frontend/src/components/crowdsec/DecisionsExportButton.tsx` | Export dropdown (CSV/JSON) — moved from PR-2 for coherent slicing (button + tests in same PR) |
-| `frontend/src/components/crowdsec/__tests__/AlertsList.test.tsx` | Unit tests |
-| `frontend/src/components/crowdsec/__tests__/DecisionsExportButton.test.tsx` | Unit tests |
-
-**Files to modify:**
-
-| File | Change |
-|------|--------|
-| `frontend/src/pages/CrowdSecDashboard.tsx` | Add AlertsList component |
-| `backend/internal/services/enhanced_security_notification_service.go` | Enrich `crowdsec_decision` payload with scenario and structured data |
-
-**Notification Enrichment:** Currently the notification service dispatches a generic event for `crowdsec_decision`. This PR enriches the payload to include scenario, IP, action, and duration in a structured format.
-
-**Acceptance Criteria:**
-
-- [ ] Alerts list displays LAPI alerts with scenario, IP, events count, timestamps
-- [ ] Alerts list supports filtering by scenario
-- [ ] Export button downloads valid CSV or JSON file
-- [ ] Export filename includes timestamp
-- [ ] Notification payload includes scenario name and structured data
-- [ ] E2E tests cover export flow and alerts display
-- [ ] Unit test coverage ≥ 85%
-
----
-
-## 7. Testing Strategy
-
-### 7.1 Backend Unit Tests
-
-**File:** `backend/internal/api/handlers/crowdsec_dashboard_test.go`
-
-| Test | What It Validates |
-|------|-------------------|
-| `TestDashboardSummary_EmptyDB` | Returns zero counts when no decisions exist |
-| `TestDashboardSummary_WithData` | Returns correct aggregates for seeded data |
-| `TestDashboardSummary_InvalidRange` | Returns 400 for unsupported range values |
-| `TestDashboardSummary_CacheHit` | Second call returns cached data without DB query |
-| `TestDashboardTimeline_Buckets` | Correct time bucketing for 24h/1h interval |
-| `TestDashboardTimeline_EmptyRange` | Returns empty buckets array |
-| `TestDashboardTopIPs_Ranking` | IPs ordered by count descending |
-| `TestDashboardTopIPs_LimitCap` | Limit parameter capped at 50 |
-| `TestDashboardScenarios_Breakdown` | Correct percentage calculation |
-| `TestListAlerts_LAPISuccess` | Parses LAPI response correctly |
-| `TestListAlerts_LAPIFallback` | Falls back to cscli on LAPI failure |
-| `TestExportDecisions_CSV` | Valid CSV output with headers |
-| `TestExportDecisions_JSON` | Valid JSON array output |
-| `TestExportDecisions_InvalidFormat` | Returns 400 for unsupported format |
-| `TestParseTimeRange_Valid` | Accepts 1h, 6h, 24h, 7d, 30d |
-| `TestParseTimeRange_Invalid` | Rejects arbitrary strings, negative values |
-| `TestDashboardCache_TTL` | Entries expire after configured TTL |
-| `TestDashboardCache_Invalidation` | Cache cleared on BanIP/UnbanIP |
-
-**Test Fixtures:** Use GORM's `sqlite://file::memory:?cache=shared` in-memory DB with seeded `SecurityDecision` records spanning multiple scenarios, IPs, and time ranges.
-
-### 7.2 Frontend Unit Tests (Vitest)
-
-| Test File | Key Tests |
-|-----------|-----------|
-| `DashboardSummaryCards.test.tsx` | Renders 4 cards, shows loading skeleton, handles error |
-| `BanTimelineChart.test.tsx` | Renders Recharts AreaChart, handles empty data |
-| `TopAttackingIPsChart.test.tsx` | Renders BarChart, shows correct IP labels |
-| `ScenarioBreakdownChart.test.tsx` | Renders PieChart, shows percentages |
-| `ActiveDecisionsTable.test.tsx` | Renders table rows, sorts by column |
-| `AlertsList.test.tsx` | Renders alert items, filters by scenario |
-| `DecisionsExportButton.test.tsx` | Triggers correct API call for CSV/JSON |
-| `useCrowdsecDashboard.test.ts` | Hooks return data, handle loading/error states |
-
-**Mocking:** Use `vi.mock('../api/crowdsecDashboard')` with typed mock data matching the API schemas defined in Section 4.
-
-### 7.3 E2E Tests (Playwright)
-
-**File:** `tests/security/crowdsec-dashboard.spec.ts`
-
-```typescript
-test.describe('CrowdSec Dashboard', () => {
- test.beforeEach(async ({ page }) => {
- await page.goto('/security/crowdsec')
- })
-
- test('Dashboard tab is visible and clickable', async ({ page }) => {
- await test.step('Navigate to dashboard tab', async () => {
- await page.getByRole('tab', { name: 'Dashboard' }).click()
- })
- await test.step('Verify summary cards are visible', async () => {
- await expect(page.getByTestId('dashboard-summary-cards')).toBeVisible()
- })
- })
-
- test('Time range selector updates all charts', async ({ page }) => {
- // Click Dashboard tab, select 7d range, verify charts re-render
- })
-
- test('Export button downloads CSV file', async ({ page }) => {
- // Click Dashboard tab, click Export, select CSV, verify download
- })
-
- test('Active decisions table displays data', async ({ page }) => {
- // Verify table headers and at least one row (requires seed data or ban)
- })
-})
-```
-
-**Test Data:** E2E tests run against the Docker container which has CrowdSec running. Tests that require specific decision data should use the existing `POST /admin/crowdsec/ban` endpoint to create test bans. Because `BanIP` now calls `SecurityService.LogDecision()` (see B1 fix), these bans will be persisted as `SecurityDecision` records and will appear in dashboard aggregation queries. Seed at least 3 bans with distinct IPs and the default duration to populate summary cards and top-IPs chart.
-
-### 7.4 Test Execution Order
-
-1. **GORM Security Scanner** — Before any commit (manual stage, PR-1 only)
-2. **Backend unit tests** — `cd backend && go test ./internal/api/handlers/ -run TestDashboard -v -count=1`
-3. **Frontend unit tests** — `cd frontend && npm run test`
-4. **E2E Playwright** — Via task `Test: E2E Playwright (FireFox) - Security Suite`
-
----
-
-## 8. Commit Slicing Strategy
-
-### Decision: 3 PRs (Multi-PR)
-
-**Trigger reasons:**
-
-- Cross-domain changes (backend + frontend + new dependency)
-- Risk isolation (new dependency in separate PR aids rollback)
-- Review size (estimated ~2500 lines total; 800-1000 per PR is reviewable)
-- Testing gates (backend APIs must exist before frontend can E2E test against them)
-
-### PR Slices
-
-| Slice | Scope | Files (est.) | Dependencies | Validation Gate |
-|-------|-------|-------------|--------------|-----------------|
-| **PR-1** | Backend: model enrichment + 6 API endpoints + cache + unit tests | ~800 LOC across 3 new files + 2 modified | None | Backend unit tests pass ≥ 85% coverage; GORM scanner clean |
-| **PR-2** | Frontend: Recharts dependency + dashboard page + charts + Vitest + E2E (no export button) | ~1100 LOC across 11 new files + 2 modified | PR-1 merged | Vitest ≥ 85% coverage; E2E pass all browsers |
-| **PR-3** | Alerts feed + notification enrichment + export button + export UI | ~600 LOC across 4 new files + 2 modified | PR-2 merged | Unit + E2E pass; export produces valid files |
-
-### Rollback & Contingency
-
-- **PR-1 rollback:** Drop new columns via migration or tolerate empty columns (GORM ignores unknown columns). Remove new routes.
-- **PR-2 rollback:** Revert frontend changes. Remove `recharts` from `package.json`. The backend endpoints remain harmlessly unused.
-- **PR-3 rollback:** Revert frontend components. Notification changes are backward-compatible (extra fields in payload are ignored by existing formatters).
-
----
-
-## 9. Risk Assessment
-
-| Risk | Likelihood | Impact | Mitigation |
-|------|-----------|--------|------------|
-| **LAPI unavailable** when CrowdSec stopped | High | Dashboard shows incomplete data | All endpoints return graceful empty states; summary uses SQLite-only data; alerts endpoint falls back to cscli |
-| **SQLite performance** on large `security_decisions` tables | Medium | Slow dashboard loads | Indexed columns, time-range scoping, 30s cache TTL, LIMIT clauses |
-| **Recharts bundle size** increases frontend load | Low | Slower initial page load | Tree-shaking (only import used charts), code-split dashboard tab via `React.lazy` |
-| **LAPI API changes** between CrowdSec versions | Low | Parsing failures | Defensive parsing with fallback to cscli; version-agnostic field access |
-| **Country lookup accuracy** | Medium | Inaccurate country data | Country field is best-effort from LAPI's `source.cn` field; clearly labeled as approximate |
-| **SSRF via LAPI URL** | Low | Internal network scanning | Reuse existing `validateCrowdsecLAPIBaseURL` with internal-only allowlist |
-| **Sensitive IP data exposure** | Medium | Privacy concern | All dashboard endpoints behind admin auth; no public access; export requires admin role |
-
----
-
-## 10. Out of Scope
-
-| Item | Rationale |
-|------|-----------|
-| **Full CrowdSec management clone** (à la crowdsec_manager) | Charon embeds CrowdSec as one module, not a standalone manager |
-| **Hub browser expansion** | Existing preset system is sufficient; hub browsing is a separate feature |
-| **Real-time SSE/WebSocket decision streaming** | Adds significant complexity; polling with 30s cache is adequate for v1 |
-| **GeoIP map visualization** | Requires map rendering library + GeoIP database; future enhancement |
-| **CrowdSec Traefik/Nginx integration** | Charon uses Caddy only |
-| **Custom scenario creation** | Users manage scenarios via CrowdSec Hub/CLI, not Charon UI |
-| **Decision auto-expiry cleanup** | CrowdSec handles decision lifecycle; Charon only displays |
-| **i18n for chart data labels** | Deferred; chart data labels use English scenario names from CrowdSec. Tab labels (`Configuration`, `Dashboard`) are i18n'd in PR-2 (see acceptance criteria). |
-| **Dark mode chart theme** | Deferred; initial charts use Charon's existing color palette |
-
----
-
-## Appendix A: Time Range Validation
-
-```go
-func parseTimeRange(rangeStr string) (time.Time, error) {
- now := time.Now().UTC()
- switch rangeStr {
- case "1h":
- return now.Add(-1 * time.Hour), nil
- case "6h":
- return now.Add(-6 * time.Hour), nil
- case "24h", "":
- return now.Add(-24 * time.Hour), nil
- case "7d":
- return now.Add(-7 * 24 * time.Hour), nil
- case "30d":
- return now.Add(-30 * 24 * time.Hour), nil
- default:
- return time.Time{}, fmt.Errorf("invalid range: %s (valid: 1h, 6h, 24h, 7d, 30d)", rangeStr)
- }
-}
-```
-
-## Appendix B: Dashboard Color Palette
-
-Colors chosen for WCAG 2.2 AA compliance against both light and dark backgrounds:
-
-| Use | Color | Hex | Contrast vs White | Contrast vs #1a1a2e |
-|-----|-------|-----|-------------------|---------------------|
-| Bans (primary) | Blue | `#3b82f6` | 4.5:1 | 5.2:1 |
-| Captchas | Amber | `#f59e0b` | 3.1:1 (large text) | 7.8:1 |
-| Top scenario 1 | Indigo | `#6366f1` | 4.6:1 | 4.8:1 |
-| Top scenario 2 | Emerald | `#10b981` | 4.5:1 | 5.9:1 |
-| Top scenario 3 | Rose | `#f43f5e` | 4.5:1 | 5.7:1 |
-| Top scenario 4 | Cyan | `#06b6d4` | 4.5:1 | 6.1:1 |
-| Top scenario 5+ | Slate | `#64748b` | 4.6:1 | 4.5:1 |
-
-## Appendix C: SQLite Index Verification
-
-After migration, verify indexes exist:
-
-```sql
-SELECT name, tbl_name FROM sqlite_master
-WHERE type = 'index' AND tbl_name = 'security_decisions';
-```
-
-Expected new **single-column** indexes: `idx_security_decisions_scenario`, `idx_security_decisions_country`, `idx_security_decisions_expires_at` — in addition to existing indexes on `source`, `action`, `ip`, `host`, `rule_id`, `created_at`.
-
-Expected new **composite** indexes (see Section 3.3 for rationale):
-
-| Index Name | Columns | Purpose |
-|------------|---------|---------|
-| `idx_sd_source_created` | `(source, created_at DESC)` | All time-range filtered aggregation queries |
-| `idx_sd_source_scenario_created` | `(source, scenario, created_at DESC)` | Scenario breakdown, top-scenario ranking |
-| `idx_sd_source_ip_created` | `(source, ip, created_at DESC)` | Top-IPs ranking, unique IP counts |
-
-**Verification query for composite indexes:**
-
-```sql
-SELECT name, sql FROM sqlite_master
-WHERE type = 'index' AND tbl_name = 'security_decisions'
- AND name LIKE 'idx_sd_%';
-```
-
-**strftime interval reference** (used in timeline bucketing, Section 4.2):
-
-| Interval | SQLite Expression |
-|----------|-------------------|
-| `5m` | `strftime('%Y-%m-%dT%H:', created_at) \|\| printf('%02d:00Z', (CAST(strftime('%M', created_at) AS INTEGER) / 5) * 5)` |
-| `15m` | `strftime('%Y-%m-%dT%H:', created_at) \|\| printf('%02d:00Z', (CAST(strftime('%M', created_at) AS INTEGER) / 15) * 15)` |
-| `1h` | `strftime('%Y-%m-%dT%H:00:00Z', created_at)` |
-| `6h` | `strftime('%Y-%m-%dT', created_at) \|\| printf('%02d:00:00Z', (CAST(strftime('%H', created_at) AS INTEGER) / 6) * 6)` |
-| `1d` | `strftime('%Y-%m-%dT00:00:00Z', created_at)` |
-
----
-
-## Revision History
-
-| Version | Date | Author | Changes |
-|---------|------|--------|---------|
-| 1.0 | 2026-03-25 | — | Initial draft |
-| 1.1 | 2026-03-25 | — | Supervisor review remediation: 7 blocking fixes (B1–B7) + 5 important items (I1–I5). **B1:** Added `LogDecision()` call in `BanIP` for manual ban persistence. **B2:** Added composite indexes for aggregation query performance. **B3:** Removed `top_scenario` from per-IP response (simplicity). **B4:** Added `strftime` format strings for all 5 timeline intervals. **B5:** Added CSV formula injection sanitization requirement. **B6:** Specified `dashCache` field wiring on `CrowdsecHandler` struct. **B7:** Hybrid approach — `active_decisions` from LAPI, historical metrics from SQLite. **I1:** Tab labels use i18n keys. **I2:** Defined `decisions_trend` formula. **I3:** Clarified country field population from LAPI `source.cn`. **I4:** `React.lazy()` for dashboard tab. **I5:** Confirmed `tests/security/` convention. Moved `DecisionsExportButton.tsx` to PR-3. |
+**PR-1**: CrowdSec hub bootstrapping fix
+- **Scope**: `.docker/docker-entrypoint.sh`, `configs/crowdsec/install_hub_items.sh`
+- **Validation**: Manual docker rebuild + verify collections with `cscli collections list`
+- **Rollback**: Revert PR; behavior returns to current (broken) state — no data loss risk
diff --git a/docs/reports/qa_crowdsec_hub_bootstrapping.md b/docs/reports/qa_crowdsec_hub_bootstrapping.md
new file mode 100644
index 00000000..a9468481
--- /dev/null
+++ b/docs/reports/qa_crowdsec_hub_bootstrapping.md
@@ -0,0 +1,192 @@
+# QA Report: CrowdSec Hub Bootstrapping Fix
+
+**Date:** 2026-04-05
+**Scope:** `.docker/docker-entrypoint.sh`, `configs/crowdsec/install_hub_items.sh`, `scripts/crowdsec_startup_test.sh`
+**Status:** PASS
+
+---
+
+## 1. Shell Script Syntax Validation (`bash -n`)
+
+| File | Result |
+|------|--------|
+| `.docker/docker-entrypoint.sh` | ✓ Syntax OK |
+| `configs/crowdsec/install_hub_items.sh` | ✓ Syntax OK |
+| `scripts/crowdsec_startup_test.sh` | ✓ Syntax OK |
+
+**Verdict:** PASS — All three scripts parse without errors.
+
+---
+
+## 2. ShellCheck Static Analysis (v0.9.0)
+
+| File | Findings | Severity |
+|------|----------|----------|
+| `.docker/docker-entrypoint.sh` | SC2012 (L243): `ls` used where `find` is safer for non-alphanumeric filenames | Info |
+| `configs/crowdsec/install_hub_items.sh` | None | — |
+| `scripts/crowdsec_startup_test.sh` | SC2317 (L70,71,84,85,87-90): Functions in `trap` handler flagged as "unreachable" (false positive — invoked indirectly via `trap cleanup EXIT`) | Info |
+| `scripts/crowdsec_startup_test.sh` | SC2086 (L85): `${CONTAINER_NAME}` unquoted in `docker rm -f` | Info |
+
+**Verdict:** PASS — All findings are informational (severity: info). No warnings or errors. The SC2317 findings are false positives (standard `trap` pattern). The SC2086 finding is pre-existing and non-exploitable (variable is set to a constant string `charon-crowdsec-startup-test` without user input).
+
+---
+
+## 3. Pre-commit Hooks (Lefthook v2.1.4)
+
+| Hook | Result |
+|------|--------|
+| check-yaml | ✓ Pass |
+| actionlint | ✓ Pass |
+| end-of-file-fixer | ✓ Pass |
+| trailing-whitespace | ✓ Pass |
+| dockerfile-check | ✓ Pass |
+| shellcheck | ✓ Pass |
+
+**Verdict:** PASS — All 6 applicable hooks passed successfully.
+
+---
+
+## 4. Security Review
+
+### 4.1 Secrets and Credential Exposure
+
+| Check | Result |
+|-------|--------|
+| Hardcoded secrets in changed files | None found |
+| API keys/tokens in changed files | None found |
+| Gotify tokens in logs/output/URLs | None found |
+| Environment variable secrets exposed via `echo` | None — all `echo` statements output status messages only |
+
+**Verdict:** PASS
+
+### 4.2 Shell Injection Vectors
+
+| Check | Result |
+|-------|--------|
+| User input used in commands | No user-controlled input enters any command. All variables are set from hardcoded paths |
+| `eval` usage | None |
+| Unquoted variable expansion in commands | All critical variables are quoted or hardcoded strings |
+| Command injection via hub item names | Not applicable — all `cscli` arguments are hardcoded collection/parser names |
+
+**Verdict:** PASS
+
+### 4.3 `timeout` Usage Safety
+
+The entrypoint uses `timeout 60s cscli hub update 2>&1`:
+
+- `timeout` is the coreutils version (Alpine `busybox` timeout), sending SIGTERM after 60s
+- Prevents indefinite hang if hub CDN is unresponsive
+- 60s is appropriate for a single HTTPS request with potential DNS resolution
+- Failure is handled gracefully — logged as warning, startup continues
+
+**Verdict:** PASS
+
+### 4.4 `--force` Flag Analysis
+
+All `--force` flags are on `cscli` install commands:
+
+- `--force` in `cscli` context means "re-download and overwrite if already installed" — functionally an upsert
+- Does NOT bypass integrity checks or signature verification
+- Does NOT skip CrowdSec's hub item hash validation
+- Ensures idempotent behavior on every startup
+
+**Verdict:** PASS
+
+### 4.5 Error Visibility Changes
+
+The diff changes `2>/dev/null || true` patterns to `|| echo "⚠️ Failed to install ..."`:
+
+- **Before:** Errors silently swallowed
+- **After:** Errors logged with descriptive messages
+- This is a security improvement — silent failures can mask missing detection capabilities
+
+**Verdict:** PASS — Improved error visibility is a positive security change.
+
+### 4.6 Deprecated Environment Variable Removal
+
+| Check | Result |
+|-------|--------|
+| `SECURITY_CROWDSEC_MODE` removed from entrypoint | ✓ env var gate deleted |
+| `CERBERUS_SECURITY_CROWDSEC_MODE=local` removed from startup test | ✓ removed from `docker run` |
+| No remaining references in changed files | ✓ only documentation files reference it (as deprecated) |
+| Backend config still reads `CERBERUS_SECURITY_CROWDSEC_MODE` | ✓ backend uses different var names via `getEnvAny()` |
+
+**Verdict:** PASS
+
+---
+
+## 5. Dockerfile Consistency
+
+### 5.1 `install_hub_items.sh` Copy
+
+```dockerfile
+COPY configs/crowdsec/install_hub_items.sh /usr/local/bin/install_hub_items.sh
+RUN chmod +x /usr/local/bin/install_hub_items.sh /usr/local/bin/register_bouncer.sh
+```
+
+Copy path matches entrypoint invocation path (`-x /usr/local/bin/install_hub_items.sh`).
+
+**Verdict:** PASS
+
+### 5.2 Build-Time vs Runtime Conflict Check
+
+| Concern | Analysis |
+|---------|----------|
+| Build-time `cscli hub update` | Not performed in Dockerfile |
+| Build-time collection install | Not performed in Dockerfile |
+| Conflict with runtime approach | None — all hub operations deferred to container startup |
+
+**Verdict:** PASS
+
+### 5.3 Entrypoint Reference
+
+```dockerfile
+COPY .docker/docker-entrypoint.sh /docker-entrypoint.sh
+RUN chmod +x /docker-entrypoint.sh
+```
+
+**Verdict:** PASS
+
+---
+
+## 6. Related Tests and CI
+
+| Test | Location | Status |
+|------|----------|--------|
+| `scripts/crowdsec_startup_test.sh` | Modified in this PR | Updated — no longer passes deprecated env var |
+| Backend config tests | `backend/internal/config/config_test.go` | Unchanged, still valid |
+| CrowdSec-specific CI workflows | None exist | N/A |
+
+The startup test script is the primary validation mechanism for hub bootstrapping. The E2E Playwright suite covers CrowdSec UI but not hub bootstrapping directly.
+
+---
+
+## 7. Change Summary and Risk Assessment
+
+| Change | Risk | Rationale |
+|--------|------|-----------|
+| Unconditional `cscli hub update` on startup | Low | Adds ~2-5s. Prevents stale-index hash mismatch. `timeout 60s` prevents hangs. Failure is graceful. |
+| Removed `SECURITY_CROWDSEC_MODE` env var gate | Low | Env var was deprecated and never set. Collections are idempotent config files with zero runtime cost. |
+| Added `crowdsecurity/caddy` collection | Low | Standard CrowdSec collection for Caddy. Installed via `--force` (idempotent). |
+| Removed `2>/dev/null` from `cscli` install commands | Low (positive) | Errors now visible in container logs. |
+| Removed redundant `cscli hub update` from `install_hub_items.sh` | Low | Prevents double hub update (~3s saved). |
+| Removed deprecated env var from startup test | Low | Test matches actual container behavior. |
+
+---
+
+## 8. Overall Verdict
+
+| Category | Status |
+|----------|--------|
+| Shell syntax | ✅ PASS |
+| ShellCheck | ✅ PASS (info-only findings) |
+| Pre-commit hooks | ✅ PASS |
+| Security: secrets/credentials | ✅ PASS |
+| Security: injection vectors | ✅ PASS |
+| Security: `timeout` safety | ✅ PASS |
+| Security: `--force` flags | ✅ PASS |
+| Dockerfile consistency | ✅ PASS |
+| Deprecated env var cleanup | ✅ PASS |
+| CI/test coverage | ✅ PASS |
+
+**Overall: PASS — No blockers. Ready for merge.**
diff --git a/scripts/crowdsec_startup_test.sh b/scripts/crowdsec_startup_test.sh
index 99f7dd66..46aa091b 100755
--- a/scripts/crowdsec_startup_test.sh
+++ b/scripts/crowdsec_startup_test.sh
@@ -16,7 +16,7 @@ sleep 1
#
# Steps:
# 1. Build charon:local image if not present
-# 2. Start container with CERBERUS_SECURITY_CROWDSEC_MODE=local
+# 2. Start container with CrowdSec environment
# 3. Wait for initialization (30 seconds)
# 4. Check for fatal errors
# 5. Check LAPI health
@@ -127,7 +127,7 @@ docker rm -f ${CONTAINER_NAME} 2>/dev/null || true
# ============================================================================
# Step 4: Start container with CrowdSec enabled
# ============================================================================
-log_info "Starting Charon container with CERBERUS_SECURITY_CROWDSEC_MODE=local..."
+log_info "Starting Charon container with CrowdSec enabled..."
docker run -d --name ${CONTAINER_NAME} \
-p ${HTTP_PORT}:80 \
@@ -136,7 +136,6 @@ docker run -d --name ${CONTAINER_NAME} \
-e CHARON_ENV=development \
-e CHARON_DEBUG=1 \
-e FEATURE_CERBERUS_ENABLED=true \
- -e CERBERUS_SECURITY_CROWDSEC_MODE=local \
-e CERBERUS_SECURITY_CROWDSEC_API_KEY=dummy-key \
-v charon_crowdsec_startup_data:/app/data \
-v caddy_crowdsec_startup_data:/data \
|