# Security Hardening Plan ## Current State Analysis **Issues Identified**: - No input validation middleware - API keys in plain text - No rate limiting per key - No audit logging - Secrets in configuration files - No CSP headers for management UI - Missing security headers - No SAST/DAST in CI ## Phase 1: Input Validation & Sanitization (Week 1) ### 1.1 Validation Middleware **File**: `internal/api/middleware/validation.go` ```go package middleware import ( "bytes" "io" "net/http" "strings" "github.com/gin-gonic/gin" "github.com/go-playground/validator/v10" ) var validate = validator.New() // RequestValidator validates incoming requests type RequestValidator struct { maxBodySize int64 } func NewRequestValidator(maxBodySize int64) *RequestValidator { return &RequestValidator{maxBodySize: maxBodySize} } func (v *RequestValidator) Validate() gin.HandlerFunc { return func(c *gin.Context) { // Check content type for POST/PUT/PATCH if c.Request.Method != "GET" && c.Request.Method != "DELETE" { contentType := c.ContentType() if contentType != "application/json" { c.AbortWithStatusJSON(http.StatusUnsupportedMediaType, gin.H{ "error": "Content-Type must be application/json", }) return } } // Limit body size c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, v.maxBodySize) c.Next() } } // SanitizeHeaders removes sensitive headers from logging type SanitizedHeader string const ( Authorization SanitizedHeader = "Authorization" Cookie SanitizedHeader = "Cookie" SetCookie SanitizedHeader = "Set-Cookie" ) func (v *RequestValidator) SanitizeHeaders(c *gin.Context) { // Create copy of headers with sensitive data redacted headers := make(http.Header) for k, values := range c.Request.Header { if strings.EqualFold(k, "Authorization") || strings.EqualFold(k, "Cookie") || strings.EqualFold(k, "X-API-Key") { headers.Set(k, "[REDACTED]") } else { headers[k] = values } } c.Set("sanitized_headers", headers) } ``` ### 1.2 SQL Injection Prevention **File**: `internal/infrastructure/persistence/sanitize.go` ```go package persistence import ( "regexp" "strings" ) var sqlInjectionPattern = regexp.MustCompile(`(?i)(union|select|insert|delete|update|drop|create|alter|exec|execute|;|--|/\*|\*/)`) // SanitizeIdentifier sanitizes database identifiers (table names, column names) func SanitizeIdentifier(identifier string) string { // Remove any characters that aren't alphanumeric or underscore clean := regexp.MustCompile(`[^a-zA-Z0-9_]`).ReplaceAllString(identifier, "") // Check for SQL keywords if sqlInjectionPattern.MatchString(clean) { return "" } return clean } // ValidateOrderBy validates ORDER BY clause parameters func ValidateOrderBy(field, direction string, allowedFields []string) (string, error) { // Validate field is in allowed list validField := false for _, f := range allowedFields { if strings.EqualFold(f, field) { validField = true break } } if !validField { return "", fmt.Errorf("invalid sort field: %s", field) } // Validate direction direction = strings.ToUpper(direction) if direction != "ASC" && direction != "DESC" { direction = "ASC" } return fmt.Sprintf("%s %s", SanitizeIdentifier(field), direction), nil } ``` ## Phase 2: Rate Limiting (Skipped) *This phase has been skipped as per user request (single user environment).* ## Phase 3: Secrets Management (Week 2) ### 3.1 Secrets Provider Interface **File**: `internal/infrastructure/secrets/provider.go` ```go package secrets import ( "context" "fmt" ) // Provider interface for secrets management type Provider interface { Get(ctx context.Context, key string) (string, error) GetJSON(ctx context.Context, key string, v interface{}) error Close() error } // Config for secrets provider type Config struct { Type string // vault, aws, gcp, azure, env Vault *VaultConfig AWS *AWSConfig GCP *GCPConfig Azure *AzureConfig } type VaultConfig struct { Address string Token string Path string } type AWSConfig struct { Region string ARN string } ``` ### 3.2 HashiCorp Vault Implementation **File**: `internal/infrastructure/secrets/vault.go` ```go package secrets import ( "context" "encoding/json" "fmt" vault "github.com/hashicorp/vault/api" ) type VaultProvider struct { client *vault.Client path string } func NewVaultProvider(cfg *VaultConfig) (*VaultProvider, error) { config := vault.DefaultConfig() config.Address = cfg.Address client, err := vault.NewClient(config) if err != nil { return nil, fmt.Errorf("create vault client: %w", err) } client.SetToken(cfg.Token) return &VaultProvider{ client: client, path: cfg.Path, }, nil } func (v *VaultProvider) Get(ctx context.Context, key string) (string, error) { secret, err := v.client.KVv2(v.path).Get(ctx, key) if err != nil { return "", fmt.Errorf("get secret %s: %w", key, err) } value, ok := secret.Data["value"].(string) if !ok { return "", fmt.Errorf("secret %s has no string value", key) } return value, nil } func (v *VaultProvider) GetJSON(ctx context.Context, key string, v interface{}) error { secret, err := v.client.KVv2(v.path).Get(ctx, key) if err != nil { return err } data, err := json.Marshal(secret.Data) if err != nil { return err } return json.Unmarshal(data, v) } func (v *VaultProvider) Close() error { return nil } ``` ### 3.3 Environment Variable Provider (Default) **File**: `internal/infrastructure/secrets/env.go` ```go package secrets import ( "context" "encoding/json" "fmt" "os" "strings" ) // EnvProvider reads secrets from environment variables type EnvProvider struct { prefix string } func NewEnvProvider(prefix string) *EnvProvider { return &EnvProvider{prefix: prefix} } func (e *EnvProvider) Get(ctx context.Context, key string) (string, error) { envKey := e.prefix + strings.ToUpper(strings.ReplaceAll(key, ".", "_")) value := os.Getenv(envKey) if value == "" { return "", fmt.Errorf("environment variable %s not set", envKey) } return value, nil } func (e *EnvProvider) GetJSON(ctx context.Context, key string, v interface{}) error { value, err := e.Get(ctx, key) if err != nil { return err } return json.Unmarshal([]byte(value), v) } func (e *EnvProvider) Close() error { return nil } ``` ## Phase 4: Security Headers (Week 2) ### 4.1 Security Headers Middleware **File**: `internal/api/middleware/security.go` ```go package middleware import ( "net/http" "time" "github.com/gin-gonic/gin" ) func SecurityHeaders() gin.HandlerFunc { return func(c *gin.Context) { // Prevent clickjacking c.Header("X-Frame-Options", "DENY") // Prevent MIME type sniffing c.Header("X-Content-Type-Options", "nosniff") // XSS Protection c.Header("X-XSS-Protection", "1; mode=block") // Referrer Policy c.Header("Referrer-Policy", "strict-origin-when-cross-origin") // Permissions Policy c.Header("Permissions-Policy", "geolocation=(), microphone=(), camera=()") // HSTS (HTTPS only) c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload") // CSP for API (restrictive) if !strings.HasPrefix(c.Request.URL.Path, "/management") { c.Header("Content-Security-Policy", "default-src 'none'") } c.Next() } } // CSP for Management UI func ManagementCSP() gin.HandlerFunc { return func(c *gin.Context) { csp := strings.Join([]string{ "default-src 'self'", "script-src 'self' 'unsafe-inline' 'unsafe-eval'", "style-src 'self' 'unsafe-inline'", "img-src 'self' data: https:", "font-src 'self'", "connect-src 'self'", "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'", }, "; ") c.Header("Content-Security-Policy", csp) c.Next() } } // CORS configuration func CORS(allowedOrigins []string) gin.HandlerFunc { return func(c *gin.Context) { origin := c.Request.Header.Get("Origin") // Check if origin is allowed allowed := false for _, o := range allowedOrigins { if o == "*" || o == origin { allowed = true break } } if allowed { c.Header("Access-Control-Allow-Origin", origin) c.Header("Access-Control-Allow-Credentials", "true") c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key") c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") c.Header("Access-Control-Max-Age", "86400") } if c.Request.Method == "OPTIONS" { c.AbortWithStatus(http.StatusNoContent) return } c.Next() } } ``` ## Phase 5: Audit Logging (Week 3) ### 5.1 Audit Logger **File**: `internal/infrastructure/audit/logger.go` ```go package audit import ( "context" "encoding/json" "time" ) // EventType represents types of audit events type EventType string const ( EventAuthCreated EventType = "auth.created" EventAuthDeleted EventType = "auth.deleted" EventAuthRefreshed EventType = "auth.refreshed" EventConfigChanged EventType = "config.changed" EventAPICall EventType = "api.call" EventLoginSuccess EventType = "login.success" EventLoginFailure EventType = "login.failure" EventTokenRevoked EventType = "token.revoked" ) // Severity level type Severity string const ( SeverityInfo Severity = "info" SeverityWarning Severity = "warning" SeverityCritical Severity = "critical" ) // Event represents an audit log entry type Event struct { Timestamp time.Time `json:"timestamp"` Type EventType `json:"type"` Severity Severity `json:"severity"` Actor Actor `json:"actor"` Resource Resource `json:"resource"` Action string `json:"action"` Result string `json:"result"` // success, failure Details map[string]interface{} `json:"details,omitempty"` RequestID string `json:"request_id"` IP string `json:"ip"` UserAgent string `json:"user_agent,omitempty"` } type Actor struct { ID string `json:"id"` Type string `json:"type"` // user, api_key, system } type Resource struct { Type string `json:"type"` ID string `json:"id"` } // Logger interface for audit events type Logger interface { Log(ctx context.Context, event Event) error } // FileLogger writes audit events to file type FileLogger struct { writer io.Writer mu sync.Mutex } func NewFileLogger(path string) (*FileLogger, error) { file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) if err != nil { return nil, err } return &FileLogger{writer: file}, nil } func (l *FileLogger) Log(ctx context.Context, event Event) error { event.Timestamp = time.Now().UTC() if reqID := ctx.Value("request_id"); reqID != nil { event.RequestID = reqID.(string) } data, err := json.Marshal(event) if err != nil { return err } l.mu.Lock() defer l.mu.Unlock() _, err = fmt.Fprintf(l.writer, "%s\n", data) return err } // Audit middleware func Middleware(logger Logger) gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() c.Next() // Log API calls if c.Request.URL.Path != "/health" && c.Request.URL.Path != "/metrics" { severity := SeverityInfo result := "success" if c.Writer.Status() >= 500 { severity = SeverityCritical result = "failure" } else if c.Writer.Status() >= 400 { severity = SeverityWarning result = "failure" } logger.Log(c.Request.Context(), Event{ Type: EventAPICall, Severity: severity, Actor: Actor{ ID: c.GetHeader("X-API-Key"), Type: "api_key", }, Resource: Resource{ Type: "endpoint", ID: c.Request.Method + " " + c.FullPath(), }, Action: c.Request.Method, Result: result, Details: map[string]interface{}{ "status_code": c.Writer.Status(), "duration_ms": time.Since(start).Milliseconds(), }, IP: c.ClientIP(), UserAgent: c.Request.UserAgent(), }) } } } ``` ## Phase 6: SAST/DAST Integration (Week 3) ### 6.1 GitHub Actions Security Workflow **File**: `.github/workflows/security.yml` ```yaml name: Security on: push: branches: [main, develop] pull_request: branches: [main, develop] schedule: - cron: '0 0 * * 0' # Weekly jobs: gosec: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Run Gosec uses: securego/gosec@master with: args: '-fmt sarif -out results.sarif ./...' - name: Upload SARIF uses: github/codeql-action/upload-sarif@v2 with: sarif_file: results.sarif govulncheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: '1.24' - name: Install govulncheck run: go install golang.org/x/vuln/cmd/govulncheck@latest - name: Run govulncheck run: govulncheck ./... trivy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Build image run: docker build -t cliproxy:test . - name: Run Trivy uses: aquasecurity/trivy-action@master with: image-ref: 'cliproxy:test' format: 'sarif' output: 'trivy-results.sarif' - name: Upload SARIF uses: github/codeql-action/upload-sarif@v2 with: sarif_file: trivy-results.sarif dependency-review: runs-on: ubuntu-latest if: github.event_name == 'pull_request' steps: - uses: actions/checkout@v4 - uses: actions/dependency-review-action@v3 ``` ## Success Metrics - [ ] All secrets externalized from config files - [ ] Zero high/critical vulnerabilities in SAST scans - [ ] Rate limiting on all public endpoints - [ ] Complete audit trail for auth events - [ ] Security headers on all responses - [ ] Input validation on all user inputs - [ ] Dependency vulnerabilities tracked and remediated ## Security Checklist - [ ] API keys are hashed in storage - [ ] TLS 1.3 required for all connections - [ ] No secrets in logs - [ ] SQL injection prevention in all queries - [ ] XSS prevention in management UI - [ ] CSRF tokens for state-changing operations - [ ] Request size limits enforced - [ ] IP allowlisting for management endpoints