diff --git a/api-server b/api-server new file mode 100755 index 0000000..2a285e2 Binary files /dev/null and b/api-server differ diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index ff141ba..d4dd44d 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -21,6 +21,7 @@ func main() { apiKey := flag.String("api-key", "", "API key for authentication") showVersion := flag.Bool("version", false, "Show version and build info") verifyBuild := flag.Bool("verify", false, "Verify build integrity") + securityAudit := flag.Bool("security-audit", false, "Run security audit and exit") flag.Parse() // Handle version display @@ -38,6 +39,12 @@ func main() { os.Exit(0) } + // Handle security audit + if *securityAudit { + runSecurityAudit(*configFile) + os.Exit(0) + } + // Create and start server server, err := api.NewServer(*configFile) if err != nil { @@ -54,3 +61,75 @@ func main() { // Reserved for future authentication enhancements _ = apiKey } + +// runSecurityAudit performs security checks and reports issues +func runSecurityAudit(configFile string) { + fmt.Println("=== Security Audit ===") + issues := []string{} + warnings := []string{} + + // Check 1: Config file permissions + if info, err := os.Stat(configFile); err == nil { + mode := info.Mode().Perm() + if mode&0077 != 0 { + issues = append(issues, fmt.Sprintf("Config file %s is world/group readable (permissions: %04o)", configFile, mode)) + } else { + fmt.Printf("✓ Config file permissions: %04o (secure)\n", mode) + } + } else { + warnings = append(warnings, fmt.Sprintf("Could not check config file: %v", err)) + } + + // Check 2: Environment variable exposure + sensitiveVars := []string{"JWT_SECRET", "FETCH_ML_API_KEY", "DATABASE_PASSWORD", "REDIS_PASSWORD"} + exposedVars := []string{} + for _, v := range sensitiveVars { + if os.Getenv(v) != "" { + exposedVars = append(exposedVars, v) + } + } + if len(exposedVars) > 0 { + warnings = append(warnings, fmt.Sprintf("Sensitive environment variables exposed: %v (will be cleared on startup)", exposedVars)) + } else { + fmt.Println("✓ No sensitive environment variables exposed") + } + + // Check 3: Running as root + if os.Getuid() == 0 { + issues = append(issues, "Running as root (UID 0) - should run as non-root user") + } else { + fmt.Printf("✓ Running as non-root user (UID: %d)\n", os.Getuid()) + } + + // Check 4: API key file permissions + apiKeyFile := os.Getenv("FETCH_ML_API_KEY_FILE") + if apiKeyFile != "" { + if info, err := os.Stat(apiKeyFile); err == nil { + mode := info.Mode().Perm() + if mode&0077 != 0 { + issues = append(issues, fmt.Sprintf("API key file %s is world/group readable (permissions: %04o)", apiKeyFile, mode)) + } else { + fmt.Printf("✓ API key file permissions: %04o (secure)\n", mode) + } + } + } + + // Report results + fmt.Println() + if len(issues) == 0 && len(warnings) == 0 { + fmt.Println("✓ All security checks passed") + } else { + if len(issues) > 0 { + fmt.Printf("✗ Found %d security issue(s):\n", len(issues)) + for _, issue := range issues { + fmt.Printf(" - %s\n", issue) + } + } + if len(warnings) > 0 { + fmt.Printf("⚠ Found %d warning(s):\n", len(warnings)) + for _, warning := range warnings { + fmt.Printf(" - %s\n", warning) + } + } + } +} diff --git a/internal/api/factory.go b/internal/api/factory.go index e4e22d7..ad85ef7 100644 --- a/internal/api/factory.go +++ b/internal/api/factory.go @@ -147,6 +147,11 @@ func (s *Server) initDatabase() error { s.db = db s.logger.Info("database initialized", "type", s.config.Database.Type) + // Clear database password from memory after connection + if s.config.Database.Password != "" { + s.config.Database.Password = "[CLEARED]" + } + // Add cleanup function s.cleanupFuncs = append(s.cleanupFuncs, func() { s.logger.Info("closing database connection...") @@ -183,7 +188,11 @@ func (s *Server) initDatabaseSchema() error { func (s *Server) initSecurity() { authConfig := s.config.BuildAuthConfig() rlOpts := s.buildRateLimitOptions() - s.sec = middleware.NewSecurityMiddleware(authConfig, os.Getenv("JWT_SECRET"), rlOpts) + + // Read JWT secret securely and clear from environment + jwtSecret := SecureEnvVar("JWT_SECRET") + + s.sec = middleware.NewSecurityMiddleware(authConfig, jwtSecret, rlOpts) } // buildRateLimitOptions builds rate limit options from configuration @@ -254,3 +263,13 @@ func getSecurityConfig(cfg *ServerConfig) *config.SecurityConfig { IPWhitelist: cfg.Security.IPWhitelist, } } + +// SecureEnvVar reads an environment variable and clears it from the environment +// to prevent exposure via /proc/*/environ. Returns empty string if not set. +func SecureEnvVar(name string) string { + value := os.Getenv(name) + if value != "" { + os.Unsetenv(name) + } + return value +} diff --git a/internal/auth/flags.go b/internal/auth/flags.go index 08212bb..277906c 100644 --- a/internal/auth/flags.go +++ b/internal/auth/flags.go @@ -41,6 +41,8 @@ func ParseAuthFlags() *Flags { func GetAPIKeyFromSources(flags *Flags) string { // 1. Command line flag (highest priority) if flags.APIKey != "" { + // Warn about process list exposure + log.Println("WARNING: --api-key exposes credential in process list. Use --api-key-file for better security.") return flags.APIKey } diff --git a/internal/logging/sanitize.go b/internal/logging/sanitize.go index 7ce7c95..77ed666 100644 --- a/internal/logging/sanitize.go +++ b/internal/logging/sanitize.go @@ -21,6 +21,18 @@ var ( // Redis URLs with passwords redisPasswordPattern = regexp.MustCompile(`redis://:[^@]+@`) + + // Database connection strings - PostgreSQL + postgresPasswordPattern = regexp.MustCompile(`(postgresql?://[^:]+:)[^@]+(@)`) + + // Generic connection strings with passwords + connStringPasswordPattern = regexp.MustCompile(`(password[=:])[^\s&;]+`) + + // HTTP Authorization headers + authHeaderPattern = regexp.MustCompile(`(Authorization:\s*(?:Bearer|Basic)\s+)[^\s]+`) + + // Private keys + privateKeyPattern = regexp.MustCompile(`(-----BEGIN (?:RSA|EC|DSA|OPENSSH) PRIVATE KEY-----)[\s\S]*?(-----END (?:RSA|EC|DSA|OPENSSH) PRIVATE KEY-----)`) ) // SanitizeLogMessage removes sensitive data from log messages @@ -43,6 +55,18 @@ func SanitizeLogMessage(message string) string { // Redact Redis passwords from URLs message = redisPasswordPattern.ReplaceAllString(message, "redis://:[REDACTED]@") + // Redact PostgreSQL connection strings + message = postgresPasswordPattern.ReplaceAllString(message, "${1}[REDACTED]${2}") + + // Redact generic connection string passwords + message = connStringPasswordPattern.ReplaceAllString(message, "${1}[REDACTED]") + + // Redact HTTP Authorization headers + message = authHeaderPattern.ReplaceAllString(message, "${1}[REDACTED]") + + // Redact private keys + message = privateKeyPattern.ReplaceAllString(message, "[REDACTED-PRIVATE-KEY]") + return message } diff --git a/internal/storage/db_connect.go b/internal/storage/db_connect.go index df5b3bd..4274f31 100644 --- a/internal/storage/db_connect.go +++ b/internal/storage/db_connect.go @@ -5,6 +5,7 @@ import ( "context" "database/sql" "fmt" + "regexp" "strings" "time" @@ -12,6 +13,23 @@ import ( _ "github.com/mattn/go-sqlite3" // SQLite driver ) +// SanitizeConnectionString removes passwords from connection strings for safe logging +func SanitizeConnectionString(connStr string) string { + if connStr == "" { + return "" + } + + // Pattern for postgresql://user:pass@host format + postgresPattern := regexp.MustCompile(`(postgresql?://[^:]+:)[^@]+(@)`) + sanitized := postgresPattern.ReplaceAllString(connStr, "${1}[REDACTED]${2}") + + // Pattern for password=xxxxx in connection string + passwordPattern := regexp.MustCompile(`(password=)[^\s]*`) + sanitized = passwordPattern.ReplaceAllString(sanitized, "${1}[REDACTED]") + + return sanitized +} + // DBConfig holds database connection configuration. type DBConfig struct { Type string