diff --git a/cmd/tls-scanner/main.go b/cmd/tls-scanner/main.go index e89dfc17..b7dfed22 100644 --- a/cmd/tls-scanner/main.go +++ b/cmd/tls-scanner/main.go @@ -80,6 +80,8 @@ func run(args []string) (exitCode int) { isPQCCheck = *pqcCheck + policy := scanner.Policy() + defer func() { if *timingFile != "" { path := filepath.Join(*artifactDir, *timingFile) @@ -145,7 +147,7 @@ func run(args []string) (exitCode int) { return 1 } - scanResults := scanner.Scan(jobs, *concurrentScans, nil, nil) + scanResults := scanner.Scan(jobs, *concurrentScans, nil, nil, policy) finalScanResults = &scanResults if err := output.WriteOutputFiles(scanResults, *artifactDir, *jsonFile, *csvFile, *junitFile, isPQCCheck); err != nil { @@ -202,7 +204,7 @@ func run(args []string) (exitCode int) { } if len(pods) > 0 { - scanResults := scanner.PerformClusterScan(pods, *concurrentScans, client) + scanResults := scanner.PerformClusterScan(pods, *concurrentScans, client, policy) finalScanResults = &scanResults if err := output.WriteOutputFiles(scanResults, *artifactDir, *jsonFile, *csvFile, *junitFile, isPQCCheck); err != nil { @@ -225,7 +227,7 @@ func run(args []string) (exitCode int) { } jobs := []scanner.ScanJob{{IP: normalizeHost(*host), Port: portNum}} - scanResults := scanner.Scan(jobs, *concurrentScans, client, nil) + scanResults := scanner.Scan(jobs, *concurrentScans, client, nil, policy) finalScanResults = &scanResults if err := output.WriteOutputFiles(scanResults, *artifactDir, *jsonFile, *csvFile, *junitFile, isPQCCheck); err != nil { diff --git a/internal/k8s/tls.go b/internal/k8s/tls.go index c3b560a9..dadfe33e 100644 --- a/internal/k8s/tls.go +++ b/internal/k8s/tls.go @@ -21,19 +21,21 @@ func (c *Client) GetTLSSecurityProfile() (*TLSSecurityProfile, error) { profile := &TLSSecurityProfile{} - if ingressTLS, err := c.getIngressControllerTLS(); err != nil { - log.Printf("Warning: Could not get Ingress Controller TLS config: %v", err) - } else { - profile.IngressController = ingressTLS - } - + // APIServer is fetched first — it is the cluster-wide default that Ingress and + // Kubelet inherit when no component-specific override is configured. if apiServerTLS, err := c.getAPIServerTLS(); err != nil { log.Printf("Warning: Could not get API Server TLS config: %v", err) } else { profile.APIServer = apiServerTLS } - if kubeletTLS, err := c.getKubeletTLS(); err != nil { + if ingressTLS, err := c.getIngressControllerTLS(profile.APIServer); err != nil { + log.Printf("Warning: Could not get Ingress Controller TLS config: %v", err) + } else { + profile.IngressController = ingressTLS + } + + if kubeletTLS, err := c.getKubeletTLS(profile.APIServer); err != nil { log.Printf("Warning: Could not get Kubelet TLS config: %v", err) } else { profile.KubeletConfig = kubeletTLS @@ -42,7 +44,7 @@ func (c *Client) GetTLSSecurityProfile() (*TLSSecurityProfile, error) { return profile, nil } -func (c *Client) getIngressControllerTLS() (*IngressTLSProfile, error) { +func (c *Client) getIngressControllerTLS(fallback *APIServerTLSProfile) (*IngressTLSProfile, error) { ingress, err := c.operatorClient.OperatorV1().IngressControllers("openshift-ingress-operator").Get(context.Background(), "default", metav1.GetOptions{}) if err != nil { return nil, fmt.Errorf("failed to get IngressController custom resource: %v", err) @@ -51,9 +53,16 @@ func (c *Client) getIngressControllerTLS() (*IngressTLSProfile, error) { profile := &IngressTLSProfile{} if ingress.Spec.TLSSecurityProfile == nil { - profile.Type = defaultProfileName - profile.Ciphers = defaultProfileCiphers - profile.MinTLSVersion = defaultProfileMinVer + // No explicit override: inherit the cluster-wide APIServer profile. + if fallback != nil { + profile.Type = fallback.Type + profile.Ciphers = fallback.Ciphers + profile.MinTLSVersion = fallback.MinTLSVersion + } else { + profile.Type = defaultProfileName + profile.Ciphers = defaultProfileCiphers + profile.MinTLSVersion = defaultProfileMinVer + } return profile, nil } @@ -115,7 +124,7 @@ func (c *Client) getAPIServerTLS() (*APIServerTLSProfile, error) { return profile, nil } -func (c *Client) getKubeletTLS() (*KubeletTLSProfile, error) { +func (c *Client) getKubeletTLS(fallback *APIServerTLSProfile) (*KubeletTLSProfile, error) { kubeletConfigs, err := c.mcfgClient.MachineconfigurationV1().KubeletConfigs().List(context.Background(), metav1.ListOptions{}) if err != nil { return nil, fmt.Errorf("failed to list KubeletConfigs: %v", err) @@ -141,5 +150,13 @@ func (c *Client) getKubeletTLS() (*KubeletTLSProfile, error) { } } + // No explicit KubeletConfig override: inherit the cluster-wide APIServer profile. + if fallback != nil { + return &KubeletTLSProfile{ + TLSCipherSuites: fallback.Ciphers, + MinTLSVersion: fallback.MinTLSVersion, + }, nil + } + return nil, fmt.Errorf("no KubeletConfig with a TLSSecurityProfile found in the cluster") } diff --git a/internal/output/junit.go b/internal/output/junit.go index d983f1b6..b14b355b 100644 --- a/internal/output/junit.go +++ b/internal/output/junit.go @@ -59,13 +59,13 @@ func WriteJUnitOutput(scanResults scanner.ScanResults, filename string, pqcCheck } var failures []string - if portResult.IngressTLSConfigCompliance != nil && (!portResult.IngressTLSConfigCompliance.Version || !portResult.IngressTLSConfigCompliance.Ciphers) { + if portResult.IngressTLSConfigCompliance != nil && !scanner.IsTLSConfigCompliant(portResult.IngressTLSConfigCompliance) { failures = append(failures, "Ingress TLS config is not compliant.") } - if portResult.APIServerTLSConfigCompliance != nil && (!portResult.APIServerTLSConfigCompliance.Version || !portResult.APIServerTLSConfigCompliance.Ciphers) { + if portResult.APIServerTLSConfigCompliance != nil && !scanner.IsTLSConfigCompliant(portResult.APIServerTLSConfigCompliance) { failures = append(failures, "API Server TLS config is not compliant.") } - if portResult.KubeletTLSConfigCompliance != nil && (!portResult.KubeletTLSConfigCompliance.Version || !portResult.KubeletTLSConfigCompliance.Ciphers) { + if portResult.KubeletTLSConfigCompliance != nil && !scanner.IsTLSConfigCompliant(portResult.KubeletTLSConfigCompliance) { failures = append(failures, "Kubelet TLS config is not compliant.") } diff --git a/internal/scanner/compliance.go b/internal/scanner/compliance.go index 66829689..5c61b620 100644 --- a/internal/scanner/compliance.go +++ b/internal/scanner/compliance.go @@ -4,6 +4,22 @@ import ( "github.com/openshift/tls-scanner/internal/k8s" ) +// ComponentType identifies which TLS profile should be used when checking +// compliance for a scanned port. +type ComponentType int + +const ( + // GenericComponent covers all components that have no override capability — + // they must honor the cluster-wide APIServer TLS profile. + GenericComponent ComponentType = iota + // IngressComponent covers ports whose effective TLS profile is the + // IngressController profile (with APIServer fallback when no override is set). + IngressComponent + // KubeletComponent covers ports whose effective TLS profile is the + // KubeletConfig profile (with APIServer fallback when no override is set). + KubeletComponent +) + func getMinVersionValue(versions []string) int { if len(versions) == 0 { return 0 @@ -19,10 +35,10 @@ func getMinVersionValue(versions []string) int { } type profileInput struct { - profileType string - minTLSVersion string + profileType string + minTLSVersion string expectedCiphers []string - result *TLSConfigComplianceResult + result *TLSConfigComplianceResult } func evaluateCompliance(scannedMinVer int, scannedCiphers []string, input profileInput) { @@ -35,31 +51,43 @@ func evaluateCompliance(scannedMinVer int, scannedCiphers []string, input profil input.result.Ciphers = checkCipherCompliance(scannedCiphers, input.expectedCiphers) } -func CheckCompliance(portResult *PortResult, tlsProfile *k8s.TLSSecurityProfile) { +// CheckCompliance evaluates whether the port's observed TLS configuration +// honours the profile that applies to its component type: +// - IngressComponent → IngressController profile (or APIServer if no override) +// - KubeletComponent → KubeletConfig profile (or APIServer if no override) +// - GenericComponent → APIServer profile +// +// Only the relevant TLSConfigComplianceResult field on portResult is populated, +// leaving the others nil so callers never need to reason about which one to check. +func CheckCompliance(portResult *PortResult, tlsProfile *k8s.TLSSecurityProfile, componentType ComponentType) { scannedMinVer := 0 if portResult.TlsVersions != nil { scannedMinVer = getMinVersionValue(portResult.TlsVersions) } - portResult.IngressTLSConfigCompliance = &TLSConfigComplianceResult{} - portResult.APIServerTLSConfigCompliance = &TLSConfigComplianceResult{} - portResult.KubeletTLSConfigCompliance = &TLSConfigComplianceResult{} - - var profiles []profileInput - - if ing := tlsProfile.IngressController; ing != nil { - profiles = append(profiles, profileInput{ing.Type, ing.MinTLSVersion, ing.Ciphers, portResult.IngressTLSConfigCompliance}) - } - if api := tlsProfile.APIServer; api != nil { - profiles = append(profiles, profileInput{api.Type, api.MinTLSVersion, api.Ciphers, portResult.APIServerTLSConfigCompliance}) - } - if kube := tlsProfile.KubeletConfig; kube != nil { - profiles = append(profiles, profileInput{"", kube.MinTLSVersion, kube.TLSCipherSuites, portResult.KubeletTLSConfigCompliance}) + switch componentType { + case IngressComponent: + if ing := tlsProfile.IngressController; ing != nil { + portResult.IngressTLSConfigCompliance = &TLSConfigComplianceResult{} + evaluateCompliance(scannedMinVer, portResult.TlsCiphers, profileInput{ing.Type, ing.MinTLSVersion, ing.Ciphers, portResult.IngressTLSConfigCompliance}) + } + case KubeletComponent: + if kube := tlsProfile.KubeletConfig; kube != nil { + portResult.KubeletTLSConfigCompliance = &TLSConfigComplianceResult{} + evaluateCompliance(scannedMinVer, portResult.TlsCiphers, profileInput{"", kube.MinTLSVersion, kube.TLSCipherSuites, portResult.KubeletTLSConfigCompliance}) + } + default: + if api := tlsProfile.APIServer; api != nil { + portResult.APIServerTLSConfigCompliance = &TLSConfigComplianceResult{} + evaluateCompliance(scannedMinVer, portResult.TlsCiphers, profileInput{api.Type, api.MinTLSVersion, api.Ciphers, portResult.APIServerTLSConfigCompliance}) + } } +} - for _, p := range profiles { - evaluateCompliance(scannedMinVer, portResult.TlsCiphers, p) - } +// IsTLSConfigCompliant returns true when a compliance result exists and both +// the version and cipher checks passed. +func IsTLSConfigCompliant(result *TLSConfigComplianceResult) bool { + return result != nil && result.Version && result.Ciphers } func checkCipherCompliance(gotCiphers []string, expectedCiphers []string) bool { @@ -89,16 +117,13 @@ func checkCipherCompliance(gotCiphers []string, expectedCiphers []string) bool { func HasComplianceFailures(results ScanResults) bool { for _, ipResult := range results.IPResults { for _, portResult := range ipResult.PortResults { - if portResult.IngressTLSConfigCompliance != nil && - (!portResult.IngressTLSConfigCompliance.Version || !portResult.IngressTLSConfigCompliance.Ciphers) { + if portResult.IngressTLSConfigCompliance != nil && !IsTLSConfigCompliant(portResult.IngressTLSConfigCompliance) { return true } - if portResult.APIServerTLSConfigCompliance != nil && - (!portResult.APIServerTLSConfigCompliance.Version || !portResult.APIServerTLSConfigCompliance.Ciphers) { + if portResult.APIServerTLSConfigCompliance != nil && !IsTLSConfigCompliant(portResult.APIServerTLSConfigCompliance) { return true } - if portResult.KubeletTLSConfigCompliance != nil && - (!portResult.KubeletTLSConfigCompliance.Version || !portResult.KubeletTLSConfigCompliance.Ciphers) { + if portResult.KubeletTLSConfigCompliance != nil && !IsTLSConfigCompliant(portResult.KubeletTLSConfigCompliance) { return true } } diff --git a/internal/scanner/compliance_test.go b/internal/scanner/compliance_test.go index 6077f6ac..c572dd67 100644 --- a/internal/scanner/compliance_test.go +++ b/internal/scanner/compliance_test.go @@ -19,6 +19,10 @@ func intermediateProfile() *k8s.TLSSecurityProfile { MinTLSVersion: string(configv1.TLSProfiles[configv1.TLSProfileIntermediateType].MinTLSVersion), Ciphers: configv1.TLSProfiles[configv1.TLSProfileIntermediateType].Ciphers, }, + KubeletConfig: &k8s.KubeletTLSProfile{ + MinTLSVersion: string(configv1.TLSProfiles[configv1.TLSProfileIntermediateType].MinTLSVersion), + TLSCipherSuites: configv1.TLSProfiles[configv1.TLSProfileIntermediateType].Ciphers, + }, } } @@ -65,92 +69,110 @@ func emptyProfile() *k8s.TLSSecurityProfile { func TestCheckCompliance(t *testing.T) { tests := []struct { - name string - tlsVersions []string - ciphers []string - profile *k8s.TLSSecurityProfile - wantAPIVersion bool - wantAPICiphers bool - wantAPIProfile string - wantIngressVer bool - wantIngressCipher bool + name string + tlsVersions []string + ciphers []string + profile *k8s.TLSSecurityProfile + componentType ComponentType + wantVersion bool + wantCiphers bool + wantProfile string + // which result field should be populated + checkIngress bool + checkAPI bool + checkKubelet bool }{ { - name: "Default profile with TLS 1.2+1.3 resolved to Intermediate", - tlsVersions: []string{"TLSv1.2", "TLSv1.3"}, - ciphers: []string{"TLS_AKE_WITH_AES_256_GCM_SHA384", "TLS_AKE_WITH_AES_128_GCM_SHA256"}, - profile: defaultProfile(), - wantAPIVersion: true, - wantAPICiphers: true, - wantAPIProfile: "Default", - wantIngressVer: true, - wantIngressCipher: true, + name: "Generic component: Default profile with TLS 1.2+1.3", + tlsVersions: []string{"TLSv1.2", "TLSv1.3"}, + ciphers: []string{"TLS_AKE_WITH_AES_256_GCM_SHA384", "TLS_AKE_WITH_AES_128_GCM_SHA256"}, + profile: defaultProfile(), + componentType: GenericComponent, + wantVersion: true, + wantCiphers: true, + wantProfile: "Default", + checkAPI: true, + }, + { + name: "Generic component: empty profile = compliant", + tlsVersions: []string{"TLSv1.2", "TLSv1.3"}, + ciphers: []string{"TLS_AKE_WITH_AES_256_GCM_SHA384"}, + profile: emptyProfile(), + componentType: GenericComponent, + wantVersion: true, + wantCiphers: true, + wantProfile: "Default", + checkAPI: true, }, { - name: "Empty Default profile (no MinTLSVersion/Ciphers) = compliant", - tlsVersions: []string{"TLSv1.2", "TLSv1.3"}, - ciphers: []string{"TLS_AKE_WITH_AES_256_GCM_SHA384"}, - profile: emptyProfile(), - wantAPIVersion: true, - wantAPICiphers: true, - wantAPIProfile: "Default", - wantIngressVer: true, - wantIngressCipher: true, + name: "Generic component: Modern profile with TLS 1.3 = pass", + tlsVersions: []string{"TLSv1.3"}, + ciphers: []string{"TLS_AKE_WITH_AES_256_GCM_SHA384"}, + profile: modernProfile(), + componentType: GenericComponent, + wantVersion: true, + wantCiphers: true, + wantProfile: "Modern", + checkAPI: true, }, { - name: "Intermediate profile with TLS 1.2+1.3 and matching ciphers", - tlsVersions: []string{"TLSv1.2", "TLSv1.3"}, - ciphers: []string{"TLS_AKE_WITH_AES_256_GCM_SHA384", "TLS_AKE_WITH_AES_128_GCM_SHA256"}, - profile: intermediateProfile(), - wantAPIVersion: true, - wantAPICiphers: true, - wantAPIProfile: "Intermediate", - wantIngressVer: true, - wantIngressCipher: true, + name: "Generic component: Modern profile with TLS 1.2 = version fail", + tlsVersions: []string{"TLSv1.2"}, + ciphers: []string{"TLS_AKE_WITH_AES_256_GCM_SHA384"}, + profile: modernProfile(), + componentType: GenericComponent, + wantVersion: false, + wantCiphers: true, + wantProfile: "Modern", + checkAPI: true, }, { - name: "Modern profile with TLS 1.3 only = version pass", - tlsVersions: []string{"TLSv1.3"}, - ciphers: []string{"TLS_AKE_WITH_AES_256_GCM_SHA384"}, - profile: modernProfile(), - wantAPIVersion: true, - wantAPICiphers: true, - wantAPIProfile: "Modern", - wantIngressVer: true, - wantIngressCipher: true, + name: "Generic component: Intermediate profile with unknown cipher = cipher fail", + tlsVersions: []string{"TLSv1.2", "TLSv1.3"}, + ciphers: []string{"UNKNOWN_CIPHER_SUITE"}, + profile: intermediateProfile(), + componentType: GenericComponent, + wantVersion: true, + wantCiphers: false, + wantProfile: "Intermediate", + checkAPI: true, }, { - name: "Modern profile with TLS 1.2 = version fail", - tlsVersions: []string{"TLSv1.2"}, - ciphers: []string{"TLS_AKE_WITH_AES_256_GCM_SHA384"}, - profile: modernProfile(), - wantAPIVersion: false, - wantAPICiphers: true, - wantAPIProfile: "Modern", - wantIngressVer: false, - wantIngressCipher: true, + name: "Generic component: nil APIServer profile = no result populated", + tlsVersions: []string{"TLSv1.3"}, + ciphers: []string{"TLS_AKE_WITH_AES_256_GCM_SHA384"}, + profile: &k8s.TLSSecurityProfile{}, + componentType: GenericComponent, + checkAPI: false, }, { - name: "Intermediate profile with unknown cipher = cipher fail", - tlsVersions: []string{"TLSv1.2", "TLSv1.3"}, - ciphers: []string{"UNKNOWN_CIPHER_SUITE"}, - profile: intermediateProfile(), - wantAPIVersion: true, - wantAPICiphers: false, - wantAPIProfile: "Intermediate", - wantIngressVer: true, - wantIngressCipher: false, + name: "Ingress component: uses IngressController profile", + tlsVersions: []string{"TLSv1.2", "TLSv1.3"}, + ciphers: []string{"TLS_AKE_WITH_AES_256_GCM_SHA384", "TLS_AKE_WITH_AES_128_GCM_SHA256"}, + profile: intermediateProfile(), + componentType: IngressComponent, + wantVersion: true, + wantCiphers: true, + wantProfile: "Intermediate", + checkIngress: true, }, { - name: "Nil profile sections = no compliance populated", - tlsVersions: []string{"TLSv1.3"}, - ciphers: []string{"TLS_AKE_WITH_AES_256_GCM_SHA384"}, - profile: &k8s.TLSSecurityProfile{}, - wantAPIVersion: false, - wantAPICiphers: false, - wantAPIProfile: "", - wantIngressVer: false, - wantIngressCipher: false, + name: "Ingress component: nil IngressController profile = no result populated", + tlsVersions: []string{"TLSv1.3"}, + ciphers: []string{"TLS_AKE_WITH_AES_256_GCM_SHA384"}, + profile: &k8s.TLSSecurityProfile{}, + componentType: IngressComponent, + checkIngress: false, + }, + { + name: "Kubelet component: uses KubeletConfig profile", + tlsVersions: []string{"TLSv1.2"}, + ciphers: []string{"TLS_AKE_WITH_AES_256_GCM_SHA384", "TLS_AKE_WITH_AES_128_GCM_SHA256"}, + profile: intermediateProfile(), + componentType: KubeletComponent, + wantVersion: true, + wantCiphers: true, + checkKubelet: true, }, } @@ -160,22 +182,73 @@ func TestCheckCompliance(t *testing.T) { TlsVersions: tt.tlsVersions, TlsCiphers: tt.ciphers, } - CheckCompliance(pr, tt.profile) + CheckCompliance(pr, tt.profile, tt.componentType) - if pr.APIServerTLSConfigCompliance.Version != tt.wantAPIVersion { - t.Errorf("API version compliance = %v, want %v", pr.APIServerTLSConfigCompliance.Version, tt.wantAPIVersion) - } - if pr.APIServerTLSConfigCompliance.Ciphers != tt.wantAPICiphers { - t.Errorf("API cipher compliance = %v, want %v", pr.APIServerTLSConfigCompliance.Ciphers, tt.wantAPICiphers) + // Verify that only the expected compliance field is populated. + if tt.checkAPI { + if pr.APIServerTLSConfigCompliance == nil { + t.Fatal("APIServerTLSConfigCompliance is nil, want populated") + } + if pr.APIServerTLSConfigCompliance.Version != tt.wantVersion { + t.Errorf("API version compliance = %v, want %v", pr.APIServerTLSConfigCompliance.Version, tt.wantVersion) + } + if pr.APIServerTLSConfigCompliance.Ciphers != tt.wantCiphers { + t.Errorf("API cipher compliance = %v, want %v", pr.APIServerTLSConfigCompliance.Ciphers, tt.wantCiphers) + } + if tt.wantProfile != "" && pr.APIServerTLSConfigCompliance.ConfiguredProfile != tt.wantProfile { + t.Errorf("API configured profile = %q, want %q", pr.APIServerTLSConfigCompliance.ConfiguredProfile, tt.wantProfile) + } + if pr.IngressTLSConfigCompliance != nil { + t.Error("IngressTLSConfigCompliance should be nil for GenericComponent") + } + if pr.KubeletTLSConfigCompliance != nil { + t.Error("KubeletTLSConfigCompliance should be nil for GenericComponent") + } } - if pr.APIServerTLSConfigCompliance.ConfiguredProfile != tt.wantAPIProfile { - t.Errorf("API configured profile = %q, want %q", pr.APIServerTLSConfigCompliance.ConfiguredProfile, tt.wantAPIProfile) + + if tt.checkIngress { + if pr.IngressTLSConfigCompliance == nil { + t.Fatal("IngressTLSConfigCompliance is nil, want populated") + } + if pr.IngressTLSConfigCompliance.Version != tt.wantVersion { + t.Errorf("Ingress version compliance = %v, want %v", pr.IngressTLSConfigCompliance.Version, tt.wantVersion) + } + if pr.IngressTLSConfigCompliance.Ciphers != tt.wantCiphers { + t.Errorf("Ingress cipher compliance = %v, want %v", pr.IngressTLSConfigCompliance.Ciphers, tt.wantCiphers) + } + if tt.wantProfile != "" && pr.IngressTLSConfigCompliance.ConfiguredProfile != tt.wantProfile { + t.Errorf("Ingress configured profile = %q, want %q", pr.IngressTLSConfigCompliance.ConfiguredProfile, tt.wantProfile) + } + if pr.APIServerTLSConfigCompliance != nil { + t.Error("APIServerTLSConfigCompliance should be nil for IngressComponent") + } + if pr.KubeletTLSConfigCompliance != nil { + t.Error("KubeletTLSConfigCompliance should be nil for IngressComponent") + } } - if pr.IngressTLSConfigCompliance.Version != tt.wantIngressVer { - t.Errorf("Ingress version compliance = %v, want %v", pr.IngressTLSConfigCompliance.Version, tt.wantIngressVer) + + if tt.checkKubelet { + if pr.KubeletTLSConfigCompliance == nil { + t.Fatal("KubeletTLSConfigCompliance is nil, want populated") + } + if pr.KubeletTLSConfigCompliance.Version != tt.wantVersion { + t.Errorf("Kubelet version compliance = %v, want %v", pr.KubeletTLSConfigCompliance.Version, tt.wantVersion) + } + if pr.KubeletTLSConfigCompliance.Ciphers != tt.wantCiphers { + t.Errorf("Kubelet cipher compliance = %v, want %v", pr.KubeletTLSConfigCompliance.Ciphers, tt.wantCiphers) + } + if pr.APIServerTLSConfigCompliance != nil { + t.Error("APIServerTLSConfigCompliance should be nil for KubeletComponent") + } + if pr.IngressTLSConfigCompliance != nil { + t.Error("IngressTLSConfigCompliance should be nil for KubeletComponent") + } } - if pr.IngressTLSConfigCompliance.Ciphers != tt.wantIngressCipher { - t.Errorf("Ingress cipher compliance = %v, want %v", pr.IngressTLSConfigCompliance.Ciphers, tt.wantIngressCipher) + + if !tt.checkAPI && !tt.checkIngress && !tt.checkKubelet { + if pr.APIServerTLSConfigCompliance != nil || pr.IngressTLSConfigCompliance != nil || pr.KubeletTLSConfigCompliance != nil { + t.Error("all compliance fields should be nil when no profile is available") + } } }) } @@ -237,24 +310,66 @@ func TestHasComplianceFailures(t *testing.T) { want bool }{ { - name: "all compliant", + name: "APIServer compliant = no failure", results: ScanResults{ IPResults: []IPResult{{ PortResults: []PortResult{{ APIServerTLSConfigCompliance: &TLSConfigComplianceResult{Version: true, Ciphers: true}, - IngressTLSConfigCompliance: &TLSConfigComplianceResult{Version: true, Ciphers: true}, }}, }}, }, want: false, }, { - name: "API version failure", + name: "Ingress compliant = no failure", + results: ScanResults{ + IPResults: []IPResult{{ + PortResults: []PortResult{{ + IngressTLSConfigCompliance: &TLSConfigComplianceResult{Version: true, Ciphers: true}, + }}, + }}, + }, + want: false, + }, + { + name: "Kubelet compliant = no failure", + results: ScanResults{ + IPResults: []IPResult{{ + PortResults: []PortResult{{ + KubeletTLSConfigCompliance: &TLSConfigComplianceResult{Version: true, Ciphers: true}, + }}, + }}, + }, + want: false, + }, + { + name: "APIServer version failure = failure", results: ScanResults{ IPResults: []IPResult{{ PortResults: []PortResult{{ APIServerTLSConfigCompliance: &TLSConfigComplianceResult{Version: false, Ciphers: true}, - IngressTLSConfigCompliance: &TLSConfigComplianceResult{Version: true, Ciphers: true}, + }}, + }}, + }, + want: true, + }, + { + name: "Ingress cipher failure = failure", + results: ScanResults{ + IPResults: []IPResult{{ + PortResults: []PortResult{{ + IngressTLSConfigCompliance: &TLSConfigComplianceResult{Version: true, Ciphers: false}, + }}, + }}, + }, + want: true, + }, + { + name: "Kubelet version failure = failure", + results: ScanResults{ + IPResults: []IPResult{{ + PortResults: []PortResult{{ + KubeletTLSConfigCompliance: &TLSConfigComplianceResult{Version: false, Ciphers: true}, }}, }}, }, diff --git a/internal/scanner/policy.go b/internal/scanner/policy.go new file mode 100644 index 00000000..5e44e6a7 --- /dev/null +++ b/internal/scanner/policy.go @@ -0,0 +1,221 @@ +package scanner + +import ( + _ "embed" + "fmt" + "log" + "regexp" + + "gopkg.in/yaml.v3" +) + +//go:embed policy.yaml +var embeddedPolicyYAML []byte + +// ProfileSource names the TLS profile source that should be used when checking +// compliance for a matched component. +type ProfileSource string + +const ( + ProfileAPIServer ProfileSource = "apiserver" + ProfileIngress ProfileSource = "ingress" + ProfileKubelet ProfileSource = "kubelet" +) + +// PolicyRule matches a scanned port by any combination of namespace, process +// name, port number, and component name. Omitted fields act as wildcards. +// String fields are treated as Go regular expressions anchored at both ends +// (i.e. the pattern must match the whole value). When all specified fields +// match, the first such rule wins and the port is checked against Profile. +type PolicyRule struct { + Namespace string `yaml:"namespace,omitempty"` + Process string `yaml:"process,omitempty"` + Port *int `yaml:"port,omitempty"` + Component string `yaml:"component,omitempty"` + Profile ProfileSource `yaml:"profile"` + + // compiled regexes, populated by compile() after unmarshal + namespaceRe *regexp.Regexp + processRe *regexp.Regexp + componentRe *regexp.Regexp +} + +// ComponentPolicy is a prioritised list of PolicyRules. Rules are evaluated +// top-to-bottom; the first matching rule wins. Ports with no matching rule are +// checked against the cluster-wide APIServer TLS profile. +// +// The active policy is defined in policy.yaml and embedded at build time. +// Changes to that file must go through the normal review process. +type ComponentPolicy struct { + Rules []PolicyRule `yaml:"rules"` +} + +// Policy returns the org-wide component policy embedded in the binary. +// It is the single source of truth for which TLS profile applies to each +// component type. To change the policy, edit policy.yaml and submit for review. +func Policy() *ComponentPolicy { + var p ComponentPolicy + if err := yaml.Unmarshal(embeddedPolicyYAML, &p); err != nil { + // policy.yaml is checked into source; a parse failure is a programming error. + panic(fmt.Sprintf("failed to parse embedded policy: %v", err)) + } + for i := range p.Rules { + if err := p.Rules[i].compile(); err != nil { + panic(fmt.Sprintf("policy rule %d: %v", i, err)) + } + } + p.warnShadowedRules() + return &p +} + +// warnShadowedRules logs a warning for each rule that can never be reached +// because an earlier, broader rule will always match first. +func (p *ComponentPolicy) warnShadowedRules() { + for i := 0; i < len(p.Rules); i++ { + for j := i + 1; j < len(p.Rules); j++ { + if p.Rules[i].shadows(&p.Rules[j]) { + log.Printf("Warning: policy rule %d (%s) shadows rule %d (%s) — rule %d will never be reached. "+ + "Check policy.yaml and move more-specific rules before broader ones.", + i, p.Rules[i].description(), + j, p.Rules[j].description(), + j) + } + } + } +} + +// shadows reports whether r will always match before other, making other +// unreachable. r shadows other when r's constraints are a superset of +// other's — i.e. r is less specific (or equally specific) in every dimension. +func (r *PolicyRule) shadows(other *PolicyRule) bool { + // For each dimension: if other has no constraint, r must also have none + // (otherwise r is more specific and won't match everything other matches). + // If other has a constraint, r must either have no constraint (wildcard) + // or a pattern that covers other's literal value. + if other.namespaceRe == nil { + if r.namespaceRe != nil { + return false + } + } else if r.namespaceRe != nil && !r.namespaceRe.MatchString(other.Namespace) { + return false + } + + if other.processRe == nil { + if r.processRe != nil { + return false + } + } else if r.processRe != nil && !r.processRe.MatchString(other.Process) { + return false + } + + if other.componentRe == nil { + if r.componentRe != nil { + return false + } + } else if r.componentRe != nil && !r.componentRe.MatchString(other.Component) { + return false + } + + if other.Port == nil { + if r.Port != nil { + return false + } + } else if r.Port != nil && *r.Port != *other.Port { + return false + } + + return true +} + +// description returns a short human-readable summary of the rule's matchers. +func (r *PolicyRule) description() string { + s := string(r.Profile) + " {" + if r.Namespace != "" { + s += "namespace:" + r.Namespace + " " + } + if r.Process != "" { + s += "process:" + r.Process + " " + } + if r.Component != "" { + s += "component:" + r.Component + " " + } + if r.Port != nil { + s += fmt.Sprintf("port:%d ", *r.Port) + } + return s + "}" +} + +// compile validates and pre-compiles the rule. It checks that: +// - profile is a known value +// - at least one matcher field is set (a rule with no matchers would +// silently swallow all subsequent rules) +// - all string matcher fields are valid Go regexes +// +// Patterns are implicitly anchored so "openshift-ingress" matches that exact +// string, while "openshift-.*" matches any string with that prefix. +func (r *PolicyRule) compile() error { + switch r.Profile { + case ProfileAPIServer, ProfileIngress, ProfileKubelet: + case "": + return fmt.Errorf("profile must be set (valid values: apiserver, ingress, kubelet)") + default: + return fmt.Errorf("unknown profile %q (valid values: apiserver, ingress, kubelet)", r.Profile) + } + + if r.Namespace == "" && r.Process == "" && r.Component == "" && r.Port == nil { + return fmt.Errorf("at least one matcher field (namespace, process, port, component) must be set") + } + + var err error + if r.Namespace != "" { + if r.namespaceRe, err = regexp.Compile("^(?:" + r.Namespace + ")$"); err != nil { + return fmt.Errorf("invalid namespace pattern %q: %w", r.Namespace, err) + } + } + if r.Process != "" { + if r.processRe, err = regexp.Compile("^(?:" + r.Process + ")$"); err != nil { + return fmt.Errorf("invalid process pattern %q: %w", r.Process, err) + } + } + if r.Component != "" { + if r.componentRe, err = regexp.Compile("^(?:" + r.Component + ")$"); err != nil { + return fmt.Errorf("invalid component pattern %q: %w", r.Component, err) + } + } + return nil +} + +// Resolve returns the ComponentType for a port with the given attributes by +// evaluating the policy rules in order. Returns GenericComponent if no rule +// matches. +func (p *ComponentPolicy) Resolve(namespace, process, component string, port int) ComponentType { + for _, rule := range p.Rules { + if rule.matches(namespace, process, component, port) { + switch rule.Profile { + case ProfileIngress: + return IngressComponent + case ProfileKubelet: + return KubeletComponent + default: + return GenericComponent + } + } + } + return GenericComponent +} + +func (r *PolicyRule) matches(namespace, process, component string, port int) bool { + if r.namespaceRe != nil && !r.namespaceRe.MatchString(namespace) { + return false + } + if r.processRe != nil && !r.processRe.MatchString(process) { + return false + } + if r.componentRe != nil && !r.componentRe.MatchString(component) { + return false + } + if r.Port != nil && *r.Port != port { + return false + } + return true +} diff --git a/internal/scanner/policy.yaml b/internal/scanner/policy.yaml new file mode 100644 index 00000000..66b63694 --- /dev/null +++ b/internal/scanner/policy.yaml @@ -0,0 +1,33 @@ +# Default TLS component policy. +# +# Rules are evaluated top-to-bottom; the first matching rule wins. +# A component with no matching rule uses the cluster-wide APIServer TLS profile. +# +# Available profiles: +# apiserver - cluster-wide APIServer TLS profile (default for all components) +# ingress - IngressController TLS profile (falls back to APIServer if no override is set) +# kubelet - KubeletConfig TLS profile (falls back to APIServer if no override is set) +# +# Matching fields (all optional; omitting a field matches any value): +# namespace: Kubernetes pod namespace (Go regex, anchored) +# process: Process name from /proc (Go regex, anchored) +# port: Listening port number (exact integer match) +# component: OpenShift component name (Go regex, anchored) +# +# String fields are Go regular expressions matched against the full value. +# Plain strings like "kubelet" match exactly. Use "openshift-.*" for a prefix +# match. Omitting a field is a wildcard (matches any value). +# +# Example custom rule: +# - namespace: openshift-.* +# process: my-server +# profile: apiserver +rules: + - namespace: openshift-ingress + profile: ingress + - process: kubelet + profile: kubelet + - port: 10250 + profile: kubelet + - port: 10255 + profile: kubelet diff --git a/internal/scanner/policy_test.go b/internal/scanner/policy_test.go new file mode 100644 index 00000000..2d6db09a --- /dev/null +++ b/internal/scanner/policy_test.go @@ -0,0 +1,308 @@ +package scanner + +import ( + "testing" +) + +func TestPolicyRuleValidation(t *testing.T) { + t.Run("missing profile = error", func(t *testing.T) { + r := PolicyRule{Namespace: "openshift-ingress"} + if err := r.compile(); err == nil { + t.Error("expected error for missing profile, got nil") + } + }) + + t.Run("unknown profile = error", func(t *testing.T) { + r := PolicyRule{Namespace: "openshift-ingress", Profile: "unknown"} + if err := r.compile(); err == nil { + t.Error("expected error for unknown profile, got nil") + } + }) + + t.Run("no matchers = error", func(t *testing.T) { + r := PolicyRule{Profile: ProfileIngress} + if err := r.compile(); err == nil { + t.Error("expected error for rule with no matchers, got nil") + } + }) + + t.Run("port-only matcher is valid", func(t *testing.T) { + r := PolicyRule{Port: intPtr(10250), Profile: ProfileKubelet} + if err := r.compile(); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("invalid regex = error", func(t *testing.T) { + r := PolicyRule{Namespace: "[invalid", Profile: ProfileIngress} + if err := r.compile(); err == nil { + t.Error("expected error for invalid regex, got nil") + } + }) +} + +func TestPolicyRuleShadowing(t *testing.T) { + compiled := func(r PolicyRule) PolicyRule { + if err := r.compile(); err != nil { + panic(err) + } + return r + } + + tests := []struct { + name string + r1 PolicyRule + r2 PolicyRule + shadow bool + }{ + { + name: "broader process rule shadows narrower process+port rule", + r1: compiled(PolicyRule{Process: "kubelet", Profile: ProfileKubelet}), + r2: compiled(PolicyRule{Process: "kubelet", Port: intPtr(10250), Profile: ProfileKubelet}), + shadow: true, + }, + { + name: "identical rules shadow each other", + r1: compiled(PolicyRule{Namespace: "openshift-ingress", Profile: ProfileIngress}), + r2: compiled(PolicyRule{Namespace: "openshift-ingress", Profile: ProfileIngress}), + shadow: true, + }, + { + name: "broader namespace regex shadows literal namespace", + r1: compiled(PolicyRule{Namespace: "openshift-.*", Profile: ProfileIngress}), + r2: compiled(PolicyRule{Namespace: "openshift-ingress", Profile: ProfileIngress}), + shadow: true, + }, + { + name: "different dimensions do not shadow", + r1: compiled(PolicyRule{Namespace: "openshift-ingress", Profile: ProfileIngress}), + r2: compiled(PolicyRule{Process: "kubelet", Profile: ProfileKubelet}), + shadow: false, + }, + { + name: "more specific rule does not shadow broader rule", + r1: compiled(PolicyRule{Process: "kubelet", Port: intPtr(10250), Profile: ProfileKubelet}), + r2: compiled(PolicyRule{Process: "kubelet", Profile: ProfileKubelet}), + shadow: false, + }, + { + name: "different namespace patterns do not shadow", + r1: compiled(PolicyRule{Namespace: "openshift-ingress", Profile: ProfileIngress}), + r2: compiled(PolicyRule{Namespace: "openshift-etcd", Profile: ProfileIngress}), + shadow: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.r1.shadows(&tt.r2) + if got != tt.shadow { + t.Errorf("shadows() = %v, want %v", got, tt.shadow) + } + }) + } +} + +func TestPolicy(t *testing.T) { + p := Policy() + if p == nil { + t.Fatal("Policy() returned nil") + } + if len(p.Rules) == 0 { + t.Fatal("Policy() has no rules") + } +} + +func TestPolicyResolve(t *testing.T) { + tests := []struct { + name string + rules []PolicyRule + namespace string + process string + component string + port int + want ComponentType + }{ + { + name: "no rules = generic", + rules: nil, + port: 443, + want: GenericComponent, + }, + { + name: "namespace match → ingress", + rules: []PolicyRule{{Namespace: "openshift-ingress", Profile: ProfileIngress}}, + namespace: "openshift-ingress", + port: 443, + want: IngressComponent, + }, + { + name: "namespace mismatch = generic", + rules: []PolicyRule{{Namespace: "openshift-ingress", Profile: ProfileIngress}}, + namespace: "openshift-kube-apiserver", + port: 443, + want: GenericComponent, + }, + { + name: "port match → kubelet", + rules: []PolicyRule{{Port: intPtr(10250), Profile: ProfileKubelet}}, + port: 10250, + want: KubeletComponent, + }, + { + name: "port mismatch = generic", + rules: []PolicyRule{{Port: intPtr(10250), Profile: ProfileKubelet}}, + port: 443, + want: GenericComponent, + }, + { + name: "process match → kubelet", + rules: []PolicyRule{{Process: "kubelet", Profile: ProfileKubelet}}, + process: "kubelet", + port: 443, + want: KubeletComponent, + }, + { + name: "component match → ingress", + rules: []PolicyRule{{Component: "router", Profile: ProfileIngress}}, + component: "router", + port: 443, + want: IngressComponent, + }, + { + name: "first rule wins", + rules: []PolicyRule{ + {Namespace: "openshift-ingress", Profile: ProfileIngress}, + {Port: intPtr(443), Profile: ProfileKubelet}, + }, + namespace: "openshift-ingress", + port: 443, + want: IngressComponent, + }, + { + name: "multi-field AND: all match", + rules: []PolicyRule{ + {Namespace: "openshift-ingress", Port: intPtr(443), Profile: ProfileIngress}, + }, + namespace: "openshift-ingress", + port: 443, + want: IngressComponent, + }, + { + name: "multi-field AND: one field mismatches = no match", + rules: []PolicyRule{ + {Namespace: "openshift-ingress", Port: intPtr(443), Profile: ProfileIngress}, + }, + namespace: "openshift-ingress", + port: 8443, + want: GenericComponent, + }, + { + name: "apiserver profile rule → generic component", + rules: []PolicyRule{{Port: intPtr(6443), Profile: ProfileAPIServer}}, + port: 6443, + want: GenericComponent, + }, + { + name: "port-only rule matches any namespace/process", + rules: []PolicyRule{{Port: intPtr(9999), Profile: ProfileKubelet}}, + port: 9999, + want: KubeletComponent, + }, + { + name: "namespace regex prefix match", + rules: []PolicyRule{{Namespace: "openshift-.*", Profile: ProfileIngress}}, + namespace: "openshift-ingress", + port: 443, + want: IngressComponent, + }, + { + name: "namespace regex does not match different prefix", + rules: []PolicyRule{{Namespace: "openshift-.*", Profile: ProfileIngress}}, + namespace: "kube-system", + port: 443, + want: GenericComponent, + }, + { + name: "process regex matches kubelet variant", + rules: []PolicyRule{{Process: "kubelet.*", Profile: ProfileKubelet}}, + process: "kubelet-extra", + port: 443, + want: KubeletComponent, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + policy := &ComponentPolicy{Rules: tt.rules} + for i := range policy.Rules { + if err := policy.Rules[i].compile(); err != nil { + t.Fatalf("rule %d: compile error: %v", i, err) + } + } + got := policy.Resolve(tt.namespace, tt.process, tt.component, tt.port) + if got != tt.want { + t.Errorf("Resolve(%q, %q, %q, %d) = %v, want %v", + tt.namespace, tt.process, tt.component, tt.port, got, tt.want) + } + }) + } +} + +func TestPolicyBehaviour(t *testing.T) { + p := Policy() + + tests := []struct { + name string + namespace string + process string + component string + port int + want ComponentType + }{ + { + // Ingress controller pods run in openshift-ingress and should be + // checked against the IngressController TLS profile. + name: "ingress-controller conforms to ingress profile", + namespace: "openshift-ingress", + process: "router", + port: 443, + want: IngressComponent, + }, + { + // Kubelet is identified by process name and should be checked + // against the KubeletConfig TLS profile. + name: "kubelet conforms to kubelet profile", + process: "kubelet", + port: 10250, + want: KubeletComponent, + }, + { + // Kubelet identification falls back to well-known port when + // process name is not available. + name: "kubelet identified by port when process name unavailable", + port: 10250, + want: KubeletComponent, + }, + { + // Any other component defaults to the cluster-wide APIServer profile. + name: "generic component conforms to APIServer profile", + namespace: "openshift-kube-apiserver", + process: "kube-apiserver", + port: 6443, + want: GenericComponent, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := p.Resolve(tt.namespace, tt.process, tt.component, tt.port) + if got != tt.want { + t.Errorf("Policy().Resolve(%q, %q, %q, %d) = %v, want %v", + tt.namespace, tt.process, tt.component, tt.port, got, tt.want) + } + }) + } +} + +func intPtr(v int) *int { return &v } diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 7ba21a40..0f7073ba 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -24,7 +24,7 @@ type portScanResult struct { result PortResult } -func PerformClusterScan(pods []k8s.PodInfo, concurrentScans int, client *k8s.Client) ScanResults { +func PerformClusterScan(pods []k8s.PodInfo, concurrentScans int, client *k8s.Client, policy *ComponentPolicy) ScanResults { defer timing.Timings.Track("performClusterScan", "")() startTime := time.Now() @@ -166,7 +166,7 @@ func PerformClusterScan(pods []k8s.PodInfo, concurrentScans int, client *k8s.Cli fmt.Printf("\n=== DISCOVERY COMPLETE: %d pods -> %d scan jobs (%d deduplicated), %d skipped ===\n\n", progress.discoveredPods.Load(), len(scanJobs), beforeDedup-len(scanJobs), progress.skippedPorts.Load()) - batchResults := batchScan(scanJobs, concurrentScans, client, tlsConfig) + batchResults := batchScan(scanJobs, concurrentScans, client, tlsConfig, policy) results := assembleResults(startTime, totalIPs, tlsConfig, localhostResults, batchResults) @@ -188,7 +188,7 @@ func PerformClusterScan(pods []k8s.PodInfo, concurrentScans int, client *k8s.Cli // Scan runs a batch testssl.sh scan on pre-built scan jobs. // Used by --targets and single-host paths (no k8s discovery needed). -func Scan(jobs []ScanJob, concurrentScans int, client *k8s.Client, tlsConfig *k8s.TLSSecurityProfile) ScanResults { +func Scan(jobs []ScanJob, concurrentScans int, client *k8s.Client, tlsConfig *k8s.TLSSecurityProfile, policy *ComponentPolicy) ScanResults { defer timing.Timings.Track("scan", "")() startTime := time.Now() @@ -203,7 +203,7 @@ func Scan(jobs []ScanJob, concurrentScans int, client *k8s.Client, tlsConfig *k8 fmt.Printf("MAX_PARALLEL: %d\n", concurrentScans) fmt.Printf("========================================\n\n") - batchResults := batchScan(jobs, concurrentScans, client, tlsConfig) + batchResults := batchScan(jobs, concurrentScans, client, tlsConfig, policy) results := assembleResults(startTime, 0, tlsConfig, batchResults) duration := time.Since(startTime) @@ -221,7 +221,7 @@ func Scan(jobs []ScanJob, concurrentScans int, client *k8s.Client, tlsConfig *k8 return results } -func batchScan(jobs []ScanJob, concurrentScans int, client *k8s.Client, tlsConfig *k8s.TLSSecurityProfile) []portScanResult { +func batchScan(jobs []ScanJob, concurrentScans int, client *k8s.Client, tlsConfig *k8s.TLSSecurityProfile, policy *ComponentPolicy) []portScanResult { if len(jobs) == 0 { return nil } @@ -317,27 +317,37 @@ func batchScan(jobs []ScanJob, concurrentScans int, client *k8s.Client, tlsConfi PopulatePQCFields(&portResult) + // Fetch process/listen info before compliance so the policy can match on + // process name in addition to namespace and port. + var processName string + if client != nil { + if pn, ok := client.GetProcessName(job.IP, job.Port); ok { + portResult.ProcessName = pn + portResult.ContainerName = strings.Join(job.Pod.Containers, ",") + processName = pn + } + if info, ok := client.GetListenInfo(job.IP, job.Port); ok { + portResult.ListenAddress = info.ListenAddress + } + } + + var componentName string + if job.Component != nil { + componentName = job.Component.Component + } + if len(portResult.TlsVersions) > 0 || len(portResult.TlsCiphers) > 0 { portResult.Status = StatusOK portResult.Reason = "TLS scan successful" - if tlsConfig != nil { - CheckCompliance(&portResult, tlsConfig) + if tlsConfig != nil && policy != nil { + componentType := policy.Resolve(job.Pod.Namespace, processName, componentName, job.Port) + CheckCompliance(&portResult, tlsConfig, componentType) } } else { portResult.Status = StatusNoTLS portResult.Reason = "Port open but no TLS detected" } - if client != nil { - if processName, ok := client.GetProcessName(job.IP, job.Port); ok { - portResult.ProcessName = processName - portResult.ContainerName = strings.Join(job.Pod.Containers, ",") - } - if info, ok := client.GetListenInfo(job.IP, job.Port); ok { - portResult.ListenAddress = info.ListenAddress - } - } - results = append(results, portScanResult{ ip: job.IP, pod: job.Pod, component: job.Component, result: portResult, }) diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go index d4710b1f..de6bcf41 100644 --- a/internal/scanner/scanner_test.go +++ b/internal/scanner/scanner_test.go @@ -71,7 +71,7 @@ func TestScanWithMockTestSSL(t *testing.T) { {IP: "10.0.0.1", Port: 443}, {IP: "10.0.0.2", Port: 8443}, } - results := Scan(jobs, 2, nil, nil) + results := Scan(jobs, 2, nil, nil, Policy()) if results.ScannedIPs != 2 { t.Fatalf("expected 2 scanned IPs, got %d", results.ScannedIPs) @@ -95,7 +95,7 @@ func TestScanPQCEnrichment(t *testing.T) { installMockTestSSL(t) jobs := []ScanJob{{IP: "10.0.0.1", Port: 443}} - results := Scan(jobs, 1, nil, nil) + results := Scan(jobs, 1, nil, nil, Policy()) pr := results.IPResults[0].PortResults[0] @@ -128,7 +128,7 @@ func TestPerformClusterScanWithMockPods(t *testing.T) { makePod("no-ports", "openshift-console", "10.128.0.30"), } - results := PerformClusterScan(pods, 2, nil) + results := PerformClusterScan(pods, 2, nil, Policy()) if results.ScannedIPs != 3 { t.Errorf("expected 3 scanned IPs (including no-ports), got %d", results.ScannedIPs) diff --git a/internal/scanner/testssl_integration_test.go b/internal/scanner/testssl_integration_test.go index c664aa66..2a68e149 100644 --- a/internal/scanner/testssl_integration_test.go +++ b/internal/scanner/testssl_integration_test.go @@ -33,7 +33,7 @@ func TestIntegrationSingleTarget(t *testing.T) { jobs := []ScanJob{{IP: tgt.ip, Port: tgt.port}} start := time.Now() - results := batchScan(jobs, 1, nil, nil) + results := batchScan(jobs, 1, nil, nil, Policy()) elapsed := time.Since(start) t.Logf("Single target %s (%s:%d): %v", tgt.desc, tgt.ip, tgt.port, elapsed) @@ -73,7 +73,7 @@ func TestIntegrationBatchTargets(t *testing.T) { } start := time.Now() - results := batchScan(jobs, 4, nil, nil) + results := batchScan(jobs, 4, nil, nil, Policy()) elapsed := time.Since(start) t.Logf("Batch %d targets (MAX_PARALLEL=4): %v (%.1fs/target)", @@ -102,12 +102,12 @@ func TestIntegrationParallelScaling(t *testing.T) { t.Log("--- Sequential run (MAX_PARALLEL=1) ---") start := time.Now() - seqResults := batchScan(jobs, 1, nil, nil) + seqResults := batchScan(jobs, 1, nil, nil, Policy()) sequential := time.Since(start) t.Log("--- Parallel run (MAX_PARALLEL=", len(jobs), ") ---") start = time.Now() - parResults := batchScan(jobs, len(jobs), nil, nil) + parResults := batchScan(jobs, len(jobs), nil, nil, Policy()) parallel := time.Since(start) speedup := sequential.Seconds() / parallel.Seconds()