- Add comprehensive security package with: - API Key generation and validation (SHA256 hash) - Password policy enforcement (min 12 chars, complexity) - Rate limiting with presets (auth, api, ingest, export) - Brute force protection (5 attempts, 15min lockout) - Security headers middleware - IP whitelisting - Audit logging structure - Secure token generation - Enhanced auth middleware: - JWT + API Key dual authentication - Token revocation via Redis - Scope-based authorization - Role-based access control - Updated installer with: - Interactive setup for client customization - Auto-generated secure credentials - Docker all-in-one image - Agent installer script - Added documentation: - SECURITY.md - Complete security guide - INSTALL.md - Installation guide - .env.example - Configuration reference
559 lines
16 KiB
Go
559 lines
16 KiB
Go
package security
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// 🔐 CONSTANTES DE SEGURANÇA
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
const (
|
|
// Bcrypt cost (12-14 recomendado para produção)
|
|
BcryptCost = 12
|
|
|
|
// Tamanho mínimo de senha
|
|
MinPasswordLength = 12
|
|
|
|
// Tamanho da API Key
|
|
APIKeyLength = 32
|
|
|
|
// Prefixo da API Key
|
|
APIKeyPrefix = "ophion_"
|
|
|
|
// Rate limit padrão (requests por minuto)
|
|
DefaultRateLimit = 100
|
|
|
|
// Rate limit para auth (tentativas por minuto)
|
|
AuthRateLimit = 5
|
|
|
|
// Tempo de bloqueio após falhas (minutos)
|
|
LockoutDuration = 15
|
|
|
|
// Máximo de tentativas antes do bloqueio
|
|
MaxFailedAttempts = 5
|
|
)
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// 🔑 API KEY MANAGEMENT
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
// GenerateAPIKey cria uma nova API key segura
|
|
func GenerateAPIKey() (key string, hash string, prefix string) {
|
|
bytes := make([]byte, APIKeyLength)
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
panic("failed to generate random bytes")
|
|
}
|
|
|
|
key = APIKeyPrefix + hex.EncodeToString(bytes)
|
|
hash = HashAPIKey(key)
|
|
prefix = key[:len(APIKeyPrefix)+8] // ophion_xxxxxxxx
|
|
|
|
return key, hash, prefix
|
|
}
|
|
|
|
// HashAPIKey cria hash SHA256 da API key
|
|
func HashAPIKey(key string) string {
|
|
hash := sha256.Sum256([]byte(key))
|
|
return hex.EncodeToString(hash[:])
|
|
}
|
|
|
|
// ValidateAPIKeyFormat verifica formato da API key
|
|
func ValidateAPIKeyFormat(key string) bool {
|
|
if !strings.HasPrefix(key, APIKeyPrefix) {
|
|
return false
|
|
}
|
|
// ophion_ + 64 hex chars
|
|
return len(key) == len(APIKeyPrefix)+64
|
|
}
|
|
|
|
// SecureCompare compara strings em tempo constante (previne timing attacks)
|
|
func SecureCompare(a, b string) bool {
|
|
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// 🔒 PASSWORD MANAGEMENT
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
// PasswordPolicy define regras de senha
|
|
type PasswordPolicy struct {
|
|
MinLength int
|
|
RequireUppercase bool
|
|
RequireLowercase bool
|
|
RequireNumbers bool
|
|
RequireSpecial bool
|
|
}
|
|
|
|
// DefaultPasswordPolicy retorna política padrão
|
|
func DefaultPasswordPolicy() PasswordPolicy {
|
|
return PasswordPolicy{
|
|
MinLength: 12,
|
|
RequireUppercase: true,
|
|
RequireLowercase: true,
|
|
RequireNumbers: true,
|
|
RequireSpecial: true,
|
|
}
|
|
}
|
|
|
|
// ValidatePassword verifica se senha atende à política
|
|
func ValidatePassword(password string, policy PasswordPolicy) (bool, []string) {
|
|
var errors []string
|
|
|
|
if len(password) < policy.MinLength {
|
|
errors = append(errors, fmt.Sprintf("Senha deve ter no mínimo %d caracteres", policy.MinLength))
|
|
}
|
|
|
|
if policy.RequireUppercase && !regexp.MustCompile(`[A-Z]`).MatchString(password) {
|
|
errors = append(errors, "Senha deve conter letra maiúscula")
|
|
}
|
|
|
|
if policy.RequireLowercase && !regexp.MustCompile(`[a-z]`).MatchString(password) {
|
|
errors = append(errors, "Senha deve conter letra minúscula")
|
|
}
|
|
|
|
if policy.RequireNumbers && !regexp.MustCompile(`[0-9]`).MatchString(password) {
|
|
errors = append(errors, "Senha deve conter número")
|
|
}
|
|
|
|
if policy.RequireSpecial && !regexp.MustCompile(`[!@#$%^&*(),.?":{}|<>]`).MatchString(password) {
|
|
errors = append(errors, "Senha deve conter caractere especial")
|
|
}
|
|
|
|
// Verificar senhas comuns
|
|
if isCommonPassword(password) {
|
|
errors = append(errors, "Senha muito comum, escolha outra")
|
|
}
|
|
|
|
return len(errors) == 0, errors
|
|
}
|
|
|
|
// HashPassword cria hash bcrypt da senha
|
|
func HashPassword(password string) (string, error) {
|
|
bytes, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
|
|
return string(bytes), err
|
|
}
|
|
|
|
// VerifyPassword verifica senha contra hash
|
|
func VerifyPassword(password, hash string) bool {
|
|
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
|
return err == nil
|
|
}
|
|
|
|
// isCommonPassword verifica senhas comuns
|
|
func isCommonPassword(password string) bool {
|
|
common := []string{
|
|
"password", "123456", "12345678", "qwerty", "abc123",
|
|
"monkey", "1234567", "letmein", "trustno1", "dragon",
|
|
"baseball", "iloveyou", "master", "sunshine", "ashley",
|
|
"passw0rd", "shadow", "123123", "654321", "superman",
|
|
"senha", "mudar123", "admin123", "root123",
|
|
}
|
|
lower := strings.ToLower(password)
|
|
for _, p := range common {
|
|
if strings.Contains(lower, p) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// 🚦 RATE LIMITING
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
// RateLimiter controla taxa de requests
|
|
type RateLimiter struct {
|
|
sync.RWMutex
|
|
requests map[string]*rateLimitEntry
|
|
limit int
|
|
window time.Duration
|
|
}
|
|
|
|
type rateLimitEntry struct {
|
|
count int
|
|
firstSeen time.Time
|
|
blocked bool
|
|
blockedAt time.Time
|
|
}
|
|
|
|
// NewRateLimiter cria novo rate limiter
|
|
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
|
|
rl := &RateLimiter{
|
|
requests: make(map[string]*rateLimitEntry),
|
|
limit: limit,
|
|
window: window,
|
|
}
|
|
// Cleanup goroutine
|
|
go rl.cleanup()
|
|
return rl
|
|
}
|
|
|
|
// Allow verifica se request é permitido
|
|
func (rl *RateLimiter) Allow(identifier string) bool {
|
|
rl.Lock()
|
|
defer rl.Unlock()
|
|
|
|
now := time.Now()
|
|
entry, exists := rl.requests[identifier]
|
|
|
|
if !exists {
|
|
rl.requests[identifier] = &rateLimitEntry{
|
|
count: 1,
|
|
firstSeen: now,
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Verificar se está bloqueado
|
|
if entry.blocked {
|
|
if now.Sub(entry.blockedAt) < time.Duration(LockoutDuration)*time.Minute {
|
|
return false
|
|
}
|
|
// Desbloquear
|
|
entry.blocked = false
|
|
entry.count = 0
|
|
entry.firstSeen = now
|
|
}
|
|
|
|
// Verificar janela de tempo
|
|
if now.Sub(entry.firstSeen) > rl.window {
|
|
entry.count = 1
|
|
entry.firstSeen = now
|
|
return true
|
|
}
|
|
|
|
entry.count++
|
|
|
|
if entry.count > rl.limit {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// Block bloqueia um identificador
|
|
func (rl *RateLimiter) Block(identifier string) {
|
|
rl.Lock()
|
|
defer rl.Unlock()
|
|
|
|
entry, exists := rl.requests[identifier]
|
|
if !exists {
|
|
entry = &rateLimitEntry{}
|
|
rl.requests[identifier] = entry
|
|
}
|
|
entry.blocked = true
|
|
entry.blockedAt = time.Now()
|
|
}
|
|
|
|
// cleanup remove entradas antigas
|
|
func (rl *RateLimiter) cleanup() {
|
|
ticker := time.NewTicker(5 * time.Minute)
|
|
for range ticker.C {
|
|
rl.Lock()
|
|
now := time.Now()
|
|
for id, entry := range rl.requests {
|
|
if now.Sub(entry.firstSeen) > rl.window*2 && !entry.blocked {
|
|
delete(rl.requests, id)
|
|
}
|
|
}
|
|
rl.Unlock()
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// 🛡️ BRUTE FORCE PROTECTION
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
// LoginAttemptTracker rastreia tentativas de login
|
|
type LoginAttemptTracker struct {
|
|
sync.RWMutex
|
|
attempts map[string]*loginAttempt
|
|
}
|
|
|
|
type loginAttempt struct {
|
|
failures int
|
|
lastAttempt time.Time
|
|
lockedUntil time.Time
|
|
}
|
|
|
|
// NewLoginAttemptTracker cria tracker
|
|
func NewLoginAttemptTracker() *LoginAttemptTracker {
|
|
return &LoginAttemptTracker{
|
|
attempts: make(map[string]*loginAttempt),
|
|
}
|
|
}
|
|
|
|
// RecordFailure registra falha de login
|
|
func (t *LoginAttemptTracker) RecordFailure(identifier string) (blocked bool, remainingAttempts int) {
|
|
t.Lock()
|
|
defer t.Unlock()
|
|
|
|
now := time.Now()
|
|
attempt, exists := t.attempts[identifier]
|
|
|
|
if !exists {
|
|
t.attempts[identifier] = &loginAttempt{
|
|
failures: 1,
|
|
lastAttempt: now,
|
|
}
|
|
return false, MaxFailedAttempts - 1
|
|
}
|
|
|
|
// Reset se última tentativa foi há muito tempo
|
|
if now.Sub(attempt.lastAttempt) > time.Duration(LockoutDuration)*time.Minute {
|
|
attempt.failures = 1
|
|
attempt.lastAttempt = now
|
|
attempt.lockedUntil = time.Time{}
|
|
return false, MaxFailedAttempts - 1
|
|
}
|
|
|
|
attempt.failures++
|
|
attempt.lastAttempt = now
|
|
|
|
if attempt.failures >= MaxFailedAttempts {
|
|
attempt.lockedUntil = now.Add(time.Duration(LockoutDuration) * time.Minute)
|
|
return true, 0
|
|
}
|
|
|
|
return false, MaxFailedAttempts - attempt.failures
|
|
}
|
|
|
|
// RecordSuccess registra sucesso de login
|
|
func (t *LoginAttemptTracker) RecordSuccess(identifier string) {
|
|
t.Lock()
|
|
defer t.Unlock()
|
|
delete(t.attempts, identifier)
|
|
}
|
|
|
|
// IsLocked verifica se está bloqueado
|
|
func (t *LoginAttemptTracker) IsLocked(identifier string) (bool, time.Duration) {
|
|
t.RLock()
|
|
defer t.RUnlock()
|
|
|
|
attempt, exists := t.attempts[identifier]
|
|
if !exists {
|
|
return false, 0
|
|
}
|
|
|
|
if attempt.lockedUntil.IsZero() {
|
|
return false, 0
|
|
}
|
|
|
|
remaining := time.Until(attempt.lockedUntil)
|
|
if remaining <= 0 {
|
|
return false, 0
|
|
}
|
|
|
|
return true, remaining
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// 🔍 INPUT VALIDATION & SANITIZATION
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
// SanitizeEmail limpa e valida email
|
|
func SanitizeEmail(email string) (string, error) {
|
|
email = strings.TrimSpace(strings.ToLower(email))
|
|
|
|
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
|
|
if !emailRegex.MatchString(email) {
|
|
return "", fmt.Errorf("email inválido")
|
|
}
|
|
|
|
return email, nil
|
|
}
|
|
|
|
// SanitizeHostname limpa hostname
|
|
func SanitizeHostname(hostname string) (string, error) {
|
|
hostname = strings.TrimSpace(hostname)
|
|
|
|
// Permitir apenas alfanuméricos, hífens e pontos
|
|
hostnameRegex := regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9.-]{0,253}[a-zA-Z0-9]$`)
|
|
if !hostnameRegex.MatchString(hostname) && len(hostname) > 1 {
|
|
return "", fmt.Errorf("hostname inválido")
|
|
}
|
|
|
|
return hostname, nil
|
|
}
|
|
|
|
// SanitizeSQL previne SQL injection (para queries dinâmicas)
|
|
func SanitizeSQL(input string) string {
|
|
// Remover caracteres perigosos
|
|
dangerous := []string{"'", "\"", ";", "--", "/*", "*/", "xp_", "sp_"}
|
|
result := input
|
|
for _, d := range dangerous {
|
|
result = strings.ReplaceAll(result, d, "")
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ValidateIPAddress valida endereço IP
|
|
func ValidateIPAddress(ip string) bool {
|
|
return net.ParseIP(ip) != nil
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// 🛡️ SECURITY HEADERS MIDDLEWARE
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
// SecurityHeaders adiciona headers de segurança
|
|
func SecurityHeaders() fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
// Prevenir clickjacking
|
|
c.Set("X-Frame-Options", "DENY")
|
|
|
|
// Prevenir MIME type sniffing
|
|
c.Set("X-Content-Type-Options", "nosniff")
|
|
|
|
// XSS Protection
|
|
c.Set("X-XSS-Protection", "1; mode=block")
|
|
|
|
// Content Security Policy
|
|
c.Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' wss: https:")
|
|
|
|
// HSTS (apenas se HTTPS)
|
|
if c.Protocol() == "https" {
|
|
c.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
|
|
}
|
|
|
|
// Referrer Policy
|
|
c.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
|
|
// Permissions Policy
|
|
c.Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
|
|
|
|
// Remove Server header
|
|
c.Set("Server", "")
|
|
|
|
return c.Next()
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// 📝 AUDIT LOGGING
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
// AuditEvent representa um evento de auditoria
|
|
type AuditEvent struct {
|
|
Timestamp time.Time `json:"timestamp"`
|
|
EventType string `json:"event_type"`
|
|
UserID string `json:"user_id,omitempty"`
|
|
OrgID string `json:"org_id,omitempty"`
|
|
IP string `json:"ip"`
|
|
UserAgent string `json:"user_agent"`
|
|
Resource string `json:"resource"`
|
|
Action string `json:"action"`
|
|
Status string `json:"status"`
|
|
Details map[string]string `json:"details,omitempty"`
|
|
}
|
|
|
|
// AuditEventType tipos de eventos
|
|
const (
|
|
AuditLogin = "auth.login"
|
|
AuditLogout = "auth.logout"
|
|
AuditLoginFailed = "auth.login_failed"
|
|
AuditAPIKeyCreated = "apikey.created"
|
|
AuditAPIKeyRevoked = "apikey.revoked"
|
|
AuditUserCreated = "user.created"
|
|
AuditUserDeleted = "user.deleted"
|
|
AuditConfigChanged = "config.changed"
|
|
AuditAlertCreated = "alert.created"
|
|
AuditDataExport = "data.export"
|
|
)
|
|
|
|
// AuditLogger interface para logging de auditoria
|
|
type AuditLogger interface {
|
|
Log(event AuditEvent) error
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// 🔐 SECRETS MANAGEMENT
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
// GenerateSecureToken gera token seguro
|
|
func GenerateSecureToken(length int) string {
|
|
bytes := make([]byte, length)
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
panic("failed to generate secure token")
|
|
}
|
|
return base64.URLEncoding.EncodeToString(bytes)[:length]
|
|
}
|
|
|
|
// MaskSecret mascara segredos para logging
|
|
func MaskSecret(secret string) string {
|
|
if len(secret) <= 8 {
|
|
return "****"
|
|
}
|
|
return secret[:4] + "****" + secret[len(secret)-4:]
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// 🌐 IP FILTERING
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
// IPWhitelist gerencia whitelist de IPs
|
|
type IPWhitelist struct {
|
|
sync.RWMutex
|
|
allowed map[string]bool
|
|
cidrs []*net.IPNet
|
|
}
|
|
|
|
// NewIPWhitelist cria whitelist
|
|
func NewIPWhitelist(ips []string, cidrs []string) *IPWhitelist {
|
|
wl := &IPWhitelist{
|
|
allowed: make(map[string]bool),
|
|
}
|
|
|
|
for _, ip := range ips {
|
|
wl.allowed[ip] = true
|
|
}
|
|
|
|
for _, cidr := range cidrs {
|
|
_, network, err := net.ParseCIDR(cidr)
|
|
if err == nil {
|
|
wl.cidrs = append(wl.cidrs, network)
|
|
}
|
|
}
|
|
|
|
return wl
|
|
}
|
|
|
|
// IsAllowed verifica se IP está permitido
|
|
func (wl *IPWhitelist) IsAllowed(ip string) bool {
|
|
wl.RLock()
|
|
defer wl.RUnlock()
|
|
|
|
// Verificar lista direta
|
|
if wl.allowed[ip] {
|
|
return true
|
|
}
|
|
|
|
// Verificar CIDRs
|
|
parsedIP := net.ParseIP(ip)
|
|
if parsedIP == nil {
|
|
return false
|
|
}
|
|
|
|
for _, network := range wl.cidrs {
|
|
if network.Contains(parsedIP) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|