Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0a3b64b
fix: correct misplaced env block in propagate-changes workflow
actions-user Apr 15, 2026
080e17d
Merge pull request #951 from Wikid82/main
Wikid82 Apr 15, 2026
1bd7eab
Merge pull request #953 from Wikid82/development
Wikid82 Apr 16, 2026
98c7209
chore(deps): update non-major-updates
renovate[bot] Apr 16, 2026
abf88ab
Merge pull request #954 from Wikid82/renovate/feature/beta-release-no…
Wikid82 Apr 16, 2026
9945fac
Merge branch 'development' into feature/beta-release
Wikid82 Apr 16, 2026
40090cd
feat: add installation of crowdsecurity/whitelists parser
actions-user Apr 15, 2026
1726a19
feat: add CrowdSecWhitelist model and integrate into API route regist…
actions-user Apr 15, 2026
5642a37
feat: implement CrowdSecWhitelistService for managing IP/CIDR whitelists
actions-user Apr 15, 2026
741a59c
feat: add whitelist management endpoints to CrowdsecHandler
actions-user Apr 15, 2026
a243066
feat: regenerate whitelist YAML on CrowdSec startup
actions-user Apr 15, 2026
1971969
feat: add unit tests for CrowdSecWhitelistService and CrowdsecHandler
actions-user Apr 15, 2026
28bc73b
feat: add whitelist management hooks for querying and mutating whitel…
actions-user Apr 15, 2026
c977cf6
feat: add whitelist management functionality to CrowdSecConfig
actions-user Apr 15, 2026
aee0eee
feat: add unit tests for useCrowdSecWhitelist hooks
actions-user Apr 15, 2026
eb9b907
feat: add end-to-end tests for CrowdSec IP whitelist management
actions-user Apr 16, 2026
028342c
fix: update JSON response key for whitelist entries in ListWhitelists…
actions-user Apr 16, 2026
973efd6
fix: initialize WhitelistSvc only if db is not nil and update error m…
actions-user Apr 16, 2026
f0fdf9b
test: update response key for whitelist entries and add validation te…
actions-user Apr 16, 2026
2a1652d
feat: add IP whitelist management details to architecture documentation
actions-user Apr 16, 2026
557b33d
fix: update docker/go-connections dependency to v0.7.0
actions-user Apr 16, 2026
3d0179a
fix: update @asamuzakjp/css-color and @asamuzakjp/dom-selector to lat…
actions-user Apr 16, 2026
f46bb83
feat: add QA audit report for CrowdSec IP Whitelist Management
actions-user Apr 16, 2026
402a8b3
fix: update electron-to-chromium, eslint-plugin-sonarjs, minimatch, a…
actions-user Apr 16, 2026
4232c0a
fix: update benchmark-action/github-action-benchmark to v1.22.0 and m…
actions-user Apr 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ jobs:
# This avoids gh-pages branch errors and permission issues on fork PRs
if: github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main'
# Security: Pinned to full SHA for supply chain security
uses: benchmark-action/github-action-benchmark@4e0b38bc48375986542b13c0d8976b7b80c60c00 # v1
uses: benchmark-action/github-action-benchmark@a60cea5bc7b49e15c1f58f411161f99e0df48372 # v1.22.0
with:
name: Go Benchmark
tool: 'go'
Expand Down
9 changes: 5 additions & 4 deletions .github/workflows/propagate-changes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ jobs:
env:
CURRENT_BRANCH: ${{ github.event.workflow_run.head_branch || github.ref_name }}
CURRENT_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CHARON_TOKEN: ${{ secrets.CHARON_TOKEN }}
with:
script: |
const currentBranch = process.env.CURRENT_BRANCH || context.ref.replace('refs/heads/', '');
Expand Down Expand Up @@ -133,7 +135,9 @@ jobs:

const sensitive = files.some(fn => configPaths.some(sp => fn.startsWith(sp) || fn.includes(sp)));
if (sensitive) {
core.info(`${src} -> ${base} contains sensitive changes (${files.join(', ')}). Skipping automatic propagation.`);
const preview = files.slice(0, 25).join(', ');
const suffix = files.length > 25 ? ` …(+${files.length - 25} more)` : '';
core.info(`${src} -> ${base} contains sensitive changes (${preview}${suffix}). Skipping automatic propagation.`);
return;
}
} catch (error) {
Expand Down Expand Up @@ -203,6 +207,3 @@ jobs:
await createPR('development', targetBranch);
}
}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CHARON_TOKEN: ${{ secrets.CHARON_TOKEN }}
2 changes: 1 addition & 1 deletion .github/workflows/release-goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ jobs:

- name: Install Cross-Compilation Tools (Zig)
# Security: Pinned to full SHA for supply chain security
uses: goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406 # v2
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
with:
version: 0.13.0

Expand Down
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,7 @@ graph LR
- Global threat intelligence (crowd-sourced IP reputation)
- Automatic IP banning with configurable duration
- Decision management API (view, create, delete bans)
- IP whitelist management: operators add/remove IPs and CIDRs via the management UI; entries are persisted in SQLite and regenerated into a `crowdsecurity/whitelists` parser YAML on every mutating operation and at startup

**Modes:**

Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ ARG BUILD_DEBUG=0
ARG GO_VERSION=1.26.2

# renovate: datasource=docker depName=alpine versioning=docker
ARG ALPINE_IMAGE=alpine:3.23.3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659
ARG ALPINE_IMAGE=alpine:3.23.4@sha256:5b10f432ef3da1b8d4c7eb6c487f2f5a8f096bc91145e68878dd4a5019afde11

# ---- Shared CrowdSec Version ----
# renovate: datasource=github-releases depName=crowdsecurity/crowdsec
Expand Down
2 changes: 1 addition & 1 deletion backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ require (
github.com/containerd/log v0.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
Expand Down
4 changes: 2 additions & 2 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
Expand Down
80 changes: 79 additions & 1 deletion backend/internal/api/handlers/crowdsec_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ type CrowdsecHandler struct {
Hub *crowdsec.HubService
Console *crowdsec.ConsoleEnrollmentService
Security *services.SecurityService
WhitelistSvc *services.CrowdSecWhitelistService
CaddyManager *caddy.Manager // For config reload after bouncer registration
LAPIMaxWait time.Duration // For testing; 0 means 60s default
LAPIPollInterval time.Duration // For testing; 0 means 500ms default
Expand Down Expand Up @@ -383,7 +384,7 @@ func NewCrowdsecHandler(db *gorm.DB, executor CrowdsecExecutor, binPath, dataDir
securitySvc = services.NewSecurityService(db)
consoleSvc = crowdsec.NewConsoleEnrollmentService(db, &crowdsec.SecureCommandExecutor{}, dataDir, consoleSecret)
}
return &CrowdsecHandler{
h := &CrowdsecHandler{
DB: db,
Executor: executor,
CmdExec: &RealCommandExecutor{},
Expand All @@ -395,6 +396,10 @@ func NewCrowdsecHandler(db *gorm.DB, executor CrowdsecExecutor, binPath, dataDir
dashCache: newDashboardCache(),
validateLAPIURL: validateCrowdsecLAPIBaseURLDefault,
}
if db != nil {
h.WhitelistSvc = services.NewCrowdSecWhitelistService(db, dataDir)
}
return h
}

// isCerberusEnabled returns true when Cerberus is enabled via DB or env flag.
Expand Down Expand Up @@ -2700,6 +2705,75 @@ func fileExists(path string) bool {
return err == nil
}

// ListWhitelists returns all CrowdSec IP/CIDR whitelist entries.
func (h *CrowdsecHandler) ListWhitelists(c *gin.Context) {
entries, err := h.WhitelistSvc.List(c.Request.Context())
if err != nil {
logger.Log().WithError(err).Error("failed to list whitelist entries")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list whitelist entries"})
return
}
c.JSON(http.StatusOK, gin.H{"whitelist": entries})
}

// AddWhitelist adds a new IP or CIDR to the 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 err != nil {
switch {
case errors.Is(err, services.ErrInvalidIPOrCIDR):
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid IP address or CIDR notation"})
case errors.Is(err, services.ErrDuplicateEntry):
c.JSON(http.StatusConflict, gin.H{"error": "entry already exists in whitelist"})
default:
logger.Log().WithError(err).Error("failed to add whitelist entry")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add whitelist entry"})
}
return
}

if _, execErr := h.CmdExec.Execute(c.Request.Context(), "cscli", "hub", "reload"); execErr != nil {
logger.Log().WithError(execErr).Warn("cscli hub reload failed after whitelist add (non-fatal)")
}

c.JSON(http.StatusCreated, entry)
}

// DeleteWhitelist removes a whitelist entry by UUID.
func (h *CrowdsecHandler) DeleteWhitelist(c *gin.Context) {
id := c.Param("uuid")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "uuid is required"})
return
}

if err := h.WhitelistSvc.Delete(c.Request.Context(), id); err != nil {
switch {
case errors.Is(err, services.ErrWhitelistNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "whitelist entry not found"})
default:
logger.Log().WithError(err).Error("failed to delete whitelist entry")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete whitelist entry"})
}
return
}

if _, execErr := h.CmdExec.Execute(c.Request.Context(), "cscli", "hub", "reload"); execErr != nil {
logger.Log().WithError(execErr).Warn("cscli hub reload failed after whitelist delete (non-fatal)")
}

c.Status(http.StatusNoContent)
}

// RegisterRoutes registers crowdsec admin routes under protected group
func (h *CrowdsecHandler) RegisterRoutes(rg *gin.RouterGroup) {
rg.POST("/admin/crowdsec/start", h.Start)
Expand Down Expand Up @@ -2742,4 +2816,8 @@ func (h *CrowdsecHandler) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/admin/crowdsec/dashboard/scenarios", h.DashboardScenarios)
rg.GET("/admin/crowdsec/alerts", h.ListAlerts)
rg.GET("/admin/crowdsec/decisions/export", h.ExportDecisions)
// Whitelist management endpoints (Issue #939)
rg.GET("/admin/crowdsec/whitelist", h.ListWhitelists)
rg.POST("/admin/crowdsec/whitelist", h.AddWhitelist)
rg.DELETE("/admin/crowdsec/whitelist/:uuid", h.DeleteWhitelist)
}
175 changes: 175 additions & 0 deletions backend/internal/api/handlers/crowdsec_whitelist_handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package handlers

import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)

type mockCmdExecWhitelist struct {
reloadCalled bool
reloadErr error
}

func (m *mockCmdExecWhitelist) Execute(_ context.Context, _ string, _ ...string) ([]byte, error) {
m.reloadCalled = true
return nil, m.reloadErr
}

func setupWhitelistHandler(t *testing.T) (*CrowdsecHandler, *gin.Engine, *gorm.DB) {
t.Helper()
db := OpenTestDB(t)
require.NoError(t, db.AutoMigrate(&models.CrowdSecWhitelist{}))
fe := &fakeExec{}
h := newTestCrowdsecHandler(t, db, fe, "/bin/false", "")
h.WhitelistSvc = services.NewCrowdSecWhitelistService(db, "")

r := gin.New()
g := r.Group("/api/v1")
g.GET("/admin/crowdsec/whitelist", h.ListWhitelists)
g.POST("/admin/crowdsec/whitelist", h.AddWhitelist)
g.DELETE("/admin/crowdsec/whitelist/:uuid", h.DeleteWhitelist)

return h, r, db
}

func TestListWhitelists_Empty(t *testing.T) {
t.Parallel()
_, r, _ := setupWhitelistHandler(t)

w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/whitelist", nil)
r.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
entries, ok := resp["whitelist"].([]interface{})
assert.True(t, ok)
assert.Empty(t, entries)
}

func TestAddWhitelist_ValidIP(t *testing.T) {
t.Parallel()
h, r, _ := setupWhitelistHandler(t)
mock := &mockCmdExecWhitelist{}
h.CmdExec = mock

body := `{"ip_or_cidr":"1.2.3.4","reason":"test"}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/whitelist", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)

assert.Equal(t, http.StatusCreated, w.Code)
assert.True(t, mock.reloadCalled)

var entry models.CrowdSecWhitelist
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &entry))
assert.Equal(t, "1.2.3.4", entry.IPOrCIDR)
assert.NotEmpty(t, entry.UUID)
}

func TestAddWhitelist_InvalidIP(t *testing.T) {
t.Parallel()
_, r, _ := setupWhitelistHandler(t)

body := `{"ip_or_cidr":"not-valid","reason":""}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/whitelist", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)

assert.Equal(t, http.StatusBadRequest, w.Code)
}

func TestAddWhitelist_Duplicate(t *testing.T) {
t.Parallel()
_, r, _ := setupWhitelistHandler(t)

body := `{"ip_or_cidr":"9.9.9.9","reason":""}`
for i := 0; i < 2; i++ {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/whitelist", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if i == 0 {
assert.Equal(t, http.StatusCreated, w.Code)
} else {
assert.Equal(t, http.StatusConflict, w.Code)
}
}
}

func TestDeleteWhitelist_Existing(t *testing.T) {
t.Parallel()
h, r, db := setupWhitelistHandler(t)
mock := &mockCmdExecWhitelist{}
h.CmdExec = mock

svc := services.NewCrowdSecWhitelistService(db, "")
entry, err := svc.Add(t.Context(), "7.7.7.7", "to delete")
require.NoError(t, err)

w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/crowdsec/whitelist/"+entry.UUID, nil)
r.ServeHTTP(w, req)

assert.Equal(t, http.StatusNoContent, w.Code)
assert.True(t, mock.reloadCalled)
}

func TestDeleteWhitelist_NotFound(t *testing.T) {
t.Parallel()
_, r, _ := setupWhitelistHandler(t)

w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/crowdsec/whitelist/00000000-0000-0000-0000-000000000000", nil)
r.ServeHTTP(w, req)

assert.Equal(t, http.StatusNotFound, w.Code)
}

func TestListWhitelists_AfterAdd(t *testing.T) {
t.Parallel()
_, r, db := setupWhitelistHandler(t)
svc := services.NewCrowdSecWhitelistService(db, "")
_, err := svc.Add(t.Context(), "8.8.8.8", "google dns")
require.NoError(t, err)

w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/whitelist", nil)
r.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
entries := resp["whitelist"].([]interface{})
assert.Len(t, entries, 1)
}

func TestAddWhitelist_400_MissingField(t *testing.T) {
t.Parallel()
_, r, _ := setupWhitelistHandler(t)

body := `{}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/whitelist", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)

assert.Equal(t, http.StatusBadRequest, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "ip_or_cidr is required", resp["error"])
}
1 change: 1 addition & 0 deletions backend/internal/api/routes/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg
&models.DNSProviderCredential{}, // Multi-credential support (Phase 3)
&models.Plugin{}, // Phase 5: DNS provider plugins
&models.ManualChallenge{}, // Phase 1: Manual DNS challenges
&models.CrowdSecWhitelist{}, // Issue #939: CrowdSec IP whitelist management
); err != nil {
return fmt.Errorf("auto migrate: %w", err)
}
Expand Down
13 changes: 13 additions & 0 deletions backend/internal/models/crowdsec_whitelist.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package models

import "time"

// CrowdSecWhitelist represents a single IP or CIDR block that CrowdSec should never ban.
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"`
}
Loading
Loading