Files
Redflag/aggregator-agent/internal/migration/executor.go
jpetree331 f97d4845af feat(security): A-1 Ed25519 key rotation + A-2 replay attack fixes
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>
2026-03-28 21:25:47 -04:00

561 lines
19 KiB
Go

package migration
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/Fimeg/RedFlag/aggregator-agent/internal/common"
"github.com/Fimeg/RedFlag/aggregator-agent/internal/event"
"github.com/Fimeg/RedFlag/aggregator-agent/internal/models"
"github.com/google/uuid"
)
// MigrationPlan represents a complete migration plan
type MigrationPlan struct {
Detection *MigrationDetection `json:"detection"`
TargetVersion string `json:"target_version"`
Config *FileDetectionConfig `json:"config"`
BackupPath string `json:"backup_path"`
EstimatedDuration time.Duration `json:"estimated_duration"`
RiskLevel string `json:"risk_level"` // low, medium, high
}
// MigrationResult represents the result of a migration execution
type MigrationResult struct {
Success bool `json:"success"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
Duration time.Duration `json:"duration"`
BackupPath string `json:"backup_path"`
MigratedFiles []string `json:"migrated_files"`
Errors []string `json:"errors"`
Warnings []string `json:"warnings"`
AppliedChanges []string `json:"applied_changes"`
RollbackAvailable bool `json:"rollback_available"`
}
// MigrationExecutor handles the execution of migration plans
type MigrationExecutor struct {
plan *MigrationPlan
result *MigrationResult
eventBuffer *event.Buffer
agentID uuid.UUID
stateManager *StateManager
}
// NewMigrationExecutor creates a new migration executor
func NewMigrationExecutor(plan *MigrationPlan, configPath string) *MigrationExecutor {
return &MigrationExecutor{
plan: plan,
result: &MigrationResult{},
stateManager: NewStateManager(configPath),
}
}
// NewMigrationExecutorWithEvents creates a new migration executor with event buffering
func NewMigrationExecutorWithEvents(plan *MigrationPlan, eventBuffer *event.Buffer, agentID uuid.UUID, configPath string) *MigrationExecutor {
return &MigrationExecutor{
plan: plan,
result: &MigrationResult{},
eventBuffer: eventBuffer,
agentID: agentID,
stateManager: NewStateManager(configPath),
}
}
// bufferEvent buffers a migration failure event
func (e *MigrationExecutor) bufferEvent(eventSubtype, severity, component, message string, metadata map[string]interface{}) {
if e.eventBuffer == nil {
return // Event buffering not enabled
}
// Use agent ID if available
var agentIDPtr *uuid.UUID
if e.agentID != uuid.Nil {
agentIDPtr = &e.agentID
}
event := &models.SystemEvent{
ID: uuid.New(),
AgentID: agentIDPtr,
EventType: "migration_failure",
EventSubtype: eventSubtype,
Severity: severity,
Component: component,
Message: message,
Metadata: metadata,
CreatedAt: time.Now(),
}
// Buffer the event (best effort)
if err := e.eventBuffer.BufferEvent(event); err != nil {
fmt.Printf("Warning: Failed to buffer migration event: %v\n", err)
}
}
// ExecuteMigration executes the complete migration plan
func (e *MigrationExecutor) ExecuteMigration() (*MigrationResult, error) {
e.result.StartTime = time.Now()
e.result.BackupPath = e.plan.BackupPath
fmt.Printf("[MIGRATION] Starting migration from %s to %s\n",
e.plan.Detection.CurrentAgentVersion, e.plan.TargetVersion)
// Phase 1: Create backups
if err := e.createBackups(); err != nil {
e.bufferEvent("backup_creation_failure", "error", "migration_executor",
fmt.Sprintf("Backup creation failed: %v", err),
map[string]interface{}{
"error": err.Error(),
"backup_path": e.plan.BackupPath,
"phase": "backup_creation",
})
return e.completeMigration(false, fmt.Errorf("backup creation failed: %w", err))
}
e.result.AppliedChanges = append(e.result.AppliedChanges, "Created backups at "+e.plan.BackupPath)
// Phase 2: Directory migration
if contains(e.plan.Detection.RequiredMigrations, "directory_migration") {
if err := e.migrateDirectories(); err != nil {
e.bufferEvent("directory_migration_failure", "error", "migration_executor",
fmt.Sprintf("Directory migration failed: %v", err),
map[string]interface{}{
"error": err.Error(),
"phase": "directory_migration",
})
return e.completeMigration(false, fmt.Errorf("directory migration failed: %w", err))
}
e.result.AppliedChanges = append(e.result.AppliedChanges, "Migrated directories")
// Mark directory migration as completed
if err := e.stateManager.MarkMigrationCompleted("directory_migration", e.plan.BackupPath, e.plan.TargetVersion); err != nil {
fmt.Printf("[MIGRATION] Warning: Failed to mark directory migration as completed: %v\n", err)
}
}
// Phase 3: Configuration migration
if contains(e.plan.Detection.RequiredMigrations, "config_migration") {
if err := e.migrateConfiguration(); err != nil {
e.bufferEvent("configuration_migration_failure", "error", "migration_executor",
fmt.Sprintf("Configuration migration failed: %v", err),
map[string]interface{}{
"error": err.Error(),
"phase": "configuration_migration",
})
return e.completeMigration(false, fmt.Errorf("configuration migration failed: %w", err))
}
e.result.AppliedChanges = append(e.result.AppliedChanges, "Migrated configuration")
// Mark configuration migration as completed
if err := e.stateManager.MarkMigrationCompleted("config_migration", e.plan.BackupPath, e.plan.TargetVersion); err != nil {
fmt.Printf("[MIGRATION] Warning: Failed to mark configuration migration as completed: %v\n", err)
}
}
// Phase 4: Docker secrets migration (if available)
if contains(e.plan.Detection.RequiredMigrations, "docker_secrets_migration") {
if e.plan.Detection.DockerDetection == nil {
e.bufferEvent("docker_migration_failure", "error", "migration_executor",
"Docker secrets migration requested but detection data missing",
map[string]interface{}{
"error": "missing detection data",
"phase": "docker_secrets_migration",
})
return e.completeMigration(false, fmt.Errorf("docker secrets migration requested but detection data missing"))
}
dockerExecutor := NewDockerSecretsExecutor(e.plan.Detection.DockerDetection, e.plan.Config)
if err := dockerExecutor.ExecuteDockerSecretsMigration(); err != nil {
e.bufferEvent("docker_migration_failure", "error", "migration_executor",
fmt.Sprintf("Docker secrets migration failed: %v", err),
map[string]interface{}{
"error": err.Error(),
"phase": "docker_secrets_migration",
})
return e.completeMigration(false, fmt.Errorf("docker secrets migration failed: %w", err))
}
e.result.AppliedChanges = append(e.result.AppliedChanges, "Migrated to Docker secrets")
// Mark docker secrets migration as completed
if err := e.stateManager.MarkMigrationCompleted("docker_secrets_migration", e.plan.BackupPath, e.plan.TargetVersion); err != nil {
fmt.Printf("[MIGRATION] Warning: Failed to mark docker secrets migration as completed: %v\n", err)
}
}
// Phase 5: Security hardening
if contains(e.plan.Detection.RequiredMigrations, "security_hardening") {
if err := e.applySecurityHardening(); err != nil {
e.result.Warnings = append(e.result.Warnings,
fmt.Sprintf("Security hardening incomplete: %v", err))
} else {
e.result.AppliedChanges = append(e.result.AppliedChanges, "Applied security hardening")
// Mark security hardening as completed
if err := e.stateManager.MarkMigrationCompleted("security_hardening", e.plan.BackupPath, e.plan.TargetVersion); err != nil {
fmt.Printf("[MIGRATION] Warning: Failed to mark security hardening as completed: %v\n", err)
}
}
}
// Phase 6: Validation
if err := e.validateMigration(); err != nil {
e.bufferEvent("migration_validation_failure", "error", "migration_executor",
fmt.Sprintf("Migration validation failed: %v", err),
map[string]interface{}{
"error": err.Error(),
"phase": "validation",
})
return e.completeMigration(false, fmt.Errorf("migration validation failed: %w", err))
}
return e.completeMigration(true, nil)
}
// createBackups creates backups of all existing files
func (e *MigrationExecutor) createBackups() error {
backupPath := e.plan.BackupPath
if backupPath == "" {
timestamp := time.Now().Format("2006-01-02-150405")
backupPath = fmt.Sprintf(e.plan.Config.BackupDirPattern, timestamp)
e.plan.BackupPath = backupPath
}
fmt.Printf("[MIGRATION] Creating backup at: %s\n", backupPath)
// Create backup directory
if err := os.MkdirAll(backupPath, 0755); err != nil {
return fmt.Errorf("failed to create backup directory: %w", err)
}
// Backup all files in inventory
allFiles := e.collectAllFiles()
for _, file := range allFiles {
if err := e.backupFile(file, backupPath); err != nil {
return fmt.Errorf("failed to backup file %s: %w", file.Path, err)
}
e.result.MigratedFiles = append(e.result.MigratedFiles, file.Path)
}
return nil
}
// migrateDirectories handles directory migration from old to new paths
func (e *MigrationExecutor) migrateDirectories() error {
fmt.Printf("[MIGRATION] Migrating directories...\n")
// Create new directories
newDirectories := []string{e.plan.Config.NewConfigPath, e.plan.Config.NewStatePath}
for _, newPath := range newDirectories {
if err := os.MkdirAll(newPath, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", newPath, err)
}
fmt.Printf("[MIGRATION] Created directory: %s\n", newPath)
}
// Migrate files from old to new directories
for _, oldDir := range e.plan.Detection.Inventory.OldDirectoryPaths {
newDir := e.getNewDirectoryPath(oldDir)
if newDir == "" {
continue
}
if err := e.migrateDirectoryContents(oldDir, newDir); err != nil {
return fmt.Errorf("failed to migrate directory %s to %s: %w", oldDir, newDir, err)
}
fmt.Printf("[MIGRATION] Migrated: %s → %s\n", oldDir, newDir)
}
return nil
}
// migrateConfiguration handles configuration file migration
func (e *MigrationExecutor) migrateConfiguration() error {
fmt.Printf("[MIGRATION] Migrating configuration...\n")
// Find and migrate config files
for _, configFile := range e.plan.Detection.Inventory.ConfigFiles {
if strings.Contains(configFile.Path, "config.json") {
newPath := e.getNewConfigPath(configFile.Path)
if newPath != "" && newPath != configFile.Path {
if err := e.migrateConfigFile(configFile.Path, newPath); err != nil {
return fmt.Errorf("failed to migrate config file: %w", err)
}
fmt.Printf("[MIGRATION] Migrated config: %s → %s\n", configFile.Path, newPath)
}
}
}
return nil
}
// applySecurityHardening applies security-related configurations
func (e *MigrationExecutor) applySecurityHardening() error {
fmt.Printf("[MIGRATION] Applying security hardening...\n")
// This would integrate with the config system to apply security defaults
// For now, just log what would be applied
for _, feature := range e.plan.Detection.MissingSecurityFeatures {
switch feature {
case "nonce_validation":
fmt.Printf("[MIGRATION] Enabling nonce validation\n")
case "machine_id_binding":
fmt.Printf("[MIGRATION] Configuring machine ID binding\n")
case "ed25519_verification":
fmt.Printf("[MIGRATION] Enabling Ed25519 verification\n")
case "subsystem_configuration":
fmt.Printf("[MIGRATION] Adding missing subsystem configurations\n")
case "system_subsystem":
fmt.Printf("[MIGRATION] Adding system scanner configuration\n")
case "updates_subsystem":
fmt.Printf("[MIGRATION] Adding updates subsystem configuration\n")
}
}
return nil
}
// validateMigration validates that the migration was successful
func (e *MigrationExecutor) validateMigration() error {
fmt.Printf("[MIGRATION] Validating migration...\n")
// Check that new directories exist
newDirectories := []string{e.plan.Config.NewConfigPath, e.plan.Config.NewStatePath}
for _, newDir := range newDirectories {
if _, err := os.Stat(newDir); err != nil {
return fmt.Errorf("new directory %s not found: %w", newDir, err)
}
}
// Check that config files exist in new location
for _, configFile := range e.plan.Detection.Inventory.ConfigFiles {
newPath := e.getNewConfigPath(configFile.Path)
if newPath != "" {
if _, err := os.Stat(newPath); err != nil {
return fmt.Errorf("migrated config file %s not found: %w", newPath, err)
}
}
}
fmt.Printf("[MIGRATION] ✅ Migration validation successful\n")
return nil
}
// Helper methods
func (e *MigrationExecutor) collectAllFiles() []common.AgentFile {
var allFiles []common.AgentFile
allFiles = append(allFiles, e.plan.Detection.Inventory.ConfigFiles...)
allFiles = append(allFiles, e.plan.Detection.Inventory.StateFiles...)
allFiles = append(allFiles, e.plan.Detection.Inventory.BinaryFiles...)
allFiles = append(allFiles, e.plan.Detection.Inventory.LogFiles...)
allFiles = append(allFiles, e.plan.Detection.Inventory.CertificateFiles...)
return allFiles
}
func (e *MigrationExecutor) backupFile(file common.AgentFile, backupPath string) error {
// Check if file exists before attempting backup
if _, err := os.Stat(file.Path); err != nil {
if os.IsNotExist(err) {
// File doesn't exist, log and skip
fmt.Printf("[MIGRATION] [agent] [migration_executor] File does not exist, skipping backup: %s\n", file.Path)
e.bufferEvent("backup_file_missing", "warning", "migration_executor",
fmt.Sprintf("File does not exist, skipping backup: %s", file.Path),
map[string]interface{}{
"file_path": file.Path,
"phase": "backup",
})
return nil
}
return fmt.Errorf("migration: failed to stat file %s: %w", file.Path, err)
}
// Clean paths to fix trailing slash issues
cleanOldConfig := filepath.Clean(e.plan.Config.OldConfigPath)
cleanOldState := filepath.Clean(e.plan.Config.OldStatePath)
cleanPath := filepath.Clean(file.Path)
var relPath string
var err error
// Try to get relative path based on expected file location
// If file is under old config path, use that as base
if strings.HasPrefix(cleanPath, cleanOldConfig) {
relPath, err = filepath.Rel(cleanOldConfig, cleanPath)
if err != nil || strings.Contains(relPath, "..") {
// Fallback to filename if path traversal or error
relPath = filepath.Base(cleanPath)
}
} else if strings.HasPrefix(cleanPath, cleanOldState) {
relPath, err = filepath.Rel(cleanOldState, cleanPath)
if err != nil || strings.Contains(relPath, "..") {
// Fallback to filename if path traversal or error
relPath = filepath.Base(cleanPath)
}
} else {
// File is not in expected old locations - use just the filename
// This happens for files already in the new location
relPath = filepath.Base(cleanPath)
// Add subdirectory based on file type to avoid collisions
switch {
case ContainsAny(cleanPath, []string{"config.json", "agent.key", "server.key", "ca.crt"}):
relPath = filepath.Join("config", relPath)
case ContainsAny(cleanPath, []string{
"pending_acks.json", "public_key.cache", "last_scan.json", "metrics.json"}):
relPath = filepath.Join("state", relPath)
}
}
// Ensure backup path is clean
cleanBackupPath := filepath.Clean(backupPath)
backupFilePath := filepath.Join(cleanBackupPath, relPath)
backupFilePath = filepath.Clean(backupFilePath)
backupDir := filepath.Dir(backupFilePath)
// Final safety check
if strings.Contains(backupFilePath, "..") {
return fmt.Errorf("migration: backup path contains parent directory reference: %s", backupFilePath)
}
if err := os.MkdirAll(backupDir, 0755); err != nil {
return fmt.Errorf("migration: failed to create backup directory %s: %w", backupDir, err)
}
// Copy file to backup location
if err := copyFile(cleanPath, backupFilePath); err != nil {
return fmt.Errorf("migration: failed to copy file to backup: %w", err)
}
fmt.Printf("[MIGRATION] [agent] [migration_executor] Successfully backed up: %s\n", cleanPath)
return nil
}
func (e *MigrationExecutor) migrateDirectoryContents(oldDir, newDir string) error {
return filepath.Walk(oldDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
relPath, err := filepath.Rel(oldDir, path)
if err != nil {
return err
}
newPath := filepath.Join(newDir, relPath)
newDirPath := filepath.Dir(newPath)
if err := os.MkdirAll(newDirPath, 0755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
if err := copyFile(path, newPath); err != nil {
return fmt.Errorf("failed to copy file: %w", err)
}
return nil
})
}
func (e *MigrationExecutor) migrateConfigFile(oldPath, newPath string) error {
// This would use the config migration logic from the config package
// For now, just copy the file
return copyFile(oldPath, newPath)
}
func (e *MigrationExecutor) getNewDirectoryPath(oldPath string) string {
if oldPath == e.plan.Config.OldConfigPath {
return e.plan.Config.NewConfigPath
}
if oldPath == e.plan.Config.OldStatePath {
return e.plan.Config.NewStatePath
}
return ""
}
func (e *MigrationExecutor) getNewConfigPath(oldPath string) string {
if strings.HasPrefix(oldPath, e.plan.Config.OldConfigPath) {
relPath := strings.TrimPrefix(oldPath, e.plan.Config.OldConfigPath)
return filepath.Join(e.plan.Config.NewConfigPath, relPath)
}
if strings.HasPrefix(oldPath, e.plan.Config.OldStatePath) {
relPath := strings.TrimPrefix(oldPath, e.plan.Config.OldStatePath)
return filepath.Join(e.plan.Config.NewStatePath, relPath)
}
return ""
}
func (e *MigrationExecutor) completeMigration(success bool, err error) (*MigrationResult, error) {
e.result.EndTime = time.Now()
e.result.Duration = e.result.EndTime.Sub(e.result.StartTime)
e.result.Success = success
e.result.RollbackAvailable = success && e.result.BackupPath != ""
if err != nil {
e.result.Errors = append(e.result.Errors, err.Error())
}
if success {
fmt.Printf("[MIGRATION] ✅ Migration completed successfully in %v\n", e.result.Duration)
if e.result.RollbackAvailable {
fmt.Printf("[MIGRATION] 📦 Rollback available at: %s\n", e.result.BackupPath)
}
// Clean up old directories after successful migration
if err := e.stateManager.CleanupOldDirectories(); err != nil {
fmt.Printf("[MIGRATION] Warning: Failed to cleanup old directories: %v\n", err)
}
} else {
fmt.Printf("[MIGRATION] ❌ Migration failed after %v\n", e.result.Duration)
if len(e.result.Errors) > 0 {
for _, errMsg := range e.result.Errors {
fmt.Printf("[MIGRATION] Error: %s\n", errMsg)
}
}
}
return e.result, err
}
// Utility functions
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
func copyFile(src, dst string) error {
sourceFile, err := os.Open(src)
if err != nil {
return err
}
defer sourceFile.Close()
destFile, err := os.Create(dst)
if err != nil {
return err
}
defer destFile.Close()
_, err = destFile.ReadFrom(sourceFile)
if err != nil {
return err
}
// Preserve file permissions
sourceInfo, err := os.Stat(src)
if err != nil {
return err
}
return os.Chmod(dst, sourceInfo.Mode())
}