diff --git a/.gitignore b/.gitignore index e06bdd43..5dcbecab 100644 --- a/.gitignore +++ b/.gitignore @@ -301,3 +301,6 @@ docs/plans/supply_chain_security_implementation.md.backup /playwright/.cache/ /playwright/.auth/ test-data/** + +# GORM Security Scanner Reports +docs/reports/gorm-scan-*.txt diff --git a/backend/internal/api/handlers/dns_provider_handler.go b/backend/internal/api/handlers/dns_provider_handler.go index f41aa309..fd6b8167 100644 --- a/backend/internal/api/handlers/dns_provider_handler.go +++ b/backend/internal/api/handlers/dns_provider_handler.go @@ -34,10 +34,8 @@ func (h *DNSProviderHandler) List(c *gin.Context) { // Convert to response format with has_credentials indicator responses := make([]services.DNSProviderResponse, len(providers)) for i, p := range providers { - responses[i] = services.DNSProviderResponse{ - DNSProvider: p, - HasCredentials: p.CredentialsEncrypted != "", - } + pCopy := p // Create a copy to take address of + responses[i] = services.NewDNSProviderResponse(&pCopy) } c.JSON(http.StatusOK, gin.H{ @@ -65,10 +63,7 @@ func (h *DNSProviderHandler) Get(c *gin.Context) { return } - response := services.DNSProviderResponse{ - DNSProvider: *provider, - HasCredentials: provider.CredentialsEncrypted != "", - } + response := services.NewDNSProviderResponse(provider) c.JSON(http.StatusOK, response) } @@ -101,10 +96,7 @@ func (h *DNSProviderHandler) Create(c *gin.Context) { return } - response := services.DNSProviderResponse{ - DNSProvider: *provider, - HasCredentials: provider.CredentialsEncrypted != "", - } + response := services.NewDNSProviderResponse(provider) c.JSON(http.StatusCreated, response) } @@ -144,10 +136,7 @@ func (h *DNSProviderHandler) Update(c *gin.Context) { return } - response := services.DNSProviderResponse{ - DNSProvider: *provider, - HasCredentials: provider.CredentialsEncrypted != "", - } + response := services.NewDNSProviderResponse(provider) c.JSON(http.StatusOK, response) } diff --git a/backend/internal/api/handlers/dns_provider_handler_test.go b/backend/internal/api/handlers/dns_provider_handler_test.go index aa118a90..a21b7016 100644 --- a/backend/internal/api/handlers/dns_provider_handler_test.go +++ b/backend/internal/api/handlers/dns_provider_handler_test.go @@ -209,7 +209,7 @@ func TestDNSProviderHandler_Get(t *testing.T) { err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) - assert.Equal(t, uint(1), response.ID) + assert.Equal(t, "uuid-1", response.UUID) assert.Equal(t, "Test Provider", response.Name) assert.True(t, response.HasCredentials) @@ -282,7 +282,7 @@ func TestDNSProviderHandler_Create(t *testing.T) { err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) - assert.Equal(t, uint(1), response.ID) + assert.Equal(t, "uuid-1", response.UUID) assert.Equal(t, "Test Provider", response.Name) assert.True(t, response.HasCredentials) diff --git a/backend/internal/api/handlers/proxy_host_handler.go b/backend/internal/api/handlers/proxy_host_handler.go index e73da5a6..f5556da6 100644 --- a/backend/internal/api/handlers/proxy_host_handler.go +++ b/backend/internal/api/handlers/proxy_host_handler.go @@ -6,6 +6,7 @@ import ( "net" "net/http" "strconv" + "time" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -27,9 +28,82 @@ type ProxyHostWarning struct { } // ProxyHostResponse wraps a proxy host with optional advisory warnings. +// Uses explicit fields to avoid exposing internal database IDs. type ProxyHostResponse struct { - models.ProxyHost - Warnings []ProxyHostWarning `json:"warnings,omitempty"` + UUID string `json:"uuid"` + Name string `json:"name"` + DomainNames string `json:"domain_names"` + ForwardScheme string `json:"forward_scheme"` + ForwardHost string `json:"forward_host"` + ForwardPort int `json:"forward_port"` + SSLForced bool `json:"ssl_forced"` + HTTP2Support bool `json:"http2_support"` + HSTSEnabled bool `json:"hsts_enabled"` + HSTSSubdomains bool `json:"hsts_subdomains"` + BlockExploits bool `json:"block_exploits"` + WebsocketSupport bool `json:"websocket_support"` + Application string `json:"application"` + Enabled bool `json:"enabled"` + CertificateID *uint `json:"certificate_id"` + Certificate *models.SSLCertificate `json:"certificate,omitempty"` + AccessListID *uint `json:"access_list_id"` + AccessList *models.AccessList `json:"access_list,omitempty"` + Locations []models.Location `json:"locations"` + AdvancedConfig string `json:"advanced_config"` + AdvancedConfigBackup string `json:"advanced_config_backup"` + ForwardAuthEnabled bool `json:"forward_auth_enabled"` + WAFDisabled bool `json:"waf_disabled"` + SecurityHeaderProfileID *uint `json:"security_header_profile_id"` + SecurityHeaderProfile *models.SecurityHeaderProfile `json:"security_header_profile,omitempty"` + SecurityHeadersEnabled bool `json:"security_headers_enabled"` + SecurityHeadersCustom string `json:"security_headers_custom"` + EnableStandardHeaders *bool `json:"enable_standard_headers,omitempty"` + DNSProviderID *uint `json:"dns_provider_id,omitempty"` + DNSProvider *models.DNSProvider `json:"dns_provider,omitempty"` + UseDNSChallenge bool `json:"use_dns_challenge"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Warnings []ProxyHostWarning `json:"warnings,omitempty"` +} + +// NewProxyHostResponse creates a ProxyHostResponse from a ProxyHost model. +func NewProxyHostResponse(host *models.ProxyHost, warnings []ProxyHostWarning) ProxyHostResponse { + return ProxyHostResponse{ + UUID: host.UUID, + Name: host.Name, + DomainNames: host.DomainNames, + ForwardScheme: host.ForwardScheme, + ForwardHost: host.ForwardHost, + ForwardPort: host.ForwardPort, + SSLForced: host.SSLForced, + HTTP2Support: host.HTTP2Support, + HSTSEnabled: host.HSTSEnabled, + HSTSSubdomains: host.HSTSSubdomains, + BlockExploits: host.BlockExploits, + WebsocketSupport: host.WebsocketSupport, + Application: host.Application, + Enabled: host.Enabled, + CertificateID: host.CertificateID, + Certificate: host.Certificate, + AccessListID: host.AccessListID, + AccessList: host.AccessList, + Locations: host.Locations, + AdvancedConfig: host.AdvancedConfig, + AdvancedConfigBackup: host.AdvancedConfigBackup, + ForwardAuthEnabled: host.ForwardAuthEnabled, + WAFDisabled: host.WAFDisabled, + SecurityHeaderProfileID: host.SecurityHeaderProfileID, + SecurityHeaderProfile: host.SecurityHeaderProfile, + SecurityHeadersEnabled: host.SecurityHeadersEnabled, + SecurityHeadersCustom: host.SecurityHeadersCustom, + EnableStandardHeaders: host.EnableStandardHeaders, + DNSProviderID: host.DNSProviderID, + DNSProvider: host.DNSProvider, + UseDNSChallenge: host.UseDNSChallenge, + CreatedAt: host.CreatedAt, + UpdatedAt: host.UpdatedAt, + Warnings: warnings, + } } // generateForwardHostWarnings checks the forward_host value and returns advisory warnings. @@ -176,10 +250,7 @@ func (h *ProxyHostHandler) Create(c *gin.Context) { // Return response with warnings if any if len(warnings) > 0 { - c.JSON(http.StatusCreated, ProxyHostResponse{ - ProxyHost: host, - Warnings: warnings, - }) + c.JSON(http.StatusCreated, NewProxyHostResponse(&host, warnings)) return } @@ -448,10 +519,7 @@ func (h *ProxyHostHandler) Update(c *gin.Context) { // Return response with warnings if any if len(warnings) > 0 { - c.JSON(http.StatusOK, ProxyHostResponse{ - ProxyHost: *host, - Warnings: warnings, - }) + c.JSON(http.StatusOK, NewProxyHostResponse(host, warnings)) return } diff --git a/backend/internal/models/access_list.go b/backend/internal/models/access_list.go index 6768dd8a..09a583b7 100644 --- a/backend/internal/models/access_list.go +++ b/backend/internal/models/access_list.go @@ -7,7 +7,7 @@ import ( // AccessList defines IP-based or auth-based access control rules // that can be applied to proxy hosts. type AccessList struct { - ID uint `json:"id" gorm:"primaryKey"` + ID uint `json:"-" gorm:"primaryKey"` UUID string `json:"uuid" gorm:"uniqueIndex"` Name string `json:"name" gorm:"index"` Description string `json:"description"` diff --git a/backend/internal/models/caddy_config.go b/backend/internal/models/caddy_config.go index 4b4ea08e..07cc0276 100644 --- a/backend/internal/models/caddy_config.go +++ b/backend/internal/models/caddy_config.go @@ -6,8 +6,8 @@ import ( // CaddyConfig stores an audit trail of Caddy configuration changes. type CaddyConfig struct { - ID uint `json:"id" gorm:"primaryKey"` - ConfigHash string `json:"config_hash" gorm:"index"` + ID uint `json:"-" gorm:"primaryKey"` + ConfigHash string `json:"-" gorm:"index"` AppliedAt time.Time `json:"applied_at"` Success bool `json:"success"` ErrorMsg string `json:"error_msg"` diff --git a/backend/internal/models/crowdsec_console_enrollment.go b/backend/internal/models/crowdsec_console_enrollment.go index 6a829784..5f014f2a 100644 --- a/backend/internal/models/crowdsec_console_enrollment.go +++ b/backend/internal/models/crowdsec_console_enrollment.go @@ -4,7 +4,7 @@ import "time" // CrowdsecConsoleEnrollment stores enrollment status and secrets for console registration. type CrowdsecConsoleEnrollment struct { - ID uint `json:"id" gorm:"primarykey"` + ID uint `json:"-" gorm:"primaryKey"` UUID string `json:"uuid" gorm:"uniqueIndex"` Status string `json:"status" gorm:"index"` Tenant string `json:"tenant"` diff --git a/backend/internal/models/crowdsec_preset_event.go b/backend/internal/models/crowdsec_preset_event.go index 3e98d9d3..8491a651 100644 --- a/backend/internal/models/crowdsec_preset_event.go +++ b/backend/internal/models/crowdsec_preset_event.go @@ -4,7 +4,7 @@ import "time" // CrowdsecPresetEvent captures audit trail for preset pull/apply events. type CrowdsecPresetEvent struct { - ID uint `gorm:"primarykey" json:"id"` + ID uint `gorm:"primarykey" json:"-"` Slug string `json:"slug"` Action string `json:"action"` Status string `json:"status"` diff --git a/backend/internal/models/dns_provider.go b/backend/internal/models/dns_provider.go index 65f51d7b..b6d84624 100644 --- a/backend/internal/models/dns_provider.go +++ b/backend/internal/models/dns_provider.go @@ -8,7 +8,7 @@ import ( // DNSProvider represents a DNS provider configuration for ACME DNS-01 challenges. // Credentials are stored encrypted at rest using AES-256-GCM. type DNSProvider struct { - ID uint `json:"id" gorm:"primaryKey"` + ID uint `json:"-" gorm:"primaryKey"` UUID string `json:"uuid" gorm:"uniqueIndex;size:36"` Name string `json:"name" gorm:"index;not null;size:255"` ProviderType string `json:"provider_type" gorm:"index;not null;size:50"` diff --git a/backend/internal/models/dns_provider_credential.go b/backend/internal/models/dns_provider_credential.go index df35c9ec..b8fde57f 100644 --- a/backend/internal/models/dns_provider_credential.go +++ b/backend/internal/models/dns_provider_credential.go @@ -8,7 +8,7 @@ import ( // DNSProviderCredential represents a zone-specific credential set for a DNS provider. // This allows different credentials to be used for different domains/zones within the same provider. type DNSProviderCredential struct { - ID uint `json:"id" gorm:"primaryKey"` + ID uint `json:"-" gorm:"primaryKey"` UUID string `json:"uuid" gorm:"uniqueIndex;size:36"` DNSProviderID uint `json:"dns_provider_id" gorm:"index;not null"` DNSProvider *DNSProvider `json:"dns_provider,omitempty" gorm:"foreignKey:DNSProviderID"` diff --git a/backend/internal/models/domain.go b/backend/internal/models/domain.go index 76b0945f..4b7e8399 100644 --- a/backend/internal/models/domain.go +++ b/backend/internal/models/domain.go @@ -8,7 +8,7 @@ import ( ) type Domain struct { - ID uint `json:"id" gorm:"primarykey"` + ID uint `json:"-" gorm:"primaryKey"` UUID string `json:"uuid" gorm:"uniqueIndex;not null"` Name string `json:"name" gorm:"uniqueIndex;not null"` CreatedAt time.Time `json:"created_at"` diff --git a/backend/internal/models/emergency_token.go b/backend/internal/models/emergency_token.go index 945e7281..0c6a15db 100644 --- a/backend/internal/models/emergency_token.go +++ b/backend/internal/models/emergency_token.go @@ -7,7 +7,7 @@ import ( // EmergencyToken stores metadata for database-backed emergency access tokens. // Tokens are stored as bcrypt hashes for security. type EmergencyToken struct { - ID uint `json:"id" gorm:"primaryKey"` + ID uint `json:"-" gorm:"primaryKey"` TokenHash string `json:"-" gorm:"type:text;not null"` // bcrypt hash, never exposed in JSON CreatedAt time.Time `json:"created_at"` ExpiresAt *time.Time `json:"expires_at"` // NULL = never expires diff --git a/backend/internal/models/import_session.go b/backend/internal/models/import_session.go index 8ae7896a..2a946b0d 100644 --- a/backend/internal/models/import_session.go +++ b/backend/internal/models/import_session.go @@ -7,7 +7,7 @@ import ( // ImportSession tracks Caddyfile import operations with pending state // until user reviews and confirms via UI. type ImportSession struct { - ID uint `json:"id" gorm:"primaryKey"` + ID uint `json:"-" gorm:"primaryKey"` UUID string `json:"uuid" gorm:"uniqueIndex"` SourceFile string `json:"source_file"` // Path to original Caddyfile Status string `json:"status" gorm:"default:'pending'"` // "pending", "reviewing", "committed", "rejected", "failed" diff --git a/backend/internal/models/location.go b/backend/internal/models/location.go index 36fe9aa4..839b3e9c 100644 --- a/backend/internal/models/location.go +++ b/backend/internal/models/location.go @@ -6,7 +6,7 @@ import ( // Location represents a custom path-based proxy configuration within a ProxyHost. type Location struct { - ID uint `json:"id" gorm:"primaryKey"` + ID uint `json:"-" gorm:"primaryKey"` UUID string `json:"uuid" gorm:"uniqueIndex;not null"` ProxyHostID uint `json:"proxy_host_id" gorm:"not null;index"` Path string `json:"path" gorm:"not null;index"` // e.g., /api, /admin diff --git a/backend/internal/models/manual_challenge.go b/backend/internal/models/manual_challenge.go index d325939b..f8f9ce97 100644 --- a/backend/internal/models/manual_challenge.go +++ b/backend/internal/models/manual_challenge.go @@ -39,8 +39,8 @@ type ManualChallenge struct { // Example: "_acme-challenge.example.com" FQDN string `json:"fqdn" gorm:"index;not null;size:255"` - // Token is the ACME challenge token (for identification). - Token string `json:"token" gorm:"size:255"` + // Token is the ACME challenge token (for identification), never exposed in JSON. + Token string `json:"-" gorm:"size:255"` // Value is the TXT record value that must be created. Value string `json:"value" gorm:"not null;size:255"` diff --git a/backend/internal/models/plugin.go b/backend/internal/models/plugin.go index c80277bf..41472e37 100644 --- a/backend/internal/models/plugin.go +++ b/backend/internal/models/plugin.go @@ -5,7 +5,7 @@ import "time" // Plugin represents an installed DNS provider plugin. // This tracks both external .so plugins and their load status. type Plugin struct { - ID uint `json:"id" gorm:"primaryKey"` + ID uint `json:"-" gorm:"primaryKey"` UUID string `json:"uuid" gorm:"uniqueIndex;size:36"` Name string `json:"name" gorm:"not null;size:255"` Type string `json:"type" gorm:"uniqueIndex;not null;size:100"` diff --git a/backend/internal/models/proxy_host.go b/backend/internal/models/proxy_host.go index fe75e58b..d31bfe0b 100644 --- a/backend/internal/models/proxy_host.go +++ b/backend/internal/models/proxy_host.go @@ -6,7 +6,7 @@ import ( // ProxyHost represents a reverse proxy configuration. type ProxyHost struct { - ID uint `json:"id" gorm:"primaryKey"` + ID uint `json:"-" gorm:"primaryKey"` UUID string `json:"uuid" gorm:"uniqueIndex;not null"` Name string `json:"name" gorm:"index"` DomainNames string `json:"domain_names" gorm:"not null;index"` // Comma-separated list diff --git a/backend/internal/models/remote_server.go b/backend/internal/models/remote_server.go index 7939c8fe..a1b5d528 100644 --- a/backend/internal/models/remote_server.go +++ b/backend/internal/models/remote_server.go @@ -7,7 +7,7 @@ import ( // RemoteServer represents a known backend server that can be selected // when creating proxy hosts, eliminating manual IP/port entry. type RemoteServer struct { - ID uint `json:"id" gorm:"primaryKey"` + ID uint `json:"-" gorm:"primaryKey"` UUID string `json:"uuid" gorm:"uniqueIndex"` Name string `json:"name" gorm:"index"` Provider string `json:"provider" gorm:"index"` // e.g., "docker", "vm", "cloud", "manual" diff --git a/backend/internal/models/security_audit.go b/backend/internal/models/security_audit.go index adf48d9c..4930d766 100644 --- a/backend/internal/models/security_audit.go +++ b/backend/internal/models/security_audit.go @@ -6,7 +6,7 @@ import ( // SecurityAudit records admin actions or important changes related to security. type SecurityAudit struct { - ID uint `json:"id" gorm:"primaryKey"` + ID uint `json:"-" gorm:"primaryKey"` UUID string `json:"uuid" gorm:"uniqueIndex"` Actor string `json:"actor" gorm:"index"` Action string `json:"action"` diff --git a/backend/internal/models/security_config.go b/backend/internal/models/security_config.go index 8825d7ff..893b522f 100644 --- a/backend/internal/models/security_config.go +++ b/backend/internal/models/security_config.go @@ -7,7 +7,7 @@ import ( // SecurityConfig represents global Cerberus/CrowdSec/WAF/RateLimit settings // used by the server and propagated into the generated Caddy config. type SecurityConfig struct { - ID uint `json:"id" gorm:"primaryKey"` + ID uint `json:"-" gorm:"primaryKey"` UUID string `json:"uuid" gorm:"uniqueIndex"` Name string `json:"name" gorm:"index"` Enabled bool `json:"enabled" gorm:"index"` diff --git a/backend/internal/models/security_decision.go b/backend/internal/models/security_decision.go index a5263b93..709c6c86 100644 --- a/backend/internal/models/security_decision.go +++ b/backend/internal/models/security_decision.go @@ -7,7 +7,7 @@ import ( // SecurityDecision stores a decision/action taken by CrowdSec/WAF/RateLimit or manual // override so it can be audited and surfaced in the UI. type SecurityDecision struct { - ID uint `json:"id" gorm:"primaryKey"` + ID uint `json:"-" gorm:"primaryKey"` UUID string `json:"uuid" gorm:"uniqueIndex"` Source string `json:"source" gorm:"index"` // e.g., crowdsec, waf, ratelimit, manual Action string `json:"action" gorm:"index"` // allow, block, challenge diff --git a/backend/internal/models/security_header_profile.go b/backend/internal/models/security_header_profile.go index 3e0f070d..ddd1307a 100644 --- a/backend/internal/models/security_header_profile.go +++ b/backend/internal/models/security_header_profile.go @@ -7,7 +7,7 @@ import ( // SecurityHeaderProfile stores reusable security header configurations. // Users can create profiles and assign them to proxy hosts. type SecurityHeaderProfile struct { - ID uint `json:"id" gorm:"primaryKey"` + ID uint `json:"-" gorm:"primaryKey"` UUID string `json:"uuid" gorm:"uniqueIndex;not null"` Name string `json:"name" gorm:"index;not null"` diff --git a/backend/internal/models/security_ruleset.go b/backend/internal/models/security_ruleset.go index 1c441503..fa681f54 100644 --- a/backend/internal/models/security_ruleset.go +++ b/backend/internal/models/security_ruleset.go @@ -6,7 +6,7 @@ import ( // SecurityRuleSet stores metadata about WAF/CrowdSec rule sets that the server can download and apply. type SecurityRuleSet struct { - ID uint `json:"id" gorm:"primaryKey"` + ID uint `json:"-" gorm:"primaryKey"` UUID string `json:"uuid" gorm:"uniqueIndex"` Name string `json:"name" gorm:"index"` SourceURL string `json:"source_url" gorm:"type:text"` diff --git a/backend/internal/models/setting.go b/backend/internal/models/setting.go index 4465a1e5..03b4060c 100644 --- a/backend/internal/models/setting.go +++ b/backend/internal/models/setting.go @@ -7,7 +7,7 @@ import ( // Setting stores global application configuration as key-value pairs. // Used for system-wide preferences, feature flags, and runtime config. type Setting struct { - ID uint `json:"id" gorm:"primaryKey"` + ID uint `json:"-" gorm:"primaryKey"` Key string `json:"key" gorm:"uniqueIndex"` Value string `json:"value" gorm:"type:text"` Type string `json:"type" gorm:"index"` // "string", "int", "bool", "json" diff --git a/backend/internal/models/ssl_certificate.go b/backend/internal/models/ssl_certificate.go index 659cfad5..705eadda 100644 --- a/backend/internal/models/ssl_certificate.go +++ b/backend/internal/models/ssl_certificate.go @@ -7,7 +7,7 @@ import ( // SSLCertificate represents TLS certificates managed by Charon. // Can be Let's Encrypt auto-generated or custom uploaded certs. type SSLCertificate struct { - ID uint `json:"id" gorm:"primaryKey"` + ID uint `json:"-" gorm:"primaryKey"` UUID string `json:"uuid" gorm:"uniqueIndex"` Name string `json:"name" gorm:"index"` Provider string `json:"provider" gorm:"index"` // "letsencrypt", "custom", "self-signed" diff --git a/backend/internal/models/uptime.go b/backend/internal/models/uptime.go index dd5bb6e9..bda9533a 100644 --- a/backend/internal/models/uptime.go +++ b/backend/internal/models/uptime.go @@ -36,7 +36,7 @@ type UptimeMonitor struct { } type UptimeHeartbeat struct { - ID uint `gorm:"primaryKey" json:"id"` + ID uint `gorm:"primaryKey" json:"-"` MonitorID string `json:"monitor_id" gorm:"index"` Status string `json:"status"` // up, down Latency int64 `json:"latency"` diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go index 64262292..b6f1d51c 100644 --- a/backend/internal/models/user.go +++ b/backend/internal/models/user.go @@ -20,10 +20,10 @@ const ( // User represents authenticated users with role-based access control. // Supports local auth, SSO integration, and invite-based onboarding. type User struct { - ID uint `json:"id" gorm:"primaryKey"` + ID uint `json:"-" gorm:"primaryKey"` UUID string `json:"uuid" gorm:"uniqueIndex"` Email string `json:"email" gorm:"uniqueIndex"` - APIKey string `json:"api_key" gorm:"uniqueIndex"` // For external API access + APIKey string `json:"-" gorm:"uniqueIndex"` // For external API access, never exposed in JSON PasswordHash string `json:"-"` // Never serialize password hash Name string `json:"name"` Role string `json:"role" gorm:"default:'user'"` // "admin", "user", "viewer" diff --git a/backend/internal/services/dns_provider_service.go b/backend/internal/services/dns_provider_service.go index 85912c98..6c4dfc19 100644 --- a/backend/internal/services/dns_provider_service.go +++ b/backend/internal/services/dns_provider_service.go @@ -52,9 +52,46 @@ type UpdateDNSProviderRequest struct { } // DNSProviderResponse represents the API response for a DNS provider. +// Uses explicit fields to avoid exposing internal database IDs. type DNSProviderResponse struct { - models.DNSProvider - HasCredentials bool `json:"has_credentials"` + UUID string `json:"uuid"` + Name string `json:"name"` + ProviderType string `json:"provider_type"` + Enabled bool `json:"enabled"` + IsDefault bool `json:"is_default"` + UseMultiCredentials bool `json:"use_multi_credentials"` + KeyVersion int `json:"key_version"` + PropagationTimeout int `json:"propagation_timeout"` + PollingInterval int `json:"polling_interval"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + SuccessCount int `json:"success_count"` + FailureCount int `json:"failure_count"` + LastError string `json:"last_error,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + HasCredentials bool `json:"has_credentials"` +} + +// NewDNSProviderResponse creates a DNSProviderResponse from a DNSProvider model. +func NewDNSProviderResponse(provider *models.DNSProvider) DNSProviderResponse { + return DNSProviderResponse{ + UUID: provider.UUID, + Name: provider.Name, + ProviderType: provider.ProviderType, + Enabled: provider.Enabled, + IsDefault: provider.IsDefault, + UseMultiCredentials: provider.UseMultiCredentials, + KeyVersion: provider.KeyVersion, + PropagationTimeout: provider.PropagationTimeout, + PollingInterval: provider.PollingInterval, + LastUsedAt: provider.LastUsedAt, + SuccessCount: provider.SuccessCount, + FailureCount: provider.FailureCount, + LastError: provider.LastError, + CreatedAt: provider.CreatedAt, + UpdatedAt: provider.UpdatedAt, + HasCredentials: provider.CredentialsEncrypted != "", + } } // TestResult represents the result of testing DNS provider credentials. diff --git a/docs/plans/gorm_security_remediation_plan.md b/docs/plans/gorm_security_remediation_plan.md new file mode 100644 index 00000000..22423b9b --- /dev/null +++ b/docs/plans/gorm_security_remediation_plan.md @@ -0,0 +1,486 @@ +# GORM Security Issues Remediation Plan + +**Status:** 🟡 **READY TO START** +**Created:** 2026-01-28 +**Scanner Report:** 60 issues detected (28 CRITICAL, 2 HIGH, 33 MEDIUM) +**Estimated Total Time:** 8-12 hours +**Target Completion:** 2026-01-29 + +--- + +## Executive Summary + +The GORM Security Scanner detected **60 pre-existing security issues** in the codebase. This plan provides a systematic approach to fix all issues, organized by priority and type. + +**Issue Breakdown:** +- 🔴 **28 CRITICAL**: 22 ID leaks + 3 exposed secrets + 2 DTO issues + 1 emergency field +- 🔵 **33 MEDIUM**: Missing primary key tags (informational) + +**Approach:** +1. Fix all CRITICAL issues (models, secrets, DTOs) +2. Optionally address MEDIUM issues (missing tags) +3. Verify with scanner +4. Run full test suite +5. Update CI to blocking mode + +--- + +## Phase 1: Fix ID Leaks in Models (6-8 hours) + +### Priority: 🔴 CRITICAL +### Estimated Time: 6-8 hours (22 models × 15-20 min each) + +**Pattern:** +```go +// BEFORE (Vulnerable): +ID uint `json:"id" gorm:"primaryKey"` + +// AFTER (Secure): +ID uint `json:"-" gorm:"primaryKey"` +``` + +### Task Checklist + +#### Core Models (6 models) +- [ ] **User** (`internal/models/user.go:23`) +- [ ] **ProxyHost** (`internal/models/proxy_host.go:9`) +- [ ] **Domain** (`internal/models/domain.go:11`) +- [ ] **DNSProvider** (`internal/models/dns_provider.go:11`) +- [ ] **SSLCertificate** (`internal/models/ssl_certificate.go:10`) +- [ ] **AccessList** (`internal/models/access_list.go:10`) + +#### Security Models (5 models) +- [ ] **SecurityConfig** (`internal/models/security_config.go:10`) +- [ ] **SecurityAudit** (`internal/models/security_audit.go:9`) +- [ ] **SecurityDecision** (`internal/models/security_decision.go:10`) +- [ ] **SecurityHeaderProfile** (`internal/models/security_header_profile.go:10`) +- [ ] **SecurityRuleset** (`internal/models/security_ruleset.go:9`) + +#### Infrastructure Models (5 models) +- [ ] **Location** (`internal/models/location.go:9`) +- [ ] **Plugin** (`internal/models/plugin.go:8`) +- [ ] **RemoteServer** (`internal/models/remote_server.go:10`) +- [ ] **ImportSession** (`internal/models/import_session.go:10`) +- [ ] **Setting** (`internal/models/setting.go:10`) + +#### Integration Models (3 models) +- [ ] **CrowdsecConsoleEnrollment** (`internal/models/crowdsec_console_enrollment.go:7`) +- [ ] **CrowdsecPresetEvent** (`internal/models/crowdsec_preset_event.go:7`) +- [ ] **CaddyConfig** (`internal/models/caddy_config.go:9`) + +#### Provider & Monitoring Models (3 models) +- [ ] **DNSProviderCredential** (`internal/models/dns_provider_credential.go:11`) +- [ ] **EmergencyToken** (`internal/models/emergency_token.go:10`) +- [ ] **UptimeHeartbeat** (`internal/models/uptime.go:39`) + +### Post-Model-Update Tasks + +After changing each model: + +1. **Update API handlers** that reference `.ID`: + ```bash + # Find usages: + grep -r "\.ID" backend/internal/api/handlers/ + grep -r "\"id\":" backend/internal/api/handlers/ + ``` + +2. **Update service layer** queries using `.ID`: + ```bash + # Find usages: + grep -r "\.ID" backend/internal/services/ + ``` + +3. **Verify UUID field exists and is exposed**: + ```go + UUID string `json:"uuid" gorm:"uniqueIndex"` + ``` + +4. **Update tests** referencing `.ID`: + ```bash + # Find test failures: + go test ./... -run TestModel + ``` + +--- + +## Phase 2: Fix Exposed Secrets (30 minutes) + +### Priority: 🔴 CRITICAL +### Estimated Time: 30 minutes (3 fields) + +**Pattern:** +```go +// BEFORE (Vulnerable): +APIKey string `json:"api_key"` + +// AFTER (Secure): +APIKey string `json:"-"` +``` + +### Task Checklist + +- [ ] **User.APIKey** - Change to `json:"-"` + - Location: `internal/models/user.go` + - Verify: API key is never serialized to JSON + +- [ ] **ManualChallenge.Token** - Change to `json:"-"` + - Location: `internal/services/manual_challenge_service.go:337` + - Verify: Challenge token is never exposed + +- [ ] **CaddyConfig.ConfigHash** - Change to `json:"-"` + - Location: `internal/models/caddy_config.go` + - Verify: Config hash is never exposed + +### Verification + +```bash +# Ensure no secrets are exposed: +./scripts/scan-gorm-security.sh --report | grep "CRITICAL.*Secret" +# Should return: 0 results +``` + +--- + +## Phase 3: Fix Response DTO Embedding (1-2 hours) + +### Priority: 🟡 HIGH +### Estimated Time: 1-2 hours (2 structs) + +**Problem:** Response structs embed models, inheriting exposed IDs + +**Pattern:** +```go +// BEFORE (Inherits ID exposure): +type ProxyHostResponse struct { + models.ProxyHost // Embeds entire model + Warnings []ProxyHostWarning `json:"warnings,omitempty"` +} + +// AFTER (Explicit fields): +type ProxyHostResponse struct { + UUID string `json:"uuid"` + DomainNames []string `json:"domain_names"` + ForwardHost string `json:"forward_host"` + // ... other fields explicitly defined + Warnings []ProxyHostWarning `json:"warnings,omitempty"` +} +``` + +### Task Checklist + +- [ ] **ProxyHostResponse** (`internal/api/handlers/proxy_host_handler.go:31`) + - [ ] Replace `models.ProxyHost` embedding with explicit fields + - [ ] Include `UUID string json:"uuid"` (expose external ID) + - [ ] Exclude `ID uint` (hide internal ID) + - [ ] Update all handler functions creating ProxyHostResponse + - [ ] Update tests + +- [ ] **DNSProviderResponse** (`internal/services/dns_provider_service.go:56`) + - [ ] Replace `models.DNSProvider` embedding with explicit fields + - [ ] Include `UUID string json:"uuid"` (expose external ID) + - [ ] Exclude `ID uint` (hide internal ID) + - [ ] Keep `HasCredentials bool json:"has_credentials"` + - [ ] Update all service functions creating DNSProviderResponse + - [ ] Update tests + +### Post-DTO-Update Tasks + +1. **Update handler logic**: + ```go + // Map model to response: + response := ProxyHostResponse{ + UUID: model.UUID, + DomainNames: model.DomainNames, + // ... explicit field mapping + } + ``` + +2. **Frontend coordination** (if needed): + - Frontend likely uses `uuid` already, not `id` + - Verify API client expectations + - Update TypeScript types if needed + +--- + +## Phase 4: Fix Emergency Break Glass Field (15 minutes) + +### Priority: 🔴 CRITICAL +### Estimated Time: 15 minutes (1 field) + +**Issue:** `EmergencyConfig.BreakGlassEnabled` exposed + +### Task Checklist + +- [ ] **EmergencyConfig.BreakGlassEnabled** + - Location: Find in security models + - Change: Add `json:"-"` or verify it's informational only + - Verify: Emergency status not leaking sensitive info + +--- + +## Phase 5: Optional - Fix Missing Primary Key Tags (1 hour) + +### Priority: 🔵 MEDIUM (Informational) +### Estimated Time: 1 hour (33 fields) +### Decision: **OPTIONAL** - Can defer to separate issue + +**Pattern:** +```go +// BEFORE (Missing tag): +ID uint `json:"-"` + +// AFTER (Explicit tag): +ID uint `json:"-" gorm:"primaryKey"` +``` + +**Impact:** Missing tags don't cause security issues, but explicit tags improve: +- Query optimizer performance +- Code clarity +- GORM auto-migration accuracy + +**Recommendation:** Create separate issue for this backlog item. + +--- + +## Phase 6: Verification & Testing (1-2 hours) + +### Task Checklist + +#### 1. Scanner Verification (5 minutes) +- [ ] Run scanner in report mode: + ```bash + ./scripts/scan-gorm-security.sh --report + ``` +- [ ] Verify: **0 CRITICAL** and **0 HIGH** issues remain +- [ ] Optional: Verify **0 MEDIUM** if Phase 5 completed + +#### 2. Backend Tests (30 minutes) +- [ ] Run full backend test suite: + ```bash + cd backend && go test ./... -v + ``` +- [ ] Verify: All tests pass +- [ ] Fix any test failures related to ID → UUID changes + +#### 3. Backend Coverage (15 minutes) +- [ ] Run coverage tests: + ```bash + .github/skills/scripts/skill-runner.sh test-backend-coverage + ``` +- [ ] Verify: Coverage ≥85% +- [ ] Address any coverage drops + +#### 4. Frontend Tests (if API changes) (30 minutes) +- [ ] TypeScript type check: + ```bash + cd frontend && npm run type-check + ``` +- [ ] Run frontend tests: + ```bash + .github/skills/scripts/skill-runner.sh test-frontend-coverage + ``` +- [ ] Verify: All tests pass + +#### 5. Integration Tests (15 minutes) +- [ ] Start Docker environment: + ```bash + docker-compose up -d + ``` +- [ ] Test affected endpoints: + - GET /api/proxy-hosts (verify UUID, no ID) + - GET /api/dns-providers (verify UUID, no ID) + - GET /api/users/me (verify no APIKey exposed) + +#### 6. Final Scanner Check (5 minutes) +- [ ] Run scanner in check mode: + ```bash + ./scripts/scan-gorm-security.sh --check + echo "Exit code: $?" # Must be 0 + ``` +- [ ] Verify: Exit code **0** (no issues) + +--- + +## Phase 7: Enable Blocking in Pre-commit (5 minutes) + +### Task Checklist + +- [ ] Update `.pre-commit-config.yaml`: + ```yaml + # CHANGE FROM: + stages: [manual] + + # CHANGE TO: + stages: [commit] + ``` + +- [ ] Test pre-commit hook: + ```bash + pre-commit run --all-files + ``` + +- [ ] Verify: Scanner runs on commit and passes + +--- + +## Phase 8: Update Documentation (15 minutes) + +### Task Checklist + +- [ ] Update `docs/plans/gorm_security_remediation_plan.md`: + - Mark all tasks as complete + - Add "COMPLETED" status and completion date + +- [ ] Update `docs/implementation/gorm_security_scanner_complete.md`: + - Update "Current Findings" section to show 0 issues + - Update pre-commit status to "Blocking" + +- [ ] Update `docs/reports/gorm_scanner_qa_report.md`: + - Add remediation completion note + +- [ ] Update `CHANGELOG.md`: + - Add entry: "Fixed 60 GORM security issues (ID leaks, exposed secrets)" + +--- + +## Success Criteria + +### Technical Success ✅ +- [ ] Scanner reports **0 CRITICAL** and **0 HIGH** issues +- [ ] All backend tests pass +- [ ] Coverage maintained (≥85%) +- [ ] Pre-commit hook in blocking mode +- [ ] CI pipeline passes + +### Security Success ✅ +- [ ] No internal database IDs exposed via JSON +- [ ] No API keys/tokens/secrets exposed +- [ ] Response DTOs use explicit fields +- [ ] UUID fields used for all external references + +### Documentation Success ✅ +- [ ] Remediation plan marked complete +- [ ] Scanner documentation updated +- [ ] CHANGELOG updated +- [ ] Blocking mode documented + +--- + +## Risk Mitigation + +### Risk: Breaking API Changes + +**Mitigation:** +- Frontend likely already uses `uuid` field, not `id` +- Test all API endpoints after changes +- Check frontend API client for `id` references +- Coordinate with frontend team if breaking changes needed + +### Risk: Test Failures + +**Mitigation:** +- Tests may hardcode `.ID` references +- Search and replace `.ID` → `.UUID` in tests +- Verify test fixtures use UUIDs +- Run tests incrementally after each model update + +### Risk: Handler/Service Breakage + +**Mitigation:** +- Use grep to find all `.ID` references before changing models +- Update handlers/services at the same time as models +- Test each endpoint after updating +- Use compiler errors as a guide (`.ID` will fail to serialize) + +--- + +## Time Estimates Summary + +| Phase | Priority | Time | Can Defer? | +|-------|----------|------|------------| +| Phase 1: ID Leaks (22 models) | 🔴 CRITICAL | 6-8 hours | ❌ No | +| Phase 2: Secrets (3 fields) | 🔴 CRITICAL | 30 min | ❌ No | +| Phase 3: DTO Embedding (2 structs) | 🟡 HIGH | 1-2 hours | ❌ No | +| Phase 4: Emergency Field (1 field) | 🔴 CRITICAL | 15 min | ❌ No | +| Phase 5: Missing Tags (33 fields) | 🔵 MEDIUM | 1 hour | ✅ Yes | +| Phase 6: Verification & Testing | Required | 1-2 hours | ❌ No | +| Phase 7: Enable Blocking | Required | 5 min | ❌ No | +| Phase 8: Documentation | Required | 15 min | ❌ No | +| **TOTAL (Required)** | | **9.5-13 hours** | | +| **TOTAL (with optional)** | | **10.5-14 hours** | | + +--- + +## Quick Start Guide for Tomorrow + +### Morning Session (4-5 hours) + +1. **Start Scanner** to see baseline: + ```bash + ./scripts/scan-gorm-security.sh --report | tee gorm-before.txt + ``` + +2. **Tackle Core Models** (User, ProxyHost, Domain, DNSProvider, SSLCertificate, AccessList): + - For each model: + - Change `json:"id"` → `json:"-"` + - Verify `UUID` field exists + - Run: `go test ./internal/models/` + - Fix compilation errors + - Batch commit: "fix: hide internal IDs in core GORM models" + +3. **Coffee Break** ☕ + +### Afternoon Session (4-5 hours) + +4. **Tackle Remaining Models** (Security, Infrastructure, Integration models): + - Same pattern as morning + - Batch commit: "fix: hide internal IDs in remaining GORM models" + +5. **Fix Exposed Secrets** (Phase 2): + - Quick 30-minute task + - Commit: "fix: hide API keys and sensitive fields" + +6. **Fix Response DTOs** (Phase 3): + - ProxyHostResponse + - DNSProviderResponse + - Commit: "refactor: use explicit fields in response DTOs" + +7. **Final Verification** (Phase 6): + - Run scanner: `./scripts/scan-gorm-security.sh --check` + - Run tests: `go test ./...` + - Run coverage: `.github/skills/scripts/skill-runner.sh test-backend-coverage` + +8. **Enable & Document** (Phases 7-8): + - Update pre-commit config to blocking mode + - Update all docs + - Final commit: "chore: enable GORM security scanner blocking mode" + +--- + +## Post-Remediation + +After all fixes are complete: + +1. **Create PR** with all remediation commits +2. **CI will pass** (scanner finds 0 issues) +3. **Merge to main** +4. **Close chore item** in `docs/plans/chores.md` +5. **Celebrate** 🎉 - Charon is now more secure! + +--- + +## Notes + +- **Current Branch:** `feature/beta-release` +- **Scanner Location:** `scripts/scan-gorm-security.sh` +- **Documentation:** `docs/implementation/gorm_security_scanner_complete.md` +- **QA Report:** `docs/reports/gorm_scanner_qa_report.md` + +**Tips:** +- Work in small batches (5-6 models at a time) +- Test incrementally (don't fix everything before testing) +- Use grep to find all `.ID` references before each change +- Commit frequently to make rollback easier if needed +- Take breaks - this is tedious but important work! + +**Good luck tomorrow! 💪** diff --git a/scripts/scan-gorm-security.sh b/scripts/scan-gorm-security.sh index 1b73e879..ca91bab2 100755 --- a/scripts/scan-gorm-security.sh +++ b/scripts/scan-gorm-security.sh @@ -331,7 +331,8 @@ detect_missing_primary_key() { report_issue "MEDIUM" "MISSING-PK" "$file" "$line_num" "${struct_name:-Unknown}" \ "ID Field Missing Primary Key Tag" \ "💡 Fix: Add 'primaryKey' to gorm tag: gorm:\"primaryKey\"" - done < <(grep -n 'ID.*gorm:' "$file" 2>/dev/null || true) + # Only match primary key ID field (not foreign keys like CertificateID, AccessListID, etc.) + done < <(grep -n -E '^\s+ID\s+' "$file" 2>/dev/null || true) done < <(find "$SCAN_DIR/internal/models" -name "*.go" -type f 2>/dev/null || true) }