diff --git a/backend/internal/api/handlers/emergency_handler_test.go b/backend/internal/api/handlers/emergency_handler_test.go index 7e89e008..4106577a 100644 --- a/backend/internal/api/handlers/emergency_handler_test.go +++ b/backend/internal/api/handlers/emergency_handler_test.go @@ -381,6 +381,46 @@ func TestEmergencySecurityReset_ClearsBlockDecisions(t *testing.T) { assert.Equal(t, "allow", remaining[0].Action) } +func TestEmergencySecurityReset_MiddlewarePrevalidatedBypass(t *testing.T) { + db := setupEmergencyTestDB(t) + handler := NewEmergencyHandler(db) + + gin.SetMode(gin.TestMode) + router := gin.New() + router.POST("/api/v1/emergency/security-reset", func(c *gin.Context) { + c.Set("emergency_bypass", true) + handler.SecurityReset(c) + }) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/emergency/security-reset", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) +} + +func TestEmergencySecurityReset_MiddlewareBypass_ResetFailure(t *testing.T) { + db := setupEmergencyTestDB(t) + handler := NewEmergencyHandler(db) + + stdDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, stdDB.Close()) + + gin.SetMode(gin.TestMode) + router := gin.New() + router.POST("/api/v1/emergency/security-reset", func(c *gin.Context) { + c.Set("emergency_bypass", true) + handler.SecurityReset(c) + }) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/emergency/security-reset", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusInternalServerError, w.Code) +} + func TestLogEnhancedAudit(t *testing.T) { // Setup db := setupEmergencyTestDB(t) diff --git a/backend/internal/api/middleware/emergency_test.go b/backend/internal/api/middleware/emergency_test.go index e29bf395..11961f27 100644 --- a/backend/internal/api/middleware/emergency_test.go +++ b/backend/internal/api/middleware/emergency_test.go @@ -33,6 +33,30 @@ func TestEmergencyBypass_NoToken(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) } +func TestEmergencyBypass_InvalidClientIP(t *testing.T) { + gin.SetMode(gin.TestMode) + + t.Setenv("CHARON_EMERGENCY_TOKEN", "test-token-that-meets-minimum-length-requirement-32-chars") + + router := gin.New() + managementCIDRs := []string{"127.0.0.0/8"} + router.Use(EmergencyBypass(managementCIDRs, nil)) + + router.GET("/test", func(c *gin.Context) { + _, exists := c.Get("emergency_bypass") + assert.False(t, exists, "Emergency bypass flag should not be set for invalid client IP") + c.JSON(http.StatusOK, gin.H{"message": "ok"}) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set(EmergencyTokenHeader, "test-token-that-meets-minimum-length-requirement-32-chars") + req.RemoteAddr = "invalid-remote-addr" + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + func TestEmergencyBypass_ValidToken(t *testing.T) { // Test that valid token from allowed IP sets bypass flag gin.SetMode(gin.TestMode) diff --git a/backend/internal/cerberus/cerberus_middleware_test.go b/backend/internal/cerberus/cerberus_middleware_test.go index 0ccc3091..3b3bdc42 100644 --- a/backend/internal/cerberus/cerberus_middleware_test.go +++ b/backend/internal/cerberus/cerberus_middleware_test.go @@ -244,3 +244,22 @@ func TestMiddleware_ACLDisabledDoesNotBlock(t *testing.T) { // Disabled ACL should not block require.False(t, ctx.IsAborted()) } + +func TestMiddleware_EmergencyBypassSkipsChecks(t *testing.T) { + t.Parallel() + + db := setupDB(t) + c := cerberus.New(config.SecurityConfig{CerberusEnabled: true, ACLMode: "enabled"}, db) + + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + req := httptest.NewRequest(http.MethodGet, "/admin/secure", nil) + req.RemoteAddr = "203.0.113.10:1234" + ctx.Request = req + ctx.Set("emergency_bypass", true) + + mw := c.Middleware() + mw(ctx) + + require.False(t, ctx.IsAborted(), "middleware should short-circuit when emergency_bypass=true") +} diff --git a/backend/internal/crowdsec/hub_cache_test.go b/backend/internal/crowdsec/hub_cache_test.go index c299145d..67387cfe 100644 --- a/backend/internal/crowdsec/hub_cache_test.go +++ b/backend/internal/crowdsec/hub_cache_test.go @@ -2,6 +2,9 @@ package crowdsec import ( "context" + "errors" + "os" + "path/filepath" "testing" "time" @@ -168,6 +171,22 @@ func TestHubCacheLoadInvalidSlug(t *testing.T) { require.Error(t, err) } +func TestHubCacheLoadMetadataReadError(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + cache, err := NewHubCache(baseDir, time.Hour) + require.NoError(t, err) + + slugDir := filepath.Join(baseDir, "crowdsecurity", "demo") + require.NoError(t, os.MkdirAll(slugDir, 0o750)) + require.NoError(t, os.Mkdir(filepath.Join(slugDir, "metadata.json"), 0o750)) + + _, err = cache.Load(context.Background(), "crowdsecurity/demo") + require.Error(t, err) + require.False(t, errors.Is(err, ErrCacheMiss)) +} + func TestHubCacheExistsContextCanceled(t *testing.T) { t.Parallel() cache, err := NewHubCache(t.TempDir(), time.Hour) diff --git a/backend/internal/crowdsec/hub_sync_test.go b/backend/internal/crowdsec/hub_sync_test.go index b8427cc8..87085f83 100644 --- a/backend/internal/crowdsec/hub_sync_test.go +++ b/backend/internal/crowdsec/hub_sync_test.go @@ -1713,6 +1713,41 @@ func TestHubHTTPErrorCanFallback(t *testing.T) { }) } +func TestHubServiceFetchWithFallbackStopsOnNonFallbackError(t *testing.T) { + t.Parallel() + + svc := NewHubService(nil, nil, t.TempDir()) + attempts := 0 + svc.HTTPClient = &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + attempts++ + return newResponse(http.StatusBadRequest, "bad request"), nil + })} + + _, _, err := svc.fetchWithFallback(context.Background(), []string{"https://hub.crowdsec.net/a", "https://raw.githubusercontent.com/crowdsecurity/hub/master/b"}) + require.Error(t, err) + require.Equal(t, 1, attempts) +} + +func TestHubServiceFetchWithFallbackRetriesWhenErrorCanFallback(t *testing.T) { + t.Parallel() + + svc := NewHubService(nil, nil, t.TempDir()) + attempts := 0 + svc.HTTPClient = &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + attempts++ + if attempts == 1 { + return newResponse(http.StatusServiceUnavailable, "unavailable"), nil + } + return newResponse(http.StatusOK, "ok"), nil + })} + + data, used, err := svc.fetchWithFallback(context.Background(), []string{"https://hub.crowdsec.net/a", "https://raw.githubusercontent.com/crowdsecurity/hub/master/b"}) + require.NoError(t, err) + require.Equal(t, "ok", string(data)) + require.Equal(t, "https://raw.githubusercontent.com/crowdsecurity/hub/master/b", used) + require.Equal(t, 2, attempts) +} + // TestValidateHubURL_EdgeCases tests additional edge cases for SSRF protection func TestValidateHubURL_EdgeCases(t *testing.T) { t.Parallel() diff --git a/backend/internal/services/backup_service_wave3_test.go b/backend/internal/services/backup_service_wave3_test.go index 0cabbb37..d7a0285e 100644 --- a/backend/internal/services/backup_service_wave3_test.go +++ b/backend/internal/services/backup_service_wave3_test.go @@ -2,6 +2,7 @@ package services import ( "archive/zip" + "bytes" "os" "path/filepath" "strings" @@ -90,3 +91,49 @@ func TestBackupService_ExtractDatabaseFromBackup_ExtractWalFailure(t *testing.T) _, err = svc.extractDatabaseFromBackup(zipPath) require.Error(t, err) } + +func TestBackupService_UnzipWithSkip_RejectsPathTraversal(t *testing.T) { + tmp := t.TempDir() + destDir := filepath.Join(tmp, "data") + require.NoError(t, os.MkdirAll(destDir, 0o700)) + + zipPath := filepath.Join(tmp, "path-traversal.zip") + zipFile := openZipInTempDir(t, tmp, zipPath) + writer := zip.NewWriter(zipFile) + + entry, err := writer.Create("../escape.txt") + require.NoError(t, err) + _, err = entry.Write([]byte("evil")) + require.NoError(t, err) + + require.NoError(t, writer.Close()) + require.NoError(t, zipFile.Close()) + + svc := &BackupService{DataDir: destDir, DatabaseName: "charon.db"} + err = svc.unzipWithSkip(zipPath, destDir, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid file path in archive") +} + +func TestBackupService_UnzipWithSkip_RejectsExcessiveUncompressedSize(t *testing.T) { + tmp := t.TempDir() + destDir := filepath.Join(tmp, "data") + require.NoError(t, os.MkdirAll(destDir, 0o700)) + + zipPath := filepath.Join(tmp, "oversized.zip") + zipFile := openZipInTempDir(t, tmp, zipPath) + writer := zip.NewWriter(zipFile) + + entry, err := writer.Create("huge.bin") + require.NoError(t, err) + _, err = entry.Write(bytes.Repeat([]byte("a"), 101*1024*1024)) + require.NoError(t, err) + + require.NoError(t, writer.Close()) + require.NoError(t, zipFile.Close()) + + svc := &BackupService{DataDir: destDir, DatabaseName: "charon.db"} + err = svc.unzipWithSkip(zipPath, destDir, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "exceeded decompression limit") +} diff --git a/backend/internal/services/uptime_service_unit_test.go b/backend/internal/services/uptime_service_unit_test.go index 972edce7..bccc3c7b 100644 --- a/backend/internal/services/uptime_service_unit_test.go +++ b/backend/internal/services/uptime_service_unit_test.go @@ -190,6 +190,27 @@ func TestCheckMonitor_TCPFailure(t *testing.T) { require.NotEmpty(t, hb.Message) } +func TestCreateMonitor_AppliesDefaultIntervalAndRetries(t *testing.T) { + db := setupUnitTestDB(t) + svc := NewUptimeService(db, nil) + + monitor, err := svc.CreateMonitor("defaults", "http://example.com", "http", 0, 0) + require.NoError(t, err) + require.Equal(t, 60, monitor.Interval) + require.Equal(t, 3, monitor.MaxRetries) + require.Equal(t, "pending", monitor.Status) + require.True(t, monitor.Enabled) +} + +func TestCreateMonitor_TCPRequiresHostPort(t *testing.T) { + db := setupUnitTestDB(t) + svc := NewUptimeService(db, nil) + + _, err := svc.CreateMonitor("bad-tcp", "example.com", "tcp", 60, 2) + require.Error(t, err) + require.Contains(t, err.Error(), "TCP URL must be in host:port format") +} + // TestCheckMonitor_UnknownType tests unknown monitor type func TestCheckMonitor_UnknownType(t *testing.T) { db := setupUnitTestDB(t) diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index 29bee1fe..defb78d4 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -647,3 +647,91 @@ After user approval of this plan: 2. Execute PR-2 (quality/open findings) second. 3. Execute PR-3 (hygiene/config hardening) third. 4. Submit final supervisor review with linked evidence and closure checklist. + +## Patch-Coverage Uplift Addendum (CodeQL Remediation Branch) + +### Scope + +Input baseline (`docs/plans/codecove_patch_report.md`): 18 uncovered patch lines across 9 backend files. + +Goal: close uncovered branches with minimal, branch-specific tests only (no broad refactors). + +### 1) Exact test files to add/update + +- Update `backend/internal/api/handlers/emergency_handler_test.go` +- Update `backend/internal/api/handlers/proxy_host_handler_update_test.go` +- Update `backend/internal/crowdsec/hub_sync_test.go` +- Update `backend/internal/api/handlers/crowdsec_pull_apply_integration_test.go` +- Update `backend/internal/services/backup_service_wave3_test.go` +- Update `backend/internal/services/uptime_service_unit_test.go` +- Update `backend/internal/api/middleware/emergency_test.go` +- Update `backend/internal/cerberus/cerberus_middleware_test.go` +- Update `backend/internal/crowdsec/hub_cache_test.go` + +### 2) Minimal branch-execution scenarios + +#### `backend/internal/api/handlers/emergency_handler.go` (3 lines) +- Add middleware-prevalidated reset test: set `emergency_bypass=true` in context and assert `SecurityReset` takes middleware path and returns success. +- Add reset failure-path test: force module-disable failure (closed DB/failed upsert) and assert HTTP 500 path executes. + +#### `backend/internal/api/handlers/proxy_host_handler.go` (3 lines) +- Add update payload case with `security_header_profile_id` as valid string to execute string-conversion success path. +- Add update payload case with invalid string to execute string parse failure branch. +- Add update payload case with unsupported type (boolean/object) to execute unsupported-type branch. + +#### `backend/internal/crowdsec/hub_sync.go` (3 lines) +- Add apply scenario where cache metadata exists but archive read fails, forcing refresh path and post-refresh archive read. +- Add fallback fetch scenario with first endpoint returning fallback-eligible error, second endpoint success. +- Add fallback-stop scenario with non-fallback error to execute early break path. + +#### `backend/internal/api/handlers/crowdsec_handler.go` (2 lines) +- Add apply test where cached meta exists but archive/preview file stat fails to execute missing-file log branches before apply. +- Add pull/apply branch case that exercises cache-miss diagnostics and response payload path. + +#### `backend/internal/services/backup_service.go` (2 lines) +- Add unzip-with-skip test with oversized decompressed entry to execute decompression-limit rejection branch. +- Add unzip-with-skip error-path test that validates extraction abort handling for invalid archive entry flow. + +#### `backend/internal/services/uptime_service.go` (2 lines) +- Add `CreateMonitor` test with `interval<=0` and `max_retries<=0` to execute defaulting branches. +- Add TCP monitor validation case with invalid `host:port` input to execute TCP validation error path. + +#### `backend/internal/api/middleware/emergency.go` (1 line) +- Add malformed client IP test (`RemoteAddr` unparsable) with token present to execute invalid-IP branch and confirm bypass is not set. + +#### `backend/internal/cerberus/cerberus.go` (1 line) +- Add middleware test with `emergency_bypass=true` in gin context and ACL enabled to execute bypass short-circuit branch. + +#### `backend/internal/crowdsec/hub_cache.go` (1 line) +- Add cache-load test that causes non-ENOENT metadata read failure (e.g., invalid metadata path state) to execute hard read-error branch (not `ErrCacheMiss`). + +### 3) Verification commands (targeted + patch report) + +Run targeted backend tests only: + +```bash +cd /projects/Charon +go test ./backend/internal/api/handlers -run 'TestEmergency|TestProxyHostUpdate|TestPullThenApply|TestApplyWithoutPull|TestApplyRollbackWhenCacheMissingAndRepullFails' +go test ./backend/internal/crowdsec -run 'TestPull|TestApply|TestFetchWith|TestHubCache' +go test ./backend/internal/services -run 'TestBackupService_UnzipWithSkip|TestCreateMonitor|TestUpdateMonitor|TestDeleteMonitor' +go test ./backend/internal/api/middleware -run 'TestEmergencyBypass' +go test ./backend/internal/cerberus -run 'TestMiddleware_' +``` + +Generate local patch coverage report artifacts: + +```bash +cd /projects/Charon +bash scripts/local-patch-report.sh +``` + +Expected artifacts: +- `test-results/local-patch-report.md` +- `test-results/local-patch-report.json` + +### 4) Acceptance criteria + +- Patch coverage increases from `79.31034%` to `>= 90%` for this remediation branch. +- Missing patch lines decrease from `18` to `<= 6` (target `0` if all branches are feasibly testable). +- All nine listed backend files show reduced missing-line counts in local patch report output. +- Targeted test commands pass with zero failures.