Complete RedFlag codebase with two major security audit implementations.
== A-1: Ed25519 Key Rotation Support ==
Server:
- SignCommand sets SignedAt timestamp and KeyID on every signature
- signing_keys database table (migration 020) for multi-key rotation
- InitializePrimaryKey registers active key at startup
- /api/v1/public-keys endpoint for rotation-aware agents
- SigningKeyQueries for key lifecycle management
Agent:
- Key-ID-aware verification via CheckKeyRotation
- FetchAndCacheAllActiveKeys for rotation pre-caching
- Cache metadata with TTL and staleness fallback
- SecurityLogger events for key rotation and command signing
== A-2: Replay Attack Fixes (F-1 through F-7) ==
F-5 CRITICAL - RetryCommand now signs via signAndCreateCommand
F-1 HIGH - v3 format: "{agent_id}:{cmd_id}:{type}:{hash}:{ts}"
F-7 HIGH - Migration 026: expires_at column with partial index
F-6 HIGH - GetPendingCommands/GetStuckCommands filter by expires_at
F-2 HIGH - Agent-side executedIDs dedup map with cleanup
F-4 HIGH - commandMaxAge reduced from 24h to 4h
F-3 CRITICAL - Old-format commands rejected after 48h via CreatedAt
Verification fixes: migration idempotency (ETHOS #4), log format
compliance (ETHOS #1), stale comments updated.
All 24 tests passing. Docker --no-cache build verified.
See docs/ for full audit reports and deviation log (DEV-001 to DEV-019).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
344 lines
11 KiB
Go
344 lines
11 KiB
Go
package migration
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Fimeg/RedFlag/aggregator-agent/internal/common"
|
|
)
|
|
|
|
// DockerSecretsExecutor handles the execution of Docker secrets migration
|
|
type DockerSecretsExecutor struct {
|
|
detection *DockerDetection
|
|
config *FileDetectionConfig
|
|
encryption string
|
|
}
|
|
|
|
// NewDockerSecretsExecutor creates a new Docker secrets executor
|
|
func NewDockerSecretsExecutor(detection *DockerDetection, config *FileDetectionConfig) *DockerSecretsExecutor {
|
|
return &DockerSecretsExecutor{
|
|
detection: detection,
|
|
config: config,
|
|
}
|
|
}
|
|
|
|
// ExecuteDockerSecretsMigration performs the Docker secrets migration
|
|
func (e *DockerSecretsExecutor) ExecuteDockerSecretsMigration() error {
|
|
if !e.detection.DockerAvailable {
|
|
return fmt.Errorf("docker secrets not available")
|
|
}
|
|
|
|
if !e.detection.MigrateToSecrets {
|
|
fmt.Printf("[DOCKER] No secrets to migrate\n")
|
|
return nil
|
|
}
|
|
|
|
fmt.Printf("[DOCKER] Starting Docker secrets migration...\n")
|
|
|
|
// Generate encryption key for config files
|
|
encKey, err := GenerateEncryptionKey()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to generate encryption key: %w", err)
|
|
}
|
|
e.encryption = encKey
|
|
|
|
// Create backup before migration
|
|
if err := e.createSecretsBackup(); err != nil {
|
|
return fmt.Errorf("failed to create secrets backup: %w", err)
|
|
}
|
|
|
|
// Migrate each secret file
|
|
for _, secretFile := range e.detection.SecretFiles {
|
|
if err := e.migrateSecretFile(secretFile); err != nil {
|
|
fmt.Printf("[DOCKER] Failed to migrate secret file %s: %v\n", secretFile.Path, err)
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Create Docker secrets configuration
|
|
if err := e.createDockerConfig(); err != nil {
|
|
return fmt.Errorf("failed to create Docker config: %w", err)
|
|
}
|
|
|
|
// Remove original secret files
|
|
if err := e.removeOriginalSecrets(); err != nil {
|
|
return fmt.Errorf("failed to remove original secrets: %w", err)
|
|
}
|
|
|
|
fmt.Printf("[DOCKER] Docker secrets migration completed successfully\n")
|
|
fmt.Printf("[DOCKER] Encryption key: %s\n", encKey)
|
|
fmt.Printf("[DOCKER] Save this key securely for decryption\n")
|
|
|
|
return nil
|
|
}
|
|
|
|
// createSecretsBackup creates a backup of secret files before migration
|
|
func (e *DockerSecretsExecutor) createSecretsBackup() error {
|
|
timestamp := time.Now().Format("2006-01-02-150405")
|
|
backupDir := fmt.Sprintf("/etc/redflag.backup.secrets.%s", timestamp)
|
|
|
|
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create backup directory: %w", err)
|
|
}
|
|
|
|
for _, secretFile := range e.detection.SecretFiles {
|
|
backupPath := filepath.Join(backupDir, filepath.Base(secretFile.Path))
|
|
if err := copySecretFile(secretFile.Path, backupPath); err != nil {
|
|
fmt.Printf("[DOCKER] Failed to backup secret file %s: %v\n", secretFile.Path, err)
|
|
} else {
|
|
fmt.Printf("[DOCKER] Backed up secret file: %s → %s\n", secretFile.Path, backupPath)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// migrateSecretFile migrates a single secret file to Docker secrets
|
|
func (e *DockerSecretsExecutor) migrateSecretFile(secretFile common.AgentFile) error {
|
|
secretName := filepath.Base(secretFile.Path)
|
|
secretPath := filepath.Join(e.detection.SecretsMountPath, secretName)
|
|
|
|
// Handle config.json specially (encrypt it)
|
|
if secretName == "config.json" {
|
|
return e.migrateConfigFile(secretFile)
|
|
}
|
|
|
|
// Copy secret file to Docker secrets directory
|
|
if err := copySecretFile(secretFile.Path, secretPath); err != nil {
|
|
return fmt.Errorf("failed to copy secret to Docker mount: %w", err)
|
|
}
|
|
|
|
// Set secure permissions
|
|
if err := os.Chmod(secretPath, 0400); err != nil {
|
|
return fmt.Errorf("failed to set secret permissions: %w", err)
|
|
}
|
|
|
|
fmt.Printf("[DOCKER] Migrated secret: %s → %s\n", secretFile.Path, secretPath)
|
|
return nil
|
|
}
|
|
|
|
// migrateConfigFile handles special migration of config.json with encryption
|
|
func (e *DockerSecretsExecutor) migrateConfigFile(secretFile common.AgentFile) error {
|
|
// Read original config
|
|
configData, err := os.ReadFile(secretFile.Path)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read config file: %w", err)
|
|
}
|
|
|
|
// Parse config to separate sensitive from non-sensitive data
|
|
var config map[string]interface{}
|
|
if err := json.Unmarshal(configData, &config); err != nil {
|
|
return fmt.Errorf("failed to parse config: %w", err)
|
|
}
|
|
|
|
// Split config into public and sensitive parts
|
|
publicConfig, sensitiveConfig := e.splitConfig(config)
|
|
|
|
// Write public config back to original location
|
|
publicData, err := json.MarshalIndent(publicConfig, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal public config: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(secretFile.Path, publicData, 0644); err != nil {
|
|
return fmt.Errorf("failed to write public config: %w", err)
|
|
}
|
|
|
|
// Encrypt sensitive config
|
|
sensitiveData, err := json.MarshalIndent(sensitiveConfig, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal sensitive config: %w", err)
|
|
}
|
|
|
|
tempSensitivePath := secretFile.Path + ".sensitive"
|
|
if err := os.WriteFile(tempSensitivePath, sensitiveData, 0600); err != nil {
|
|
return fmt.Errorf("failed to write sensitive config: %w", err)
|
|
}
|
|
defer os.Remove(tempSensitivePath)
|
|
|
|
// Encrypt sensitive config
|
|
encryptedPath := filepath.Join(e.detection.SecretsMountPath, "config.json.enc")
|
|
if err := EncryptFile(tempSensitivePath, encryptedPath, e.encryption); err != nil {
|
|
return fmt.Errorf("failed to encrypt config: %w", err)
|
|
}
|
|
|
|
fmt.Printf("[DOCKER] Migrated config with encryption: %s → %s (public) + %s (encrypted)\n",
|
|
secretFile.Path, secretFile.Path, encryptedPath)
|
|
|
|
return nil
|
|
}
|
|
|
|
// splitConfig splits configuration into public and sensitive parts
|
|
func (e *DockerSecretsExecutor) splitConfig(config map[string]interface{}) (map[string]interface{}, map[string]interface{}) {
|
|
public := make(map[string]interface{})
|
|
sensitive := make(map[string]interface{})
|
|
|
|
sensitiveFields := []string{
|
|
"password", "token", "key", "secret", "credential",
|
|
"proxy", "tls", "certificate", "private",
|
|
}
|
|
|
|
for key, value := range config {
|
|
if e.isSensitiveField(key, value, sensitiveFields) {
|
|
sensitive[key] = value
|
|
} else {
|
|
public[key] = value
|
|
}
|
|
}
|
|
|
|
return public, sensitive
|
|
}
|
|
|
|
// isSensitiveField checks if a field contains sensitive data
|
|
func (e *DockerSecretsExecutor) isSensitiveField(key string, value interface{}, sensitiveFields []string) bool {
|
|
// Check key name
|
|
for _, field := range sensitiveFields {
|
|
if strings.Contains(strings.ToLower(key), strings.ToLower(field)) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Check nested values
|
|
if nested, ok := value.(map[string]interface{}); ok {
|
|
for nKey, nValue := range nested {
|
|
if e.isSensitiveField(nKey, nValue, sensitiveFields) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// createDockerConfig creates the Docker secrets configuration file
|
|
func (e *DockerSecretsExecutor) createDockerConfig() error {
|
|
dockerConfig := DockerConfig{
|
|
Enabled: true,
|
|
SecretsPath: e.detection.SecretsMountPath,
|
|
EncryptionKey: e.encryption,
|
|
Secrets: make(map[string]string),
|
|
}
|
|
|
|
// Map secret files to their Docker secret names
|
|
for _, secretFile := range e.detection.SecretFiles {
|
|
secretName := filepath.Base(secretFile.Path)
|
|
if secretName == "config.json" {
|
|
dockerConfig.Secrets["config"] = "config.json.enc"
|
|
} else {
|
|
dockerConfig.Secrets[secretName] = secretName
|
|
}
|
|
}
|
|
|
|
// Write Docker config
|
|
configPath := filepath.Join(e.config.NewConfigPath, "docker.json")
|
|
configData, err := json.MarshalIndent(dockerConfig, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal Docker config: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(configPath, configData, 0600); err != nil {
|
|
return fmt.Errorf("failed to write Docker config: %w", err)
|
|
}
|
|
|
|
fmt.Printf("[DOCKER] Created Docker config: %s\n", configPath)
|
|
return nil
|
|
}
|
|
|
|
// removeOriginalSecrets removes the original secret files after migration
|
|
func (e *DockerSecretsExecutor) removeOriginalSecrets() error {
|
|
for _, secretFile := range e.detection.SecretFiles {
|
|
// Don't remove config.json as it's been split into public part
|
|
if filepath.Base(secretFile.Path) == "config.json" {
|
|
continue
|
|
}
|
|
|
|
if err := os.Remove(secretFile.Path); err != nil {
|
|
fmt.Printf("[DOCKER] Failed to remove original secret %s: %v\n", secretFile.Path, err)
|
|
} else {
|
|
fmt.Printf("[DOCKER] Removed original secret: %s\n", secretFile.Path)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// copySecretFile copies a file from src to dst (renamed to avoid conflicts)
|
|
func copySecretFile(src, dst string) error {
|
|
// Read source file
|
|
data, err := os.ReadFile(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Ensure destination directory exists
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Write destination file
|
|
return os.WriteFile(dst, data, 0644)
|
|
}
|
|
|
|
// ValidateDockerSecretsMigration validates that the Docker secrets migration was successful
|
|
func (e *DockerSecretsExecutor) ValidateDockerSecretsMigration() error {
|
|
// Check that Docker secrets directory exists
|
|
if _, err := os.Stat(e.detection.SecretsMountPath); err != nil {
|
|
return fmt.Errorf("Docker secrets directory not accessible: %w", err)
|
|
}
|
|
|
|
// Check that all required secrets exist
|
|
for _, secretName := range e.detection.RequiredSecrets {
|
|
secretPath := filepath.Join(e.detection.SecretsMountPath, secretName)
|
|
if _, err := os.Stat(secretPath); err != nil {
|
|
return fmt.Errorf("required secret not found: %s", secretName)
|
|
}
|
|
}
|
|
|
|
// Check that Docker config exists
|
|
dockerConfigPath := filepath.Join(e.config.NewConfigPath, "docker.json")
|
|
if _, err := os.Stat(dockerConfigPath); err != nil {
|
|
return fmt.Errorf("Docker config not found: %w", err)
|
|
}
|
|
|
|
fmt.Printf("[DOCKER] Docker secrets migration validation successful\n")
|
|
return nil
|
|
}
|
|
|
|
// RollbackDockerSecretsMigration rolls back the Docker secrets migration
|
|
func (e *DockerSecretsExecutor) RollbackDockerSecretsMigration(backupDir string) error {
|
|
fmt.Printf("[DOCKER] Rolling back Docker secrets migration from backup: %s\n", backupDir)
|
|
|
|
// Restore original secret files from backup
|
|
entries, err := os.ReadDir(backupDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read backup directory: %w", err)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
|
|
backupPath := filepath.Join(backupDir, entry.Name())
|
|
originalPath := filepath.Join(e.config.NewConfigPath, entry.Name())
|
|
|
|
if err := copySecretFile(backupPath, originalPath); err != nil {
|
|
fmt.Printf("[DOCKER] Failed to restore %s: %v\n", entry.Name(), err)
|
|
} else {
|
|
fmt.Printf("[DOCKER] Restored: %s\n", entry.Name())
|
|
}
|
|
}
|
|
|
|
// Remove Docker config
|
|
dockerConfigPath := filepath.Join(e.config.NewConfigPath, "docker.json")
|
|
if err := os.Remove(dockerConfigPath); err != nil {
|
|
fmt.Printf("[DOCKER] Failed to remove Docker config: %v\n", err)
|
|
}
|
|
|
|
fmt.Printf("[DOCKER] Docker secrets migration rollback completed\n")
|
|
return nil
|
|
} |