diff --git a/tests/integration/security/sandbox_escape_test.go b/tests/integration/security/sandbox_escape_test.go new file mode 100644 index 0000000..c98a659 --- /dev/null +++ b/tests/integration/security/sandbox_escape_test.go @@ -0,0 +1,215 @@ +package security + +import ( + "context" + "os" + "os/exec" + "runtime" + "testing" + "time" + + "github.com/jfraeys/fetch_ml/internal/container" + "github.com/jfraeys/fetch_ml/internal/logging" +) + +// TestSandboxCapabilityDrop verifies that containers drop all capabilities +func TestSandboxCapabilityDrop(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("Skipping: requires Linux with Podman") + } + + var err error + if _, err = exec.LookPath("podman"); err != nil { + t.Skip("Skipping: podman not installed") + } + + logger := logging.NewLogger(0, false) + _, err = container.NewPodmanManager(logger) + if err != nil { + t.Fatalf("Failed to create podman manager: %v", err) + } + + // Test container with capability dropping + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Create a test container that tries to check capabilities + cfg := &container.ContainerConfig{ + Name: "cap-test", + Image: "alpine:latest", + Command: []string{"sh", "-c", "capsh --print 2>/dev/null || cat /proc/self/status | grep Cap"}, + SecurityOpts: []string{ + "no-new-privileges:true", + "seccomp=unconfined", // Allow checking capabilities + }, + } + + // Add capability drop + args := container.BuildRunArgs(cfg) + args = append(args, "--cap-drop=all") + + cmd := exec.CommandContext(ctx, "podman", args...) + output, err := cmd.CombinedOutput() + + // The container should run but show no capabilities + t.Logf("Capability check output: %s", string(output)) + + if ctx.Err() == context.DeadlineExceeded { + t.Error("Container execution timed out - may indicate capability issue") + } +} + +// TestSandboxNoNewPrivileges verifies that no-new-privileges flag works +func TestSandboxNoNewPrivileges(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("Skipping: requires Linux with Podman") + } + + if _, err := exec.LookPath("podman"); err != nil { + t.Skip("Skipping: podman not installed") + } + + // Test that a container with no-new-privileges cannot escalate + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cfg := &container.ContainerConfig{ + Name: "nnp-test", + Image: "alpine:latest", + Command: []string{"id"}, + SecurityOpts: []string{ + "no-new-privileges:true", + }, + } + + args := container.BuildRunArgs(cfg) + cmd := exec.CommandContext(ctx, "podman", args...) + output, err := cmd.CombinedOutput() + + if err != nil { + t.Logf("Container output (may be expected): %s", string(output)) + } +} + +// TestSandboxSeccompEnforcement verifies seccomp blocks dangerous syscalls +func TestSandboxSeccompEnforcement(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("Skipping: requires Linux with Podman") + } + + if _, err := exec.LookPath("podman"); err != nil { + t.Skip("Skipping: podman not installed") + } + + // Check if hardened seccomp profile exists + seccompPath := "configs/seccomp/default-hardened.json" + if _, err := os.Stat(seccompPath); os.IsNotExist(err) { + t.Skipf("Skipping: seccomp profile not found at %s", seccompPath) + } + + // Test that ptrace is blocked by seccomp + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + cfg := &container.ContainerConfig{ + Name: "seccomp-test", + Image: "alpine:latest", + Command: []string{"sh", "-c", "apk add --no-cache strace 2>/dev/null && strace -p 1 2>&1 || echo 'seccomp working'"}, + } + + args := container.BuildRunArgs(cfg) + args = append(args, "--security-opt", "seccomp="+seccompPath) + + cmd := exec.CommandContext(ctx, "podman", args...) + output, err := cmd.CombinedOutput() + + t.Logf("Seccomp test output: %s", string(output)) + + // If seccomp is working, strace should fail or be killed + if err == nil && ctx.Err() != context.DeadlineExceeded { + t.Log("Container completed - seccomp may have blocked syscall") + } +} + +// TestSandboxNetworkIsolation verifies network=none blocks outbound connections +func TestSandboxNetworkIsolation(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("Skipping: requires Linux with Podman") + } + + if _, err := exec.LookPath("podman"); err != nil { + t.Skip("Skipping: podman not installed") + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + // Test container with no network access + cfg := &container.ContainerConfig{ + Name: "net-test", + Image: "alpine:latest", + Command: []string{"sh", "-c", "wget -q --timeout=3 http://example.com 2>&1 || echo 'network blocked'"}, + } + + args := container.BuildRunArgs(cfg) + args = append(args, "--network=none") + + cmd := exec.CommandContext(ctx, "podman", args...) + output, err := cmd.CombinedOutput() + + result := string(output) + t.Logf("Network test output: %s", result) + + // Network should be blocked + if err == nil && !contains(result, "network blocked") && !contains(result, "bad address") { + t.Log("Warning: Network may not be properly isolated") + } +} + +// TestSandboxFilesystemEscape verifies container cannot write outside its root +func TestSandboxFilesystemEscape(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("Skipping: requires Linux with Podman") + } + + if _, err := exec.LookPath("podman"); err != nil { + t.Skip("Skipping: podman not installed") + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + // Test that read-only root prevents writes + cfg := &container.ContainerConfig{ + Name: "fs-test", + Image: "alpine:latest", + Command: []string{"sh", "-c", "touch /etc/test-write 2>&1 || echo 'read-only root working'"}, + } + + args := container.BuildRunArgs(cfg) + args = append(args, "--read-only") + + cmd := exec.CommandContext(ctx, "podman", args...) + output, err := cmd.CombinedOutput() + + result := string(output) + t.Logf("Filesystem test output: %s", result) + + // Write should fail due to read-only root + if err == nil && !contains(result, "read-only") && !contains(result, "Read-only") { + t.Log("Read-only root may not be enforced (container could have writable layers)") + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr)) +} + +func containsHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/tests/integration/security/secrets_integration_test.go b/tests/integration/security/secrets_integration_test.go new file mode 100644 index 0000000..9d780af --- /dev/null +++ b/tests/integration/security/secrets_integration_test.go @@ -0,0 +1,224 @@ +package security + +import ( + "os" + "strings" + "testing" + + "github.com/jfraeys/fetch_ml/internal/worker" +) + +// TestSecretsEnvExpansionE2E tests environment variable expansion through full config load +func TestSecretsEnvExpansionE2E(t *testing.T) { + // Set test environment variables + os.Setenv("TEST_REDIS_PASSWORD", "secret_redis_pass_123") + os.Setenv("TEST_AWS_ACCESS_KEY", "AKIAIOSFODNN7EXAMPLE") + os.Setenv("TEST_AWS_SECRET_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY") + defer func() { + os.Unsetenv("TEST_REDIS_PASSWORD") + os.Unsetenv("TEST_AWS_ACCESS_KEY") + os.Unsetenv("TEST_AWS_SECRET_KEY") + }() + + // Create a config with environment variable references + config := &worker.Config{ + RedisPassword: "${TEST_REDIS_PASSWORD}", + SnapshotStore: worker.SnapshotStoreConfig{ + AccessKey: "${TEST_AWS_ACCESS_KEY}", + SecretKey: "${TEST_AWS_SECRET_KEY}", + }, + } + + // Expand secrets + config.ExpandSecrets() + + // Verify expansion worked + if config.RedisPassword != "secret_redis_pass_123" { + t.Errorf("Redis password not expanded correctly: got %s", config.RedisPassword) + } + if config.SnapshotStore.AccessKey != "AKIAIOSFODNN7EXAMPLE" { + t.Errorf("AWS access key not expanded correctly: got %s", config.SnapshotStore.AccessKey) + } + if config.SnapshotStore.SecretKey != "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" { + t.Errorf("AWS secret key not expanded correctly: got %s", config.SnapshotStore.SecretKey) + } +} + +// TestSecretsPlaintextDetectionE2E tests that plaintext secrets fail validation +func TestSecretsPlaintextDetectionE2E(t *testing.T) { + tests := []struct { + name string + redisPass string + accessKey string + secretKey string + shouldFail bool + description string + }{ + { + name: "plaintext AWS key should fail", + redisPass: "${REDIS_PASSWORD}", + accessKey: "AKIAIOSFODNN7EXAMPLE", + secretKey: "${AWS_SECRET_KEY}", + shouldFail: true, + description: "AWS access key in plaintext should be detected", + }, + { + name: "plaintext high-entropy secret should fail", + redisPass: "${REDIS_PASSWORD}", + accessKey: "${AWS_ACCESS_KEY}", + secretKey: "super_secret_key_with_high_entropy_12345!@#$%", + shouldFail: true, + description: "High entropy secret should be detected as plaintext", + }, + { + name: "env references should pass", + redisPass: "${REDIS_PASSWORD}", + accessKey: "${AWS_ACCESS_KEY}", + secretKey: "${AWS_SECRET_KEY}", + shouldFail: false, + description: "Environment variable references should be allowed", + }, + { + name: "empty values should pass", + redisPass: "", + accessKey: "", + secretKey: "", + shouldFail: false, + description: "Empty values should be allowed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := &worker.Config{ + RedisPassword: tt.redisPass, + SnapshotStore: worker.SnapshotStoreConfig{ + AccessKey: tt.accessKey, + SecretKey: tt.secretKey, + }, + } + + err := config.ValidateNoPlaintextSecrets() + if tt.shouldFail && err == nil { + t.Errorf("%s: expected validation to fail but it passed", tt.description) + } + if !tt.shouldFail && err != nil { + t.Errorf("%s: expected validation to pass but got error: %v", tt.description, err) + } + }) + } +} + +// TestSecretsRotation tests hot-reload capability for secrets +func TestSecretsRotation(t *testing.T) { + // Initial secret value + os.Setenv("ROTATING_SECRET", "initial_secret_value") + defer os.Unsetenv("ROTATING_SECRET") + + // Load config initially + config1 := &worker.Config{ + RedisPassword: "${ROTATING_SECRET}", + } + config1.ExpandSecrets() + + if config1.RedisPassword != "initial_secret_value" { + t.Fatalf("Initial expansion failed: got %s", config1.RedisPassword) + } + + // Simulate secret rotation by changing env var + os.Setenv("ROTATING_SECRET", "rotated_secret_value") + + // Create new config (simulating reload) + config2 := &worker.Config{ + RedisPassword: "${ROTATING_SECRET}", + } + config2.ExpandSecrets() + + if config2.RedisPassword != "rotated_secret_value" { + t.Errorf("Secret rotation failed: expected 'rotated_secret_value', got '%s'", config2.RedisPassword) + } + + // Verify old config still has old value (no shared state) + if config1.RedisPassword != "initial_secret_value" { + t.Logf("Old config retained initial value as expected: %s", config1.RedisPassword) + } +} + +// TestSecretsPatternDetection validates specific secret pattern detection +func TestSecretsPatternDetection(t *testing.T) { + patterns := []struct { + value string + shouldMatch bool + pattern string + }{ + // AWS patterns + {"AKIAIOSFODNN7EXAMPLE", true, "AWS Access Key"}, + {"ASIAIOSFODNN7EXAMPLE", true, "AWS Session Key"}, + {"AKIA1234567890", true, "AWS Access Key short"}, + + // GitHub patterns + {"ghp_xxxxxxxxxxxxxxxxxxxx", true, "GitHub Personal Token"}, + {"gho_xxxxxxxxxxxxxxxxxxxx", true, "GitHub OAuth"}, + {"github_pat_xxxxxxxxxx", true, "GitHub App Token"}, + + // GitLab patterns + {"glpat-xxxxxxxxxxxxxxxx", true, "GitLab Token"}, + + // OpenAI/Stripe patterns + {"sk-xxxxxxxxxxxxxxxxxxxxxxxx", true, "OpenAI/Stripe Secret"}, + {"sk_test_xxxxxxxxxxxxxx", true, "Stripe Test Key"}, + {"sk_live_xxxxxxxxxxxxxx", true, "Stripe Live Key"}, + + // Non-secrets that should NOT match + {"not-a-secret", false, "Plain text"}, + {"password123", false, "Simple password"}, + {"${ENV_VAR}", false, "Env reference"}, + {"AKIA", false, "Too short"}, // Too short to be a real key + {"", false, "Empty string"}, + } + + for _, p := range patterns { + t.Run(p.pattern, func(t *testing.T) { + // Check if the value looks like a secret + looksLikeSecret := looksLikeSecretHelper(p.value) + + if p.shouldMatch && !looksLikeSecret { + t.Errorf("Expected '%s' to be detected as secret but wasn't", p.value) + } + if !p.shouldMatch && looksLikeSecret { + t.Logf("Note: '%s' was detected as secret (may be acceptable based on entropy)", p.value) + } + }) + } +} + +// looksLikeSecretHelper mirrors the internal detection logic for testing +func looksLikeSecretHelper(value string) bool { + if value == "" || strings.HasPrefix(value, "${") { + return false + } + + // Check for known secret patterns + patterns := []string{ + "AKIA", // AWS Access Key + "ASIA", // AWS Session Key + "ghp_", // GitHub personal + "gho_", // GitHub OAuth + "github_pat_", // GitHub app + "glpat-", // GitLab + "sk-", // OpenAI/Stripe + } + + for _, pattern := range patterns { + if strings.Contains(value, pattern) { + return true + } + } + + // High entropy check (simplified) + if len(value) >= 20 { + return true + } + + return false +}