diff --git a/internal/k8s/cluster/provider.go b/internal/k8s/cluster/provider.go index dcaa20c..33a40d1 100644 --- a/internal/k8s/cluster/provider.go +++ b/internal/k8s/cluster/provider.go @@ -246,3 +246,76 @@ type ErrInvalidConfiguration struct { func (e *ErrInvalidConfiguration) Error() string { return "invalid cluster configuration: " + e.Message } + +// Security Best Practices +// +// These guidelines should be followed when provisioning clusters and resources. + +// SecurityWarnings contains common security warnings to include in plans +var SecurityWarnings = map[string]string{ + "public-endpoint": "Warning: Enabling public endpoints exposes the cluster to the internet. Consider using private endpoints with VPN/bastion access for production workloads.", + "public-service": "Warning: Services of type LoadBalancer expose your application to the internet. Consider using internal load balancers or Ingress with authentication.", + "no-network-policy": "Note: No network policy specified. Consider adding network policies to restrict pod-to-pod traffic for defense in depth.", + "secrets-plain": "Warning: Secrets should be managed using a secrets management solution (e.g., HashiCorp Vault, AWS Secrets Manager, Azure Key Vault) rather than plain Kubernetes secrets.", + "root-container": "Warning: Running containers as root is a security risk. Consider using non-root users in your container images.", + "privileged": "Warning: Privileged containers have full access to the host. Only use when absolutely necessary.", + "host-network": "Warning: Using host network bypasses network policies. Consider using standard pod networking.", + "no-resource-limits": "Note: No resource limits specified. Consider adding CPU/memory limits to prevent resource exhaustion.", +} + +// SecurityRecommendations contains security recommendations for different scenarios +var SecurityRecommendations = map[string][]string{ + "new-cluster": { + "Enable private endpoint access when possible", + "Configure network policies to restrict pod communication", + "Enable pod security standards (restricted or baseline)", + "Set up audit logging for security monitoring", + "Use managed node groups with automatic updates", + }, + "new-deployment": { + "Use non-root containers where possible", + "Set resource requests and limits", + "Configure readiness and liveness probes", + "Use read-only root filesystem when possible", + "Drop unnecessary Linux capabilities", + }, + "new-service": { + "Use ClusterIP for internal-only services", + "Use internal load balancers for private traffic", + "Consider Ingress with TLS termination for HTTP services", + "Add network policies to control service access", + }, + "secrets": { + "Use external secrets management (Vault, AWS Secrets Manager, etc.)", + "Enable encryption at rest for etcd", + "Rotate secrets regularly", + "Limit secret access using RBAC", + }, +} + +// GetSecurityWarning returns a security warning for the given key +func GetSecurityWarning(key string) string { + if warning, ok := SecurityWarnings[key]; ok { + return warning + } + return "" +} + +// GetSecurityRecommendations returns security recommendations for the given scenario +func GetSecurityRecommendations(scenario string) []string { + if recs, ok := SecurityRecommendations[scenario]; ok { + return recs + } + return nil +} + +// IsPublicEndpoint checks if cluster configuration has public endpoint enabled +func IsPublicEndpoint(opts CreateOptions) bool { + return opts.EnablePublicEndpoint && !opts.EnablePrivateEndpoint +} + +// ShouldWarnPublicAccess checks if a public access warning should be shown +func ShouldWarnPublicAccess(opts CreateOptions) bool { + // Warn if only public endpoint is enabled without private + return opts.EnablePublicEndpoint && !opts.EnablePrivateEndpoint +} diff --git a/internal/k8s/cluster/security_test.go b/internal/k8s/cluster/security_test.go new file mode 100644 index 0000000..f6d1c4a --- /dev/null +++ b/internal/k8s/cluster/security_test.go @@ -0,0 +1,253 @@ +package cluster + +import ( + "testing" +) + +func TestGetSecurityWarning(t *testing.T) { + tests := []struct { + name string + key string + wantNone bool + }{ + { + name: "public endpoint warning", + key: "public-endpoint", + }, + { + name: "public service warning", + key: "public-service", + }, + { + name: "no network policy warning", + key: "no-network-policy", + }, + { + name: "secrets warning", + key: "secrets-plain", + }, + { + name: "root container warning", + key: "root-container", + }, + { + name: "privileged warning", + key: "privileged", + }, + { + name: "host network warning", + key: "host-network", + }, + { + name: "no resource limits warning", + key: "no-resource-limits", + }, + { + name: "unknown key returns empty", + key: "unknown-key", + wantNone: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + warning := GetSecurityWarning(tt.key) + if tt.wantNone { + if warning != "" { + t.Errorf("GetSecurityWarning(%q) = %q, want empty", tt.key, warning) + } + } else { + if warning == "" { + t.Errorf("GetSecurityWarning(%q) returned empty, want warning", tt.key) + } + } + }) + } +} + +func TestGetSecurityRecommendations(t *testing.T) { + tests := []struct { + name string + scenario string + wantNone bool + }{ + { + name: "new cluster recommendations", + scenario: "new-cluster", + }, + { + name: "new deployment recommendations", + scenario: "new-deployment", + }, + { + name: "new service recommendations", + scenario: "new-service", + }, + { + name: "secrets recommendations", + scenario: "secrets", + }, + { + name: "unknown scenario returns nil", + scenario: "unknown-scenario", + wantNone: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + recs := GetSecurityRecommendations(tt.scenario) + if tt.wantNone { + if recs != nil { + t.Errorf("GetSecurityRecommendations(%q) = %v, want nil", tt.scenario, recs) + } + } else { + if recs == nil || len(recs) == 0 { + t.Errorf("GetSecurityRecommendations(%q) returned empty, want recommendations", tt.scenario) + } + } + }) + } +} + +func TestIsPublicEndpoint(t *testing.T) { + tests := []struct { + name string + opts CreateOptions + expected bool + }{ + { + name: "only public endpoint enabled", + opts: CreateOptions{ + EnablePublicEndpoint: true, + EnablePrivateEndpoint: false, + }, + expected: true, + }, + { + name: "both endpoints enabled", + opts: CreateOptions{ + EnablePublicEndpoint: true, + EnablePrivateEndpoint: true, + }, + expected: false, + }, + { + name: "only private endpoint enabled", + opts: CreateOptions{ + EnablePublicEndpoint: false, + EnablePrivateEndpoint: true, + }, + expected: false, + }, + { + name: "neither endpoint enabled", + opts: CreateOptions{ + EnablePublicEndpoint: false, + EnablePrivateEndpoint: false, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsPublicEndpoint(tt.opts) + if result != tt.expected { + t.Errorf("IsPublicEndpoint() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestShouldWarnPublicAccess(t *testing.T) { + tests := []struct { + name string + opts CreateOptions + expected bool + }{ + { + name: "warn when only public endpoint", + opts: CreateOptions{ + EnablePublicEndpoint: true, + EnablePrivateEndpoint: false, + }, + expected: true, + }, + { + name: "no warn when both endpoints", + opts: CreateOptions{ + EnablePublicEndpoint: true, + EnablePrivateEndpoint: true, + }, + expected: false, + }, + { + name: "no warn when only private", + opts: CreateOptions{ + EnablePublicEndpoint: false, + EnablePrivateEndpoint: true, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ShouldWarnPublicAccess(tt.opts) + if result != tt.expected { + t.Errorf("ShouldWarnPublicAccess() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestSecurityWarningsContent(t *testing.T) { + // Verify that security warnings contain expected keywords + expectedKeywords := map[string]string{ + "public-endpoint": "public", + "public-service": "LoadBalancer", + "no-network-policy": "network policy", + "secrets-plain": "secrets", + "root-container": "root", + "privileged": "Privileged", + "host-network": "host network", + "no-resource-limits": "resource limits", + } + + for key, keyword := range expectedKeywords { + t.Run(key, func(t *testing.T) { + warning := GetSecurityWarning(key) + if warning == "" { + t.Fatalf("GetSecurityWarning(%q) returned empty", key) + } + if !containsIgnoreCase(warning, keyword) { + t.Errorf("GetSecurityWarning(%q) = %q, does not contain %q", key, warning, keyword) + } + }) + } +} + +func containsIgnoreCase(s, substr string) bool { + // Simple case-insensitive contains check + for i := 0; i <= len(s)-len(substr); i++ { + match := true + for j := 0; j < len(substr); j++ { + if toLower(s[i+j]) != toLower(substr[j]) { + match = false + break + } + } + if match { + return true + } + } + return false +} + +func toLower(c byte) byte { + if c >= 'A' && c <= 'Z' { + return c + 32 + } + return c +}