diff --git a/tests/data/rapidast/config-template.yaml b/tests/data/rapidast/config-template.yaml new file mode 100644 index 0000000000..3aaad1613d --- /dev/null +++ b/tests/data/rapidast/config-template.yaml @@ -0,0 +1,40 @@ +config: + configVersion: 4 + + # `application` contains data related to the application, not to the scans. +application: + shortName: "compliance-operator" + url: "https://kubernetes.default.svc" + +# `general` is a section that will be applied to all scanners. +general: + + authentication: + type: "http_header" + parameters: + name: "Authorization" + value: "Bearer TOKEN_PLACEHOLDER" + container: + # currently supported: `podman` and `none` + type: "none" + +scanners: + zap: + # define a scan through the ZAP scanner + apiScan: + apis: + apiUrl: "https://kubernetes.default.svc/openapi/v3/apis/compliance.openshift.io/v1alpha1" + passiveScan: + # optional list of passive rules to disable + disabledRules: "2,10015,10027,10096,10024,10054" + + activeScan: + # If no policy is chosen, a default ("API-scan-minimal") will be selected + # The list of policies can be found in scanners/zap/policies/ + policy: "Operator-scan" + + miscOptions: + # enableUI (default: false), requires a compatible runtime (e.g.: flatpak or no containment) + enableUI: False + # Defaults to True, set False to prevent auto update of ZAP plugins + updateAddons: False diff --git a/tests/data/rapidast/customscan.policy b/tests/data/rapidast/customscan.policy new file mode 100644 index 0000000000..d61264418a --- /dev/null +++ b/tests/data/rapidast/customscan.policy @@ -0,0 +1,283 @@ + + + helm-custom-scan + + MEDIUM + MEDIUM + + + + false + OFF + + + true + DEFAULT + + + false + OFF + + + true + DEFAULT + + + true + DEFAULT + + + true + DEFAULT + + + false + OFF + + + true + DEFAULT + + + true + DEFAULT + + + true + DEFAULT + + + false + OFF + + + false + OFF + + + false + OFF + + + true + DEFAULT + + + true + DEFAULT + + + false + OFF + + + true + + + false + OFF + + + false + OFF + + + true + DEFAULT + + + false + OFF + + + false + OFF + + + false + OFF + + + false + OFF + + + false + OFF + + + false + OFF + + + false + OFF + + + false + OFF + + + false + OFF + + + false + OFF + + + false + OFF + + + false + OFF + + + false + OFF + + + false + OFF + + + false + OFF + + + false + OFF + + + false + OFF + + + true + DEFAULT + + + true + DEFAULT + + + false + OFF + + + false + OFF + + + true + DEFAULT + + + false + OFF + + + false + OFF + + + false + OFF + + + false + OFF + + + false + OFF + + + false + OFF + + + false + OFF + + + false + OFF + + + true + + + true + DEFAULT + + + true + + + false + OFF + + + false + OFF + + + true + DEFAULT + + + false + OFF + + + false + OFF + + + false + OFF + + + false + OFF + + + false + OFF + + + true + DEFAULT + + + false + OFF + + + false + OFF + + + false + OFF + + + false + OFF + + + false + OFF + + + false + OFF + + + false + OFF + + + \ No newline at end of file diff --git a/tests/data/rapidast/job-rapidast.yaml b/tests/data/rapidast/job-rapidast.yaml new file mode 100644 index 0000000000..6d6e6cf05c --- /dev/null +++ b/tests/data/rapidast/job-rapidast.yaml @@ -0,0 +1,59 @@ +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: rapidast-job +spec: + backoffLimit: 1 + completionMode: NonIndexed + completions: 1 + parallelism: 1 + selector: + matchLabels: + job-name: rapidast-job + suspend: false + template: + metadata: + labels: + batch.kubernetes.io/job-name: rapidast-job + job-name: rapidast-job + name: rapidast-job + spec: + containers: + - command: ["/bin/sh"] + args: + - "-c" + - | + export HOME=/home/rapidast + mkdir -p $HOME/.ZAP/policies + cp /opt/rapidast/config/customscan.policy $HOME/.ZAP/policies/Operator-scan.policy + rapidast.py --config /opt/rapidast/config/rapidast-config.yaml --log-level critical + echo "--------------- show rapidash result -----------------" + find $HOME/results/compliance-operator -name zap-report.json -exec cat {} \; + echo "--------------- rapidash result end -----------------" + image: quay.io/redhatproductsecurity/rapidast:latest + workingDir: "/home/rapidast" + imagePullPolicy: Always + name: rapidast-chart + resources: {} + securityContext: {} + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + volumeMounts: + - mountPath: /opt/rapidast/config + name: config-volume + - mountPath: /home/rapidast + name: work-volume + dnsPolicy: ClusterFirst + restartPolicy: Never + schedulerName: default-scheduler + securityContext: {} + terminationGracePeriodSeconds: 30 + volumes: + - configMap: + defaultMode: 420 + name: rapidast-configmap + name: config-volume + - name: work-volume + emptyDir: + sizeLimit: 10Mi diff --git a/tests/data/rapidast/rapidast-config-upload.yaml b/tests/data/rapidast/rapidast-config-upload.yaml new file mode 100644 index 0000000000..bb82076f6c --- /dev/null +++ b/tests/data/rapidast/rapidast-config-upload.yaml @@ -0,0 +1,42 @@ +config: + configVersion: 4 + googleCloudStorage: + keyFile: "/opt/rapidast/config/dast-gcs-secret.json" # Path to the GCS key file + bucketName: secaut-bucket + directory: isc/compliance-operator +application: + shortName: "compliance-operator" + url: "https://kubernetes.default.svc" + +# `general` is a section that will be applied to all scanners. +general: + + authentication: + type: "http_header" + parameters: + name: "Authorization" + value: "Bearer sha256~xxxxxxxx" + container: + # currently supported: `podman` and `none` + type: "none" + +scanners: + zap: + # define a scan through the ZAP scanner + apiScan: + apis: + apiUrl: "https://kubernetes.default.svc/openapi/v3/apis/compliance.openshift.io/v1alpha1" + passiveScan: + # optional list of passive rules to disable + disabledRules: "2,10015,10027,10096,10024,10054" + + activeScan: + # If no policy is chosen, a default ("API-scan-minimal") will be selected + # The list of policies can be found in scanners/zap/policies/ + policy: "Operator-scan" + + miscOptions: + # enableUI (default: false), requires a compatible runtime (e.g.: flatpak or no containment) + enableUI: False + # Defaults to True, set False to prevent auto update of ZAP plugins + updateAddons: False diff --git a/tests/data/rapidast/rapidast-config.yaml b/tests/data/rapidast/rapidast-config.yaml new file mode 100644 index 0000000000..418de8f9d5 --- /dev/null +++ b/tests/data/rapidast/rapidast-config.yaml @@ -0,0 +1,41 @@ +config: + configVersion: 4 + + # `application` contains data related to the application, not to the scans. +application: + shortName: "compliance-operator" + url: "https://kubernetes.default.svc" + +# `general` is a section that will be applied to all scanners. +general: + + authentication: + type: "http_header" + parameters: + name: "Authorization" + value: "Bearer sha256~xxxxxxxx" + container: + # currently supported: `podman` and `none` + type: "none" + +scanners: + zap: + # define a scan through the ZAP scanner + apiScan: + apis: + apiUrl: "https://kubernetes.default.svc/openapi/v3/apis/compliance.openshift.io/v1alpha1" + passiveScan: + # optional list of passive rules to disable + disabledRules: "2,10015,10027,10096,10024,10054" + +# Remove comment symbols to enable activeScan once it is made sure that scanning with 'passiveScan' runs successfully. + activeScan: +# # If no policy is chosen, a default ("API-scan-minimal") will be selected +# # The list of policies can be found in scanners/zap/policies/ + policy: "Operator-scan" + + miscOptions: + # enableUI (default: false), requires a compatible runtime (e.g.: flatpak or no containment) + enableUI: False + # Defaults to True, set False to prevent auto update of ZAP plugins + updateAddons: False diff --git a/tests/e2e/framework/common.go b/tests/e2e/framework/common.go index 6fcbbadbe2..08e1150076 100644 --- a/tests/e2e/framework/common.go +++ b/tests/e2e/framework/common.go @@ -1894,6 +1894,27 @@ func (f *Framework) GetNodesWithSelector(labelselector map[string]string) ([]cor return nodes.Items, nil } +// ClusterHasArchitecture checks if the cluster has at least one node with the specified architecture +func (f *Framework) ClusterHasArchitecture(arch string) (bool, error) { + var nodes core.NodeList + listErr := backoff.Retry( + func() error { + return f.Client.List(context.TODO(), &nodes) + }, + defaultBackoff) + if listErr != nil { + return false, fmt.Errorf("couldn't list nodes: %w", listErr) + } + + for _, node := range nodes.Items { + if node.Status.NodeInfo.Architecture == arch { + return true, nil + } + } + + return false, nil +} + // GetConfigMapsFromScan lists the configmaps from the specified openscap scan instance func (f *Framework) GetConfigMapsFromScan(scaninstance *compv1alpha1.ComplianceScan) ([]core.ConfigMap, error) { var configmaps core.ConfigMapList diff --git a/tests/e2e/framework/dast.go b/tests/e2e/framework/dast.go new file mode 100644 index 0000000000..8020ad26ce --- /dev/null +++ b/tests/e2e/framework/dast.go @@ -0,0 +1,287 @@ +package framework + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + authenticationv1 "k8s.io/api/authentication/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + "sigs.k8s.io/yaml" +) + +const ( + iscDastGcsSecretPath = "/var/run/vault/rapidast-sa-isc/isc-dast-gcs-secret" +) + +// GetServiceAccountToken retrieves a service account token for API authentication +func GetServiceAccountToken(f *Framework, namespace string) (string, error) { + // Grant cluster-admin to the default service account temporarily + log.Printf("Granting cluster-admin role to system:serviceaccount:%s:default", namespace) + // Create ClusterRoleBinding to grant cluster-admin permissions + clusterRoleBindingName := fmt.Sprintf("rapidast-admin-%s", namespace) + clusterRoleBinding := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterRoleBindingName, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: "cluster-admin", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: "default", + Namespace: namespace, + }, + }, + } + + _, err := f.KubeClient.RbacV1().ClusterRoleBindings().Create( + context.TODO(), + clusterRoleBinding, + metav1.CreateOptions{}, + ) + if err != nil { + return "", fmt.Errorf("failed to create ClusterRoleBinding: %w", err) + } + + log.Printf("Successfully created ClusterRoleBinding %s", clusterRoleBindingName) + // For newer Kubernetes versions, create a token request + expirationSeconds := int64(3600) + treq := &authenticationv1.TokenRequest{ + Spec: authenticationv1.TokenRequestSpec{ + ExpirationSeconds: &expirationSeconds, + }, + } + + tresp, err := f.KubeClient.CoreV1().ServiceAccounts(namespace).CreateToken( + context.TODO(), + "default", + treq, + metav1.CreateOptions{}, + ) + if err != nil { + return "", fmt.Errorf("failed to create token: %w", err) + } + return tresp.Status.Token, nil +} + +// RunRapidASTScan executes the RapidAST scan using a Kubernetes Job +func RunRapidASTScan(f *Framework, namespace string) error { + log.Printf("Starting RapidAST scan in namespace %s", namespace) + // Get service account token + token, err := GetServiceAccountToken(f, namespace) + if err != nil { + return fmt.Errorf("failed to get service account token: %w", err) + } + + // Cleanup ClusterRoleBinding after scan completes + clusterRoleBindingName := fmt.Sprintf("rapidast-admin-%s", namespace) + defer func() { + log.Printf("Cleaning up ClusterRoleBinding %s", clusterRoleBindingName) + err := f.KubeClient.RbacV1().ClusterRoleBindings().Delete( + context.TODO(), + clusterRoleBindingName, + metav1.DeleteOptions{}, + ) + if err != nil { + log.Printf("Warning: failed to delete ClusterRoleBinding %s: %v", clusterRoleBindingName, err) + } else { + log.Printf("Successfully deleted ClusterRoleBinding %s", clusterRoleBindingName) + } + }() + + // Check if GCS secret file exists for upload support + uploadCentralStorage := false + _, err = os.ReadFile(iscDastGcsSecretPath) + if err == nil { + uploadCentralStorage = true + } + + // Read the rapidast config template + testDataDir := filepath.Join(f.projectRoot, "tests", "data", "rapidast") + configFileName := "rapidast-config.yaml" + if uploadCentralStorage { + configFileName = "rapidast-config-upload.yaml" + } + configTemplatePath := filepath.Join(testDataDir, configFileName) + policyPath := filepath.Join(testDataDir, "customscan.policy") + jobPath := filepath.Join(testDataDir, "job-rapidast.yaml") + + configTemplate, err := os.ReadFile(configTemplatePath) + if err != nil { + return fmt.Errorf("failed to read config template: %w", err) + } + + // Replace token placeholder + config := strings.Replace(string(configTemplate), "Bearer sha256~xxxxxxxx", "Bearer "+token, -1) + + // Create temporary config file + tmpConfigPath := filepath.Join(os.TempDir(), fmt.Sprintf("rapidast-config-%d.yaml", time.Now().Unix())) + if err := os.WriteFile(tmpConfigPath, []byte(config), 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + defer os.Remove(tmpConfigPath) + + // Create ConfigMap with config and policy + log.Printf("Creating ConfigMap rapidast-configmap in namespace %s", namespace) + configMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rapidast-configmap", + Namespace: namespace, + }, + Data: map[string]string{}, + BinaryData: map[string][]byte{ + "rapidast-config.yaml": []byte(config), + }, + } + + // Read policy file + policyData, err := os.ReadFile(policyPath) + if err != nil { + return fmt.Errorf("failed to read policy file: %w", err) + } + configMap.BinaryData["customscan.policy"] = policyData + + // Include GCS secret if upload is enabled + if uploadCentralStorage { + gcsSecretData, err := os.ReadFile(iscDastGcsSecretPath) + if err != nil { + return fmt.Errorf("failed to read GCS secret file: %w", err) + } + configMap.BinaryData["dast-gcs-secret.json"] = gcsSecretData + log.Printf("Including GCS secret for result upload") + } + + _, err = f.KubeClient.CoreV1().ConfigMaps(namespace).Create(context.TODO(), configMap, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("failed to create ConfigMap: %w", err) + } + + // Read and create Job + log.Printf("Creating Job rapidast-job in namespace %s", namespace) + jobData, err := os.ReadFile(jobPath) + if err != nil { + return fmt.Errorf("failed to read job template: %w", err) + } + + // Parse Job YAML + job := &batchv1.Job{} + if err := yaml.Unmarshal(jobData, job); err != nil { + return fmt.Errorf("failed to parse job YAML: %w", err) + } + job.Namespace = namespace + _, err = f.KubeClient.BatchV1().Jobs(namespace).Create(context.TODO(), job, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("failed to create Job: %w", err) + } + + // Wait for job to complete (up to 10 minutes) + log.Printf("Waiting for RapidAST job to complete...") + err = wait.PollUntilContextTimeout(context.Background(), 30*time.Second, 10*time.Minute, true, func(ctx context.Context) (bool, error) { + pods, err := f.KubeClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: "job-name=rapidast-job", + }) + if err != nil { + log.Printf("Error listing pods: %v", err) + return false, nil + } + + if len(pods.Items) == 0 { + log.Printf("No pods found for rapidast-job yet") + return false, nil + } + + pod := pods.Items[0] + phase := pod.Status.Phase + log.Printf("RapidAST job pod status: %s", phase) + + if phase == corev1.PodPending || phase == corev1.PodRunning { + return false, nil + } + if phase == corev1.PodFailed { + return false, fmt.Errorf("rapidast-job failed") + } + return phase == corev1.PodSucceeded, nil + }) + + if err != nil { + return fmt.Errorf("job did not complete successfully: %w", err) + } + + // Get pod logs + pods, err := f.KubeClient.CoreV1().Pods(namespace).List(context.TODO(), metav1.ListOptions{ + LabelSelector: "job-name=rapidast-job", + }) + if err != nil || len(pods.Items) == 0 { + return fmt.Errorf("failed to get job pods: %w", err) + } + + podName := pods.Items[0].Name + req := f.KubeClient.CoreV1().Pods(namespace).GetLogs(podName, &corev1.PodLogOptions{}) + logs, err := req.Stream(context.TODO()) + if err != nil { + return fmt.Errorf("failed to get pod logs: %w", err) + } + defer logs.Close() + + // Read logs + logData := new(strings.Builder) + buf := make([]byte, 2048) + for { + n, err := logs.Read(buf) + if n > 0 { + logData.Write(buf[:n]) + } + if err != nil { + break + } + } + podLogs := logData.String() + + // Save results to artifact directory if available + artifactDir := os.Getenv("ARTIFACT_DIR") + if artifactDir != "" { + resultsDir := filepath.Join(artifactDir, "rapiddastresultsISC") + if err := os.MkdirAll(resultsDir, 0755); err == nil { + resultFile := filepath.Join(resultsDir, "compliance_v1alpha1_rapidast.result") + if err := os.WriteFile(resultFile, []byte(podLogs), 0644); err != nil { + log.Printf("Failed to write result file: %v", err) + } else { + log.Printf("Wrote DAST results to %s", resultFile) + } + } + } + + // Parse results for high/medium risks + riskHigh := 0 + riskMedium := 0 + reHigh := regexp.MustCompile(`"riskdesc": .*High`) + reMedium := regexp.MustCompile(`"riskdesc": .*Medium`) + + lines := strings.Split(podLogs, "\n") + for _, line := range lines { + if reHigh.MatchString(line) { + riskHigh++ + } + if reMedium.MatchString(line) { + riskMedium++ + } + } + log.Printf("RapidAST scan results: High=%d, Medium=%d", riskHigh, riskMedium) + if riskHigh > 0 { + return fmt.Errorf("High risk security issues found: %d", riskHigh) + } + return nil +} diff --git a/tests/e2e/parallel/main_test.go b/tests/e2e/parallel/main_test.go index 7dbabc2d39..d39f68b961 100644 --- a/tests/e2e/parallel/main_test.go +++ b/tests/e2e/parallel/main_test.go @@ -3342,7 +3342,7 @@ func TestCustomRuleCheckTypeAndScannerTypeValidation(t *testing.T) { Title: "Invalid ScannerType Rule", Description: "This rule has invalid scannerType", Severity: "low", - CheckType: "Platform", // Valid checkType + CheckType: "Platform", // Valid checkType ScannerType: compv1alpha1.ScannerTypeOpenSCAP, // This should be rejected Expression: `pods.items.size() >= 0`, Inputs: []compv1alpha1.InputPayload{ @@ -3383,7 +3383,7 @@ func TestCustomRuleCheckTypeAndScannerTypeValidation(t *testing.T) { Title: "Valid Rule", Description: "This rule has valid checkType and scannerType", Severity: "low", - CheckType: "Platform", // Valid checkType + CheckType: "Platform", // Valid checkType ScannerType: compv1alpha1.ScannerTypeCEL, // Valid scannerType Expression: `pods.items.size() >= 0`, Inputs: []compv1alpha1.InputPayload{ @@ -6054,3 +6054,27 @@ func TestCELWithXCCDFProfileScan(t *testing.T) { t.Log("Mixed CEL + XCCDF scan test completed successfully") } + +// TestComplianceOperatorPassesDAST verifies that the compliance operator +// passes Dynamic Application Security Testing (DAST) using RapidAST +func TestComplianceOperatorPassesDAST(t *testing.T) { + t.Parallel() + f := framework.Global + + // Skip test if cluster nodes are not amd64 architecture + // The RapidAST image only supports amd64 + hasAMD64, err := f.ClusterHasArchitecture("amd64") + if err != nil { + t.Fatalf("Failed to check cluster architecture: %s", err) + } + if !hasAMD64 { + t.Skip("Skipping DAST test: RapidAST image only supports amd64, no amd64 nodes found in cluster") + } + + // Run RapidAST scan using Kubernetes Job + err = framework.RunRapidASTScan(f, f.OperatorNamespace) + if err != nil { + t.Fatalf("RapidAST scan failed: %s", err) + } + t.Log("Compliance operator passed DAST scan") +} diff --git a/tests/e2e/serial/main_test.go b/tests/e2e/serial/main_test.go index 6de021fc38..4fd0a2a71b 100644 --- a/tests/e2e/serial/main_test.go +++ b/tests/e2e/serial/main_test.go @@ -2681,6 +2681,7 @@ func TestStrictNodeScanConfiguration(t *testing.T) { if err := f.WaitForSuiteScansStatus(f.OperatorNamespace, bindingName, compv1alpha1.PhaseDone, compv1alpha1.ResultNonCompliant); err != nil { t.Fatal(err) + t.Fatal(err) } if err := f.Client.Delete(context.TODO(), &scanSettingBinding); err != nil {