Files
Redflag/aggregator-agent/internal/migration/docker.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

395 lines
10 KiB
Go

package migration
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/Fimeg/RedFlag/aggregator-agent/internal/common"
)
// DockerDetection represents Docker secrets detection results
type DockerDetection struct {
DockerAvailable bool `json:"docker_available"`
SecretsMountPath string `json:"secrets_mount_path"`
RequiredSecrets []string `json:"required_secrets"`
ExistingSecrets []string `json:"existing_secrets"`
MigrateToSecrets bool `json:"migrate_to_secrets"`
SecretFiles []common.AgentFile `json:"secret_files"`
DetectionTime time.Time `json:"detection_time"`
}
// SecretFile represents a file that should be migrated to Docker secrets
type SecretFile struct {
Name string `json:"name"`
SourcePath string `json:"source_path"`
SecretPath string `json:"secret_path"`
Encrypted bool `json:"encrypted"`
Checksum string `json:"checksum"`
Size int64 `json:"size"`
}
// DockerConfig holds Docker secrets configuration
type DockerConfig struct {
Enabled bool `json:"enabled"`
SecretsPath string `json:"secrets_path"`
EncryptionKey string `json:"encryption_key,omitempty"`
Secrets map[string]string `json:"secrets,omitempty"`
}
// GetDockerSecretsPath returns the platform-specific Docker secrets path
func GetDockerSecretsPath() string {
if runtime.GOOS == "windows" {
return `C:\ProgramData\Docker\secrets`
}
return "/run/secrets"
}
// DetectDockerSecretsRequirements detects if Docker secrets migration is needed
func DetectDockerSecretsRequirements(config *FileDetectionConfig) (*DockerDetection, error) {
detection := &DockerDetection{
DetectionTime: time.Now(),
SecretsMountPath: GetDockerSecretsPath(),
}
// Check if Docker secrets directory exists
if _, err := os.Stat(detection.SecretsMountPath); err == nil {
detection.DockerAvailable = true
fmt.Printf("[DOCKER] Docker secrets mount path detected: %s\n", detection.SecretsMountPath)
} else {
fmt.Printf("[DOCKER] Docker secrets not available: %s\n", err)
return detection, nil
}
// Scan for sensitive files that should be migrated to secrets
secretFiles, err := scanSecretFiles(config)
if err != nil {
return nil, fmt.Errorf("failed to scan for secret files: %w", err)
}
detection.SecretFiles = secretFiles
detection.MigrateToSecrets = len(secretFiles) > 0
// Identify required secrets
detection.RequiredSecrets = identifyRequiredSecrets(secretFiles)
// Check existing secrets
detection.ExistingSecrets = scanExistingSecrets(detection.SecretsMountPath)
return detection, nil
}
// scanSecretFiles scans for files containing sensitive data
func scanSecretFiles(config *FileDetectionConfig) ([]common.AgentFile, error) {
var secretFiles []common.AgentFile
// Define sensitive file patterns
secretPatterns := []string{
"agent.key",
"server.key",
"ca.crt",
"*.pem",
"*.key",
"config.json", // Will be filtered for sensitive content
}
// Scan new directory paths for secret files
for _, dirPath := range []string{config.NewConfigPath, config.NewStatePath} {
if _, err := os.Stat(dirPath); err == nil {
files, err := scanSecretDirectory(dirPath, secretPatterns)
if err != nil {
return nil, fmt.Errorf("failed to scan directory %s for secrets: %w", dirPath, err)
}
secretFiles = append(secretFiles, files...)
}
}
return secretFiles, nil
}
// scanSecretDirectory scans a directory for files that may contain secrets
func scanSecretDirectory(dirPath string, patterns []string) ([]common.AgentFile, error) {
var files []common.AgentFile
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
// Check if file matches secret patterns
if !matchesSecretPattern(path, patterns) {
// For config.json, check if it contains sensitive data
if filepath.Base(path) == "config.json" {
if hasSensitiveContent(path) {
return addSecretFile(&files, path, info)
}
}
return nil
}
return addSecretFile(&files, path, info)
})
return files, err
}
// addSecretFile adds a file to the secret files list
func addSecretFile(files *[]common.AgentFile, path string, info os.FileInfo) error {
checksum, err := calculateFileChecksum(path)
if err != nil {
return nil // Skip files we can't read
}
file := common.AgentFile{
Path: path,
Size: info.Size(),
ModifiedTime: info.ModTime(),
Checksum: checksum,
Required: true,
Migrate: true,
Description: getSecretFileDescription(path),
}
*files = append(*files, file)
return nil
}
// matchesSecretPattern checks if a file path matches secret patterns
func matchesSecretPattern(path string, patterns []string) bool {
base := filepath.Base(path)
for _, pattern := range patterns {
if matched, _ := filepath.Match(pattern, base); matched {
return true
}
}
return false
}
// hasSensitiveContent checks if a config file contains sensitive data
func hasSensitiveContent(configPath string) bool {
data, err := os.ReadFile(configPath)
if err != nil {
return false
}
var config map[string]interface{}
if err := json.Unmarshal(data, &config); err != nil {
return false
}
// Check for sensitive fields
sensitiveFields := []string{
"password", "token", "key", "secret", "credential",
"proxy", "tls", "certificate", "private",
}
for _, field := range sensitiveFields {
if containsSensitiveField(config, field) {
return true
}
}
return false
}
// containsSensitiveField recursively checks for sensitive fields in config
func containsSensitiveField(config map[string]interface{}, field string) bool {
for key, value := range config {
if containsString(key, field) {
return true
}
if nested, ok := value.(map[string]interface{}); ok {
if containsSensitiveField(nested, field) {
return true
}
}
}
return false
}
// containsString checks if a string contains a substring (case-insensitive)
func containsString(s, substr string) bool {
s = strings.ToLower(s)
substr = strings.ToLower(substr)
return strings.Contains(s, substr)
}
// identifyRequiredSecrets identifies which secrets need to be created
func identifyRequiredSecrets(secretFiles []common.AgentFile) []string {
var secrets []string
for _, file := range secretFiles {
secretName := filepath.Base(file.Path)
if file.Path == "config.json" {
secrets = append(secrets, "config.json.enc")
} else {
secrets = append(secrets, secretName)
}
}
return secrets
}
// scanExistingSecrets scans the Docker secrets directory for existing secrets
func scanExistingSecrets(secretsPath string) []string {
var secrets []string
entries, err := os.ReadDir(secretsPath)
if err != nil {
return secrets
}
for _, entry := range entries {
if !entry.IsDir() {
secrets = append(secrets, entry.Name())
}
}
return secrets
}
// getSecretFileDescription returns a description for a secret file
func getSecretFileDescription(path string) string {
base := filepath.Base(path)
switch {
case base == "agent.key":
return "Agent private key"
case base == "server.key":
return "Server private key"
case base == "ca.crt":
return "Certificate authority certificate"
case strings.Contains(base, ".key"):
return "Private key file"
case strings.Contains(base, ".crt") || strings.Contains(base, ".pem"):
return "Certificate file"
case base == "config.json":
return "Configuration file with sensitive data"
default:
return "Secret file"
}
}
// EncryptFile encrypts a file using AES-256-GCM
func EncryptFile(inputPath, outputPath, key string) error {
// Generate key from passphrase
keyBytes := sha256.Sum256([]byte(key))
// Read input file
plaintext, err := os.ReadFile(inputPath)
if err != nil {
return fmt.Errorf("failed to read input file: %w", err)
}
// Create cipher
block, err := aes.NewCipher(keyBytes[:])
if err != nil {
return fmt.Errorf("failed to create cipher: %w", err)
}
// Create GCM
gcm, err := cipher.NewGCM(block)
if err != nil {
return fmt.Errorf("failed to create GCM: %w", err)
}
// Generate nonce
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return fmt.Errorf("failed to generate nonce: %w", err)
}
// Encrypt
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
// Write encrypted file
if err := os.WriteFile(outputPath, ciphertext, 0600); err != nil {
return fmt.Errorf("failed to write encrypted file: %w", err)
}
return nil
}
// DecryptFile decrypts a file using AES-256-GCM
func DecryptFile(inputPath, outputPath, key string) error {
// Generate key from passphrase
keyBytes := sha256.Sum256([]byte(key))
// Read encrypted file
ciphertext, err := os.ReadFile(inputPath)
if err != nil {
return fmt.Errorf("failed to read encrypted file: %w", err)
}
// Create cipher
block, err := aes.NewCipher(keyBytes[:])
if err != nil {
return fmt.Errorf("failed to create cipher: %w", err)
}
// Create GCM
gcm, err := cipher.NewGCM(block)
if err != nil {
return fmt.Errorf("failed to create GCM: %w", err)
}
// Check minimum length
if len(ciphertext) < gcm.NonceSize() {
return fmt.Errorf("ciphertext too short")
}
// Extract nonce and ciphertext
nonce := ciphertext[:gcm.NonceSize()]
ciphertext = ciphertext[gcm.NonceSize():]
// Decrypt
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return fmt.Errorf("failed to decrypt: %w", err)
}
// Write decrypted file
if err := os.WriteFile(outputPath, plaintext, 0600); err != nil {
return fmt.Errorf("failed to write decrypted file: %w", err)
}
return nil
}
// GenerateEncryptionKey generates a random encryption key
func GenerateEncryptionKey() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("failed to generate encryption key: %w", err)
}
return hex.EncodeToString(bytes), nil
}
// IsDockerEnvironment checks if running in Docker environment
func IsDockerEnvironment() bool {
// Check for .dockerenv file
if _, err := os.Stat("/.dockerenv"); err == nil {
return true
}
// Check for Docker in cgroup
if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
if containsString(string(data), "docker") {
return true
}
}
return false
}