| # 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 |
|
|