diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 4643d5925..eb929af1e 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -1,8 +1,9 @@ AAD AADSTS -acr +Acr artifactory artifactregistry +auditconfig bak bitnami containerregistry @@ -28,7 +29,6 @@ psat rolearn selfsigned servicemonitor -servicemonitors spiffe SResources SVIDs diff --git a/controllers/container_image/deployment_handler.go b/controllers/container_image/deployment_handler.go index 50bfc9877..786072256 100644 --- a/controllers/container_image/deployment_handler.go +++ b/controllers/container_image/deployment_handler.go @@ -29,14 +29,8 @@ type DeploymentHandler struct { } func (n *DeploymentHandler) Reconcile(ctx context.Context) (ctrl.Result, error) { - // TODO: remove in next version - // Delete the old container scanning cronjob if it exists - if err := k8s.DeleteIfExists(ctx, - n.KubeClient, - &batchv1.CronJob{ObjectMeta: metav1.ObjectMeta{ - Name: OldCronJobName(n.Mondoo.Name), - Namespace: n.Mondoo.Namespace, - }}); err != nil { + // Clean up CronJobs with stale names (from old naming schemes) + if err := n.cleanupStaleCronJobs(ctx); err != nil { return ctrl.Result{}, err } @@ -235,3 +229,26 @@ func (n *DeploymentHandler) cleanupWIFServiceAccount(ctx context.Context) error } return nil } + +// cleanupStaleCronJobs removes CronJobs from old naming schemes by label selection. +func (n *DeploymentHandler) cleanupStaleCronJobs(ctx context.Context) error { + cronJobs := &batchv1.CronJobList{} + listOpts := &client.ListOptions{ + Namespace: n.Mondoo.Namespace, + LabelSelector: labels.SelectorFromSet(CronJobLabels(*n.Mondoo)), + } + if err := n.KubeClient.List(ctx, cronJobs, listOpts); err != nil { + return err + } + + expectedName := CronJobName(n.Mondoo.Name) + for i := range cronJobs.Items { + if cronJobs.Items[i].Name != expectedName { + logger.Info("Deleting stale container scan CronJob", "name", cronJobs.Items[i].Name) + if err := k8s.DeleteIfExists(ctx, n.KubeClient, &cronJobs.Items[i]); err != nil { + return err + } + } + } + return nil +} diff --git a/controllers/container_image/resources.go b/controllers/container_image/resources.go index 229ca94fd..a27031a75 100644 --- a/controllers/container_image/resources.go +++ b/controllers/container_image/resources.go @@ -11,7 +11,6 @@ import ( // That's the mod k8s relies on https://github.com/kubernetes/kubernetes/blob/master/go.mod#L63 "go.mondoo.com/mondoo-operator/api/v1alpha2" - k8s_scan "go.mondoo.com/mondoo-operator/controllers/k8s_scan" "go.mondoo.com/mondoo-operator/pkg/constants" "go.mondoo.com/mondoo-operator/pkg/feature_flags" "go.mondoo.com/mondoo-operator/pkg/utils/k8s" @@ -19,7 +18,6 @@ import ( "go.mondoo.com/mql/v13/providers-sdk/v1/inventory" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" "sigs.k8s.io/yaml" @@ -193,7 +191,7 @@ func CronJob(image, integrationMrn, clusterUid, privateRegistrySecretName string ) // Add init container for registry credential generation - podSpec.InitContainers = append(podSpec.InitContainers, registryWIFInitContainer(wif)) + podSpec.InitContainers = append(podSpec.InitContainers, k8s.RegistryWIFInitContainer(wif)) // AKS Workload Identity webhook requires this label on the pod template only. // Copy labels so we don't mutate the CronJob/Job metadata. @@ -232,7 +230,7 @@ func OldCronJobName(prefix string) string { } func CronJobName(prefix string) string { - return fmt.Sprintf("%s%s", prefix, CronJobNameSuffix) + return k8s.CronJobName("container-scan", prefix) } func ConfigMap(integrationMRN, clusterUID string, m v1alpha2.MondooAuditConfig, cfg v1alpha2.MondooOperatorConfig) (*corev1.ConfigMap, error) { @@ -380,175 +378,3 @@ func validateContainerRegistryWIF(wif *v1alpha2.WorkloadIdentityConfig) error { return nil } - -// registryWIFInitContainer creates an init container that generates docker config credentials -// using cloud-native Workload Identity Federation -func registryWIFInitContainer(wif *v1alpha2.WorkloadIdentityConfig) corev1.Container { - var image, shell, script string - var env []corev1.EnvVar - - // Common retry wrapper for transient failures - retryWrapper := `set -euo pipefail -# Retry wrapper for transient failures -retry() { - local max_attempts=3 - local delay=5 - local attempt=1 - while [ $attempt -le $max_attempts ]; do - if "$@"; then - return 0 - fi - echo "Attempt $attempt failed, retrying in ${delay}s..." - sleep $delay - attempt=$((attempt + 1)) - done - echo "All $max_attempts attempts failed" - return 1 -} -` - - switch wif.Provider { - case v1alpha2.CloudProviderGKE: - image = k8s_scan.GCloudSDKImage - shell = "/bin/bash" - script = retryWrapper + ` -# Use WIF identity to get an access token for Artifact Registry / GCR -TOKEN=$(retry gcloud auth print-access-token) -AUTH=$(echo -n "oauth2accesstoken:${TOKEN}" | base64 -w0) - -# All GCP regions and multi-region locations that host Artifact Registry. -# Docker config requires exact hostname matches, so we enumerate them all. -AR_LOCATIONS=" -africa-south1 asia-east1 asia-east2 asia-northeast1 asia-northeast2 asia-northeast3 -asia-south1 asia-south2 asia-southeast1 asia-southeast2 -australia-southeast1 australia-southeast2 -europe-central2 europe-north1 europe-southwest1 europe-west1 europe-west2 -europe-west3 europe-west4 europe-west6 europe-west8 europe-west9 europe-west10 europe-west12 -me-central1 me-central2 me-west1 -northamerica-northeast1 northamerica-northeast2 -southamerica-east1 southamerica-west1 -us-central1 us-east1 us-east4 us-east5 us-south1 us-west1 us-west2 us-west3 us-west4 -asia europe us -" - -AUTHS="" -add_auth() { - [ -n "$AUTHS" ] && AUTHS="${AUTHS}," - AUTHS="${AUTHS}\"$1\":{\"auth\":\"${AUTH}\"}" -} - -for loc in $AR_LOCATIONS; do - add_auth "${loc}-docker.pkg.dev" -done - -# Legacy GCR endpoints -for host in gcr.io us.gcr.io eu.gcr.io asia.gcr.io; do - add_auth "$host" -done - -cat > /etc/opt/mondoo/docker/config.json < /etc/opt/mondoo/docker/config.json < /etc/opt/mondoo/docker/config.json < 0 if !n.Mondoo.Spec.KubernetesResources.Enable { @@ -399,10 +404,7 @@ func (n *DeploymentHandler) cleanupOrphanedExternalClusterResources(ctx context. cronJobs := &batchv1.CronJobList{} listOpts := &client.ListOptions{ Namespace: n.Mondoo.Namespace, - LabelSelector: labels.SelectorFromSet(map[string]string{ - "app": "mondoo-k8s-scan", - "mondoo_cr": n.Mondoo.Name, - }), + LabelSelector: labels.SelectorFromSet(CronJobLabels(*n.Mondoo)), } if err := n.KubeClient.List(ctx, cronJobs, listOpts); err != nil { return err @@ -568,10 +570,7 @@ func (n *DeploymentHandler) garbageCollectIfNeeded(ctx context.Context, clusterU cronJobs := &batchv1.CronJobList{} listOpts := &client.ListOptions{ Namespace: n.Mondoo.Namespace, - LabelSelector: labels.SelectorFromSet(map[string]string{ - "app": "mondoo-k8s-scan", - "mondoo_cr": n.Mondoo.Name, - }), + LabelSelector: labels.SelectorFromSet(CronJobLabels(*n.Mondoo)), } if err := n.KubeClient.List(ctx, cronJobs, listOpts); err != nil { logger.Error(err, "Failed to list CronJobs for garbage collection") @@ -718,3 +717,40 @@ func (n *DeploymentHandler) syncWIFServiceAccount(ctx context.Context, cluster v return nil } + +// cleanupStaleCronJobs removes CronJobs from old naming schemes by label selection. +// CronJobs belonging to removed external clusters are skipped here — cleanupOrphanedExternalClusterResources +// handles those so it can also clean up associated ConfigMaps, ServiceAccounts, and Secrets. +func (n *DeploymentHandler) cleanupStaleCronJobs(ctx context.Context) error { + cronJobs := &batchv1.CronJobList{} + listOpts := &client.ListOptions{ + Namespace: n.Mondoo.Namespace, + LabelSelector: labels.SelectorFromSet(CronJobLabels(*n.Mondoo)), + } + if err := n.KubeClient.List(ctx, cronJobs, listOpts); err != nil { + return err + } + + expected := map[string]bool{ + CronJobName(n.Mondoo.Name): true, + } + configuredClusters := make(map[string]bool) + for _, cluster := range n.Mondoo.Spec.KubernetesResources.ExternalClusters { + expected[ExternalClusterCronJobName(n.Mondoo.Name, cluster.Name)] = true + configuredClusters[cluster.Name] = true + } + + for i := range cronJobs.Items { + if expected[cronJobs.Items[i].Name] { + continue + } + if clusterName, ok := cronJobs.Items[i].Labels["cluster_name"]; ok && !configuredClusters[clusterName] { + continue + } + logger.Info("Deleting stale k8s scan CronJob", "name", cronJobs.Items[i].Name) + if err := k8s.DeleteIfExists(ctx, n.KubeClient, &cronJobs.Items[i]); err != nil { + return err + } + } + return nil +} diff --git a/controllers/k8s_scan/deployment_handler_test.go b/controllers/k8s_scan/deployment_handler_test.go index a964b06c4..ef3d9b7de 100644 --- a/controllers/k8s_scan/deployment_handler_test.go +++ b/controllers/k8s_scan/deployment_handler_test.go @@ -1530,11 +1530,11 @@ func TestExternalClusterNaming(t *testing.T) { t.Run(tt.prefix+"-"+tt.clusterName, func(t *testing.T) { // Test CronJob name cronJobName := ExternalClusterCronJobName(tt.prefix, tt.clusterName) - if !strings.HasPrefix(cronJobName, tt.prefix) { - t.Errorf("CronJob name should start with prefix %q, got %q", tt.prefix, cronJobName) + if !strings.HasPrefix(cronJobName, "mondoo-k8s-scan-") { + t.Errorf("CronJob name should start with %q, got %q", "mondoo-k8s-scan-", cronJobName) } - if !strings.HasSuffix(cronJobName, tt.clusterName) { - t.Errorf("CronJob name should end with cluster name %q, got %q", tt.clusterName, cronJobName) + if !strings.Contains(cronJobName, tt.clusterName) && len(cronJobName) < 52 { + t.Errorf("CronJob name should contain cluster name %q when not truncated, got %q", tt.clusterName, cronJobName) } // Test ConfigMap name diff --git a/controllers/k8s_scan/resources.go b/controllers/k8s_scan/resources.go index 3dfca9516..495c5437b 100644 --- a/controllers/k8s_scan/resources.go +++ b/controllers/k8s_scan/resources.go @@ -5,6 +5,7 @@ package k8s_scan import ( "fmt" + "maps" "path/filepath" "strings" @@ -487,7 +488,7 @@ func ExternalClusterCronJob(image string, cluster v1alpha2.ExternalCluster, m *v cfg.Spec.ImagePullSecrets...) } - // Add private registry pull secrets if configured + // Add private registry pull secrets if configured (static credentials) if cluster.PrivateRegistriesPullSecretRef != nil && cluster.PrivateRegistriesPullSecretRef.Name != "" { cronjob.Spec.JobTemplate.Spec.Template.Spec.Volumes = append(cronjob.Spec.JobTemplate.Spec.Template.Spec.Volumes, corev1.Volume{ Name: "pull-secrets", @@ -531,6 +532,42 @@ func ExternalClusterCronJob(image string, cluster v1alpha2.ExternalCluster, m *v ) } + // Add WIF registry credentials when container image scanning is enabled and WIF registry auth is configured. + // The pod's existing cloud identity (from the external cluster's WIF SA) is used to obtain registry tokens. + // Skip when static pull secrets are already configured — they take precedence. + hasStaticPullSecret := cluster.PrivateRegistriesPullSecretRef != nil && cluster.PrivateRegistriesPullSecretRef.Name != "" + if !hasStaticPullSecret && cluster.ContainerImageScanning && m.Spec.Containers.WorkloadIdentity != nil && cluster.WorkloadIdentity != nil { + podSpec := &cronjob.Spec.JobTemplate.Spec.Template.Spec + + podSpec.Volumes = append(podSpec.Volumes, corev1.Volume{ + Name: "docker-config", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{}, + }, + }) + + podSpec.Containers[0].VolumeMounts = append(podSpec.Containers[0].VolumeMounts, + corev1.VolumeMount{ + Name: "docker-config", + ReadOnly: true, + MountPath: "/etc/opt/mondoo/docker", + }, + ) + podSpec.Containers[0].Env = append(podSpec.Containers[0].Env, + corev1.EnvVar{Name: "DOCKER_CONFIG", Value: "/etc/opt/mondoo/docker"}, + ) + + podSpec.InitContainers = append(podSpec.InitContainers, k8s.RegistryWIFInitContainer(m.Spec.Containers.WorkloadIdentity)) + + // AKS Workload Identity webhook requires this label + if m.Spec.Containers.WorkloadIdentity.Provider == v1alpha2.CloudProviderAKS { + podLabels := make(map[string]string, len(ls)+1) + maps.Copy(podLabels, cronjob.Spec.JobTemplate.Spec.Template.Labels) + podLabels["azure.workload.identity/use"] = "true" + cronjob.Spec.JobTemplate.Spec.Template.Labels = podLabels + } + } + return cronjob } @@ -552,11 +589,11 @@ func ExternalClusterCronJobLabels(m v1alpha2.MondooAuditConfig, clusterName stri } func CronJobName(prefix string) string { - return fmt.Sprintf("%s%s", prefix, CronJobNameSuffix) + return k8s.CronJobName("k8s-scan", prefix) } func ExternalClusterCronJobName(prefix, clusterName string) string { - return fmt.Sprintf("%s%s-%s", prefix, CronJobNameSuffix, clusterName) + return k8s.CronJobNameWithCluster("k8s-scan", prefix, clusterName) } func ConfigMapName(prefix string) string { @@ -648,25 +685,6 @@ func WIFServiceAccount(cluster v1alpha2.ExternalCluster, m *v1alpha2.MondooAudit return sa } -// Container image versions for init containers (pinned for reproducibility) -const ( - // Google Cloud SDK image - slim variant for smaller size - // https://cloud.google.com/sdk/docs/downloads-docker - GCloudSDKImage = "gcr.io/google.com/cloudsdktool/google-cloud-cli:499.0.0-slim" - - // AWS CLI image - // https://hub.docker.com/r/amazon/aws-cli - AWSCLIImage = "amazon/aws-cli:2.22.0" - - // Azure CLI image - // https://mcr.microsoft.com/en-us/artifact/mar/azure-cli/tags - AzureCLIImage = "mcr.microsoft.com/azure-cli:2.67.0" - - // SPIFFE Helper image - // https://github.com/spiffe/spiffe-helper/releases - SPIFFEHelperImage = "ghcr.io/spiffe/spiffe-helper:0.8.0" -) - // wifInitContainer creates an init container that generates kubeconfig using cloud CLI tools func wifInitContainer(cluster v1alpha2.ExternalCluster) corev1.Container { var image, shell, script string @@ -694,16 +712,41 @@ retry() { switch cluster.WorkloadIdentity.Provider { case v1alpha2.CloudProviderGKE: - image = GCloudSDKImage + image = constants.GCloudSDKImage shell = "/bin/bash" script = retryWrapper + ` -retry gcloud container clusters get-credentials "$CLUSTER_NAME" \ +# Build kubeconfig with a bearer token instead of gke-gcloud-auth-plugin, +# since the main container (cnspec) doesn't have the plugin installed. +CLUSTER_CA=$(retry gcloud container clusters describe "$CLUSTER_NAME" \ + --project "$PROJECT_ID" \ + --location "$CLUSTER_LOCATION" \ + --format='value(masterAuth.clusterCaCertificate)') +CLUSTER_ENDPOINT=$(retry gcloud container clusters describe "$CLUSTER_NAME" \ --project "$PROJECT_ID" \ - --location "$CLUSTER_LOCATION" -cp ~/.kube/config /etc/opt/mondoo/kubeconfig/kubeconfig -echo "=== DEBUG: generated kubeconfig ===" -cat /etc/opt/mondoo/kubeconfig/kubeconfig -echo "=== END DEBUG ===" + --location "$CLUSTER_LOCATION" \ + --format='value(endpoint)') +TOKEN=$(retry gcloud auth print-access-token) + +cat > /etc/opt/mondoo/kubeconfig/kubeconfig </dev/null || true return corev1.Container{ Name: "fetch-spiffe-certs", - Image: SPIFFEHelperImage, + Image: constants.SPIFFEHelperImage, ImagePullPolicy: corev1.PullIfNotPresent, Command: []string{"/bin/sh", "-c", script}, Env: []corev1.EnvVar{ diff --git a/docs/user-manual.md b/docs/user-manual.md index cfe499797..b5664d50d 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -354,6 +354,12 @@ kubectl create secret generic prod-sa-credentials \ Use cloud-native identity federation with no static credentials. Supports GKE, EKS, and AKS. +WIF requires setup in three places: + +1. **Cloud IAM**: bind the Kubernetes ServiceAccount to the cloud identity +2. **Target cluster RBAC**: grant the cloud identity read access to the target cluster's Kubernetes API +3. **MondooAuditConfig**: configure the external cluster with WIF auth + **GKE example:** ```yaml @@ -368,6 +374,50 @@ externalClusters: googleServiceAccount: scanner@my-gcp-project.iam.gserviceaccount.com ``` +GKE prerequisites: + +- GKE cluster with Workload Identity enabled on both scanner and target clusters +- Google Service Account (GSA) with access to the target cluster +- IAM binding allowing the operator's KSA to impersonate the GSA: + + ```bash + gcloud iam service-accounts add-iam-policy-binding \ + scanner@my-gcp-project.iam.gserviceaccount.com \ + --project=my-gcp-project \ + --role="roles/iam.workloadIdentityUser" \ + --member="serviceAccount:my-gcp-project.svc.id.goog[mondoo-operator/MONDOO_AUDIT_CONFIG_NAME-wif-gke-prod]" + ``` + + Replace `MONDOO_AUDIT_CONFIG_NAME` with your MondooAuditConfig name (e.g. `mondoo-client`). The KSA name follows the pattern `-wif-`. + +- ClusterRole and ClusterRoleBinding on the **target cluster** granting the GSA read permissions: + + ```yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + name: mondoo-operator-k8s-resources-scanning + rules: + - apiGroups: ['*'] + resources: ['*'] + verbs: [get, watch, list] + --- + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: mondoo-operator-k8s-resources-scanning + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: mondoo-operator-k8s-resources-scanning + subjects: + - apiGroup: rbac.authorization.k8s.io + kind: User + name: scanner@my-gcp-project.iam.gserviceaccount.com + ``` + + Apply this to the target cluster: `kubectl apply --context -f target-rbac.yaml` + **EKS example:** ```yaml @@ -381,6 +431,29 @@ externalClusters: roleArn: arn:aws:iam::123456789012:role/MondooScannerRole ``` +EKS prerequisites: + +- EKS cluster with IRSA (IAM Roles for Service Accounts) enabled on the scanner cluster +- IAM role with a trust policy allowing the operator's KSA to assume it: + + ```json + { + "Effect": "Allow", + "Principal": { + "Federated": "arn:aws:iam::123456789012:oidc-provider/oidc.eks.us-west-2.amazonaws.com/id/OIDC_ID" + }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "oidc.eks.us-west-2.amazonaws.com/id/OIDC_ID:sub": "system:serviceaccount:mondoo-operator:MONDOO_AUDIT_CONFIG_NAME-wif-eks-prod" + } + } + } + ``` + +- The IAM role must be mapped to a Kubernetes user on the target cluster via [EKS access entries](https://docs.aws.amazon.com/eks/latest/userguide/access-entries.html) or the `aws-auth` ConfigMap +- ClusterRole and ClusterRoleBinding on the **target cluster** (same as the GKE example above, with the IAM role ARN as the subject `name`) + **AKS example:** ```yaml @@ -396,6 +469,24 @@ externalClusters: tenantId: fedcba98-7654-3210-fedc-ba9876543210 ``` +AKS prerequisites: + +- AKS cluster with Workload Identity enabled on the scanner cluster +- User-assigned managed identity +- Federated identity credential trusting the operator's KSA: + + ```bash + az identity federated-credential create \ + --name mondoo-scanner-aks-prod \ + --identity-name my-managed-identity \ + --resource-group my-resource-group \ + --issuer "$(az aks show -g my-resource-group -n scanner-cluster --query oidcIssuerProfile.issuerUrl -o tsv)" \ + --subject "system:serviceaccount:mondoo-operator:MONDOO_AUDIT_CONFIG_NAME-wif-aks-prod" \ + --audience api://AzureADTokenExchange + ``` + +- ClusterRole and ClusterRoleBinding on the **target cluster** (same as the GKE example above, with the managed identity's object ID as the subject `name`) + #### 4. SPIFFE/SPIRE (zero-trust) Use SPIFFE/SPIRE for zero-trust authentication with auto-rotating X.509 certificates. @@ -411,6 +502,12 @@ externalClusters: # socketPath: "/run/spire/sockets/agent.sock" ``` +SPIFFE prerequisites: + +- SPIRE agent running on the scanner cluster nodes +- The target cluster must have a ClusterRole and ClusterRoleBinding granting read permissions to the SPIFFE identity (same pattern as WIF — use the SPIFFE ID as the subject `name`, e.g. `spiffe://trust-domain/ns/mondoo-operator/sa/scanner`) +- The target cluster's API server must be configured to authenticate SPIFFE/X.509 client certificates (e.g., via `--client-ca-file` including the SPIRE trust bundle) + > **Important: HostPath permissions required** > > SPIFFE authentication requires mounting the SPIRE agent socket from the host filesystem using a HostPath volume. This may require: @@ -582,9 +679,20 @@ spec: ``` Prerequisites: + - GKE cluster with Workload Identity enabled - Google Service Account with `roles/artifactregistry.reader` (or `roles/storage.objectViewer` for GCR) -- IAM policy binding the GSA to the Kubernetes ServiceAccount `mondoo-client-cr-wif` in the operator namespace +- IAM policy binding the GSA to the Kubernetes ServiceAccount: + + ```bash + gcloud iam service-accounts add-iam-policy-binding \ + scanner@my-gcp-project.iam.gserviceaccount.com \ + --project=my-gcp-project \ + --role="roles/iam.workloadIdentityUser" \ + --member="serviceAccount:my-gcp-project.svc.id.goog[mondoo-operator/mondoo-client-cr-wif]" + ``` + + The KSA name follows the pattern `-cr-wif`. **EKS (ECR):** @@ -601,9 +709,10 @@ spec: ``` Prerequisites: + - EKS cluster with IRSA (IAM Roles for Service Accounts) enabled - IAM role with `ecr:GetAuthorizationToken`, `ecr:BatchGetImage`, `ecr:GetDownloadUrlForLayer`, and `ecr:BatchCheckLayerAvailability` permissions -- IRSA trust policy allowing the Kubernetes ServiceAccount `mondoo-client-cr-wif` in the operator namespace +- IRSA trust policy allowing the Kubernetes ServiceAccount `-cr-wif` in the operator namespace (same trust policy pattern as external cluster WIF above) **AKS (ACR):** @@ -623,9 +732,20 @@ spec: ``` Prerequisites: + - AKS cluster with Workload Identity enabled - User-assigned managed identity with `AcrPull` role on the ACR -- Federated identity credential trusting the Kubernetes ServiceAccount `mondoo-client-cr-wif` in the operator namespace +- Federated identity credential trusting the Kubernetes ServiceAccount `-cr-wif` in the operator namespace: + + ```bash + az identity federated-credential create \ + --name mondoo-cr-wif \ + --identity-name my-managed-identity \ + --resource-group my-resource-group \ + --issuer "$(az aks show -g my-resource-group -n my-cluster --query oidcIssuerProfile.issuerUrl -o tsv)" \ + --subject "system:serviceaccount:mondoo-operator:mondoo-client-cr-wif" \ + --audience api://AzureADTokenExchange + ``` #### RBAC for the WIF ServiceAccount diff --git a/pkg/constants/images.go b/pkg/constants/images.go new file mode 100644 index 000000000..e789ba14d --- /dev/null +++ b/pkg/constants/images.go @@ -0,0 +1,22 @@ +// Copyright Mondoo, Inc. 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package constants + +const ( + // Google Cloud SDK image - slim variant for smaller size + // https://cloud.google.com/sdk/docs/downloads-docker + GCloudSDKImage = "gcr.io/google.com/cloudsdktool/google-cloud-cli:499.0.0-slim" + + // AWS CLI image + // https://hub.docker.com/r/amazon/aws-cli + AWSCLIImage = "amazon/aws-cli:2.22.0" + + // Azure CLI image + // https://mcr.microsoft.com/en-us/artifact/mar/azure-cli/tags + AzureCLIImage = "mcr.microsoft.com/azure-cli:2.67.0" + + // SPIFFE Helper image + // https://github.com/spiffe/spiffe-helper/releases + SPIFFEHelperImage = "ghcr.io/spiffe/spiffe-helper:0.8.0" +) diff --git a/pkg/utils/k8s/naming.go b/pkg/utils/k8s/naming.go new file mode 100644 index 000000000..9dd718b2b --- /dev/null +++ b/pkg/utils/k8s/naming.go @@ -0,0 +1,116 @@ +// Copyright Mondoo, Inc. 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package k8s + +import ( + "crypto/sha256" + "fmt" + "strings" +) + +const ( + cronJobPrefix = "mondoo-" + hashLen = 8 +) + +// CronJobName builds a CronJob name in the format: +// +// mondoo-{scanType} (default audit config) +// mondoo-{scanType}-{integrationID} (with integration ID, fits in limit) +// mondoo-{scanType}-{truncatedID}-{hash} (with integration ID, truncated) +// +// The hash is always derived from the integration ID alone, so CronJobs of different +// scan types for the same audit config share the same suffix. +func CronJobName(scanType, auditConfigName string) string { + return cronJobName(scanType, integrationID(auditConfigName), "") +} + +// CronJobNameWithCluster builds a CronJob name for external cluster scans: +// +// mondoo-{scanType}-{clusterName} (default audit config) +// mondoo-{scanType}-{integrationID}-{clusterName} (fits in limit) +// mondoo-{scanType}-{truncatedID}-{hash}-{clusterName} (truncated) +func CronJobNameWithCluster(scanType, auditConfigName, clusterName string) string { + return cronJobName(scanType, integrationID(auditConfigName), clusterName) +} + +func cronJobName(scanType, id, clusterName string) string { + base := cronJobPrefix + scanType + + suffix := id + if suffix != "" && clusterName != "" { + suffix += "-" + clusterName + } else if clusterName != "" { + suffix = clusterName + } + + if suffix == "" { + return base + } + + full := base + "-" + suffix + if len(full) <= ResourceNameMaxLength { + return full + } + + // Truncate the integration ID, keep cluster name intact when possible + idHash := fmt.Sprintf("%x", sha256.Sum256([]byte(id)))[:hashLen] + if clusterName != "" { + clusterHash := fmt.Sprintf("%x", sha256.Sum256([]byte(clusterName)))[:hashLen] + + // Try keeping full cluster name, truncating only the ID + // Budget: base + "-" + truncatedID + "-" + hash + "-" + clusterName + reserved := len(base) + 1 + 1 + hashLen + 1 + len(clusterName) + available := ResourceNameMaxLength - reserved + if available > 0 { + return base + "-" + id[:available] + "-" + idHash + "-" + clusterName + } + + // Full cluster name doesn't fit — try hash-only ID + full cluster name + candidate := base + "-" + idHash + "-" + clusterName + if len(candidate) <= ResourceNameMaxLength { + return candidate + } + + // Still too long — truncate cluster name too + if id == "" { + // No integration ID: base + "-" + truncatedCluster + "-" + clusterHash + reserved = len(base) + 1 + 1 + hashLen + available = ResourceNameMaxLength - reserved + if available > 0 { + return base + "-" + clusterName[:available] + "-" + clusterHash + } + return base + "-" + clusterHash + } + // Both ID and cluster: base + "-" + idHash + "-" + truncatedCluster + "-" + clusterHash + reserved = len(base) + 1 + hashLen + 1 + 1 + hashLen + available = ResourceNameMaxLength - reserved + if available > 0 { + return base + "-" + idHash + "-" + clusterName[:available] + "-" + clusterHash + } + return base + "-" + idHash + "-" + clusterHash + } + + // Budget: base + "-" + truncatedID + "-" + hash + reserved := len(base) + 1 + 1 + hashLen + available := ResourceNameMaxLength - reserved + if available > 0 { + return base + "-" + id[:available] + "-" + idHash + } + return base + "-" + idHash +} + +// integrationID extracts the integration ID from an audit config name. +// "mondoo-client" → "" (default, no integration ID) +// "mondoo-client-abc123" → "abc123" +// "custom-name" → "custom-name" (non-standard name, use as-is) +func integrationID(auditConfigName string) string { + if auditConfigName == "mondoo-client" { + return "" + } + if id, ok := strings.CutPrefix(auditConfigName, "mondoo-client-"); ok && id != "" { + return id + } + return auditConfigName +} diff --git a/pkg/utils/k8s/naming_test.go b/pkg/utils/k8s/naming_test.go new file mode 100644 index 000000000..40a197a39 --- /dev/null +++ b/pkg/utils/k8s/naming_test.go @@ -0,0 +1,89 @@ +// Copyright Mondoo, Inc. 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package k8s + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCronJobName_Default(t *testing.T) { + assert.Equal(t, "mondoo-container-scan", CronJobName("container-scan", "mondoo-client")) + assert.Equal(t, "mondoo-k8s-scan", CronJobName("k8s-scan", "mondoo-client")) +} + +func TestCronJobName_WithShortIntegrationID(t *testing.T) { + name := CronJobName("container-scan", "mondoo-client-abc123") + assert.Equal(t, "mondoo-container-scan-abc123", name) + assert.LessOrEqual(t, len(name), ResourceNameMaxLength) +} + +func TestCronJobName_WithLongIntegrationID(t *testing.T) { + name := CronJobName("container-scan", "mondoo-client-3drsen921s8x4dksnxthd5sedc4") + assert.LessOrEqual(t, len(name), ResourceNameMaxLength) + assert.Contains(t, name, "mondoo-container-scan-") + assert.Contains(t, name, "3drsen921s8x4dk") +} + +func TestCronJobName_SameHashAcrossTypes(t *testing.T) { + containerName := CronJobName("container-scan", "mondoo-client-3drsen921s8x4dksnxthd5sedc4") + k8sName := CronJobName("k8s-scan", "mondoo-client-3drsen921s8x4dksnxthd5sedc4") + + // Both should contain the same hash of the integration ID + // Extract last 8 chars (the hash) before any cluster name + containerParts := containerName[len("mondoo-container-scan-"):] + k8sParts := k8sName[len("mondoo-k8s-scan-"):] + + containerHash := containerParts[len(containerParts)-8:] + k8sHash := k8sParts[len(k8sParts)-8:] + assert.Equal(t, containerHash, k8sHash) +} + +func TestCronJobName_Deterministic(t *testing.T) { + name1 := CronJobName("container-scan", "mondoo-client-3drsen921s8x4dksnxthd5sedc4") + name2 := CronJobName("container-scan", "mondoo-client-3drsen921s8x4dksnxthd5sedc4") + assert.Equal(t, name1, name2) +} + +func TestCronJobNameWithCluster_Default(t *testing.T) { + name := CronJobNameWithCluster("k8s-scan", "mondoo-client", "target-cluster") + assert.Equal(t, "mondoo-k8s-scan-target-cluster", name) +} + +func TestCronJobNameWithCluster_WithIntegrationID(t *testing.T) { + name := CronJobNameWithCluster("k8s-scan", "mondoo-client-abc123", "target") + assert.Equal(t, "mondoo-k8s-scan-abc123-target", name) + assert.LessOrEqual(t, len(name), ResourceNameMaxLength) +} + +func TestCronJobNameWithCluster_Long(t *testing.T) { + name := CronJobNameWithCluster("k8s-scan", "mondoo-client-3drsen921s8x4dksnxthd5sedc4", "target-cluster") + assert.LessOrEqual(t, len(name), ResourceNameMaxLength) + assert.Contains(t, name, "mondoo-k8s-scan-") + assert.Contains(t, name, "target-cluster") +} + +func TestCronJobNameWithCluster_LongClusterName(t *testing.T) { + name := CronJobNameWithCluster("k8s-scan", "mondoo-client", "my-extremely-long-gke-cluster-name-that-exceeds-the-limit") + assert.LessOrEqual(t, len(name), ResourceNameMaxLength) + assert.Contains(t, name, "mondoo-k8s-scan-") +} + +func TestCronJobNameWithCluster_LongBoth(t *testing.T) { + name := CronJobNameWithCluster("k8s-scan", "mondoo-client-3drsen921s8x4dksnxthd5sedc4", "my-very-long-gke-cluster-name-in-production") + assert.LessOrEqual(t, len(name), ResourceNameMaxLength) + assert.Contains(t, name, "mondoo-k8s-scan-") +} + +func TestCronJobName_NonStandardName(t *testing.T) { + name := CronJobName("k8s-scan", "my-custom-config") + assert.Equal(t, "mondoo-k8s-scan-my-custom-config", name) +} + +func TestIntegrationID(t *testing.T) { + assert.Equal(t, "", integrationID("mondoo-client")) + assert.Equal(t, "abc123", integrationID("mondoo-client-abc123")) + assert.Equal(t, "my-config", integrationID("my-config")) +} diff --git a/pkg/utils/k8s/registry_wif.go b/pkg/utils/k8s/registry_wif.go new file mode 100644 index 000000000..3804243be --- /dev/null +++ b/pkg/utils/k8s/registry_wif.go @@ -0,0 +1,183 @@ +// Copyright Mondoo, Inc. 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package k8s + +import ( + "go.mondoo.com/mondoo-operator/api/v1alpha2" + "go.mondoo.com/mondoo-operator/pkg/constants" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/utils/ptr" +) + +// RegistryWIFInitContainer creates an init container that generates docker config credentials +// using cloud-native Workload Identity Federation for container registry authentication. +func RegistryWIFInitContainer(wif *v1alpha2.WorkloadIdentityConfig) corev1.Container { + var image, shell, script string + var env []corev1.EnvVar + + retryWrapper := `set -euo pipefail +# Retry wrapper for transient failures +retry() { + local max_attempts=3 + local delay=5 + local attempt=1 + while [ $attempt -le $max_attempts ]; do + if "$@"; then + return 0 + fi + echo "Attempt $attempt failed, retrying in ${delay}s..." + sleep $delay + attempt=$((attempt + 1)) + done + echo "All $max_attempts attempts failed" + return 1 +} +` + + switch wif.Provider { + case v1alpha2.CloudProviderGKE: + image = constants.GCloudSDKImage + shell = "/bin/bash" + script = retryWrapper + ` +# Use WIF identity to get an access token for Artifact Registry / GCR +TOKEN=$(retry gcloud auth print-access-token) +AUTH=$(echo -n "oauth2accesstoken:${TOKEN}" | base64 -w0) + +# All GCP regions and multi-region locations that host Artifact Registry. +# Docker config requires exact hostname matches, so we enumerate them all. +AR_LOCATIONS=" +africa-south1 asia-east1 asia-east2 asia-northeast1 asia-northeast2 asia-northeast3 +asia-south1 asia-south2 asia-southeast1 asia-southeast2 +australia-southeast1 australia-southeast2 +europe-central2 europe-north1 europe-southwest1 europe-west1 europe-west2 +europe-west3 europe-west4 europe-west6 europe-west8 europe-west9 europe-west10 europe-west12 +me-central1 me-central2 me-west1 +northamerica-northeast1 northamerica-northeast2 +southamerica-east1 southamerica-west1 +us-central1 us-east1 us-east4 us-east5 us-south1 us-west1 us-west2 us-west3 us-west4 +asia europe us +" + +AUTHS="" +add_auth() { + [ -n "$AUTHS" ] && AUTHS="${AUTHS}," + AUTHS="${AUTHS}\"$1\":{\"auth\":\"${AUTH}\"}" +} + +for loc in $AR_LOCATIONS; do + add_auth "${loc}-docker.pkg.dev" +done + +# Legacy GCR endpoints +for host in gcr.io us.gcr.io eu.gcr.io asia.gcr.io; do + add_auth "$host" +done + +cat > /etc/opt/mondoo/docker/config.json < /etc/opt/mondoo/docker/config.json < /etc/opt/mondoo/docker/config.json <