From 06e8532f88616a988a4e41ed8cdac62cf0f243a5 Mon Sep 17 00:00:00 2001
From: Alex Jones <alexsimonjones@gmail.com>
Date: Fri, 14 Jul 2023 10:44:15 +0100
Subject: [PATCH] feat: admission webhooks (#553)

* feat: add Validating/Mutating webhook analyzer

Signed-off-by: Rakshit Gondwal <rakshitgondwal3@gmail.com>

* change conditions

Signed-off-by: Rakshit Gondwal <rakshitgondwal3@gmail.com>

* fix: use GetClient to get pods and mask pod name

Signed-off-by: Rakshit Gondwal <rakshitgondwal3@gmail.com>

* fix: add new cases in util.GetParent

Signed-off-by: Rakshit Gondwal <rakshitgondwal3@gmail.com>

* feat: implements webhooks

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>

* feat: implements webhooks

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>

* feat: implements webhooks

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>

* feat: implements webhooks

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>

---------

Signed-off-by: Rakshit Gondwal <rakshitgondwal3@gmail.com>
Signed-off-by: Alex Jones <alexsimonjones@gmail.com>
Co-authored-by: Rakshit Gondwal <rakshitgondwal3@gmail.com>
Co-authored-by: Aris Boutselis <aris.boutselis@senseon.io>
---
 pkg/analyzer/analyzer.go           |  20 ++--
 pkg/analyzer/mutating_webhook.go   | 148 +++++++++++++++++++++++++++++
 pkg/analyzer/validating_webhook.go | 147 ++++++++++++++++++++++++++++
 pkg/common/types.go                |   3 +
 pkg/util/util.go                   |  28 ++++++
 5 files changed, 337 insertions(+), 9 deletions(-)
 create mode 100644 pkg/analyzer/mutating_webhook.go
 create mode 100644 pkg/analyzer/validating_webhook.go

diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go
index a275bb02ca..cd67d929e0 100644
--- a/pkg/analyzer/analyzer.go
+++ b/pkg/analyzer/analyzer.go
@@ -32,15 +32,17 @@ var (
 )
 
 var coreAnalyzerMap = map[string]common.IAnalyzer{
-	"Pod":                   PodAnalyzer{},
-	"Deployment":            DeploymentAnalyzer{},
-	"ReplicaSet":            ReplicaSetAnalyzer{},
-	"PersistentVolumeClaim": PvcAnalyzer{},
-	"Service":               ServiceAnalyzer{},
-	"Ingress":               IngressAnalyzer{},
-	"StatefulSet":           StatefulSetAnalyzer{},
-	"CronJob":               CronJobAnalyzer{},
-	"Node":                  NodeAnalyzer{},
+	"Pod":                            PodAnalyzer{},
+	"Deployment":                     DeploymentAnalyzer{},
+	"ReplicaSet":                     ReplicaSetAnalyzer{},
+	"PersistentVolumeClaim":          PvcAnalyzer{},
+	"Service":                        ServiceAnalyzer{},
+	"Ingress":                        IngressAnalyzer{},
+	"StatefulSet":                    StatefulSetAnalyzer{},
+	"CronJob":                        CronJobAnalyzer{},
+	"Node":                           NodeAnalyzer{},
+	"ValidatingWebhookConfiguration": ValidatingWebhookAnalyzer{},
+	"MutatingWebhookConfiguration":   MutatingWebhookAnalyzer{},
 }
 
 var additionalAnalyzerMap = map[string]common.IAnalyzer{
diff --git a/pkg/analyzer/mutating_webhook.go b/pkg/analyzer/mutating_webhook.go
new file mode 100644
index 0000000000..b5e7d224fb
--- /dev/null
+++ b/pkg/analyzer/mutating_webhook.go
@@ -0,0 +1,148 @@
+/*
+Copyright 2023 The K8sGPT Authors.
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+    http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package analyzer
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/k8sgpt-ai/k8sgpt/pkg/common"
+	"github.com/k8sgpt-ai/k8sgpt/pkg/kubernetes"
+	"github.com/k8sgpt-ai/k8sgpt/pkg/util"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+)
+
+type MutatingWebhookAnalyzer struct{}
+
+func (MutatingWebhookAnalyzer) Analyze(a common.Analyzer) ([]common.Result, error) {
+
+	kind := "MutatingWebhookConfiguration"
+	apiDoc := kubernetes.K8sApiReference{
+		Kind: kind,
+		ApiVersion: schema.GroupVersion{
+			Group:   "apps",
+			Version: "v1",
+		},
+		OpenapiSchema: a.OpenapiSchema,
+	}
+
+	AnalyzerErrorsMetric.DeletePartialMatch(map[string]string{
+		"analyzer_name": kind,
+	})
+
+	mutatingWebhooks, err := a.Client.GetClient().AdmissionregistrationV1().MutatingWebhookConfigurations().List(context.Background(), v1.ListOptions{})
+	if err != nil {
+		return nil, err
+	}
+
+	var preAnalysis = map[string]common.PreAnalysis{}
+
+	for _, webhookConfig := range mutatingWebhooks.Items {
+		for _, webhook := range webhookConfig.Webhooks {
+			var failures []common.Failure
+
+			svc := webhook.ClientConfig.Service
+			// Get the service
+			service, err := a.Client.GetClient().CoreV1().Services(svc.Namespace).Get(context.Background(), svc.Name, v1.GetOptions{})
+			if err != nil {
+				// If the service is not found, we can't check the pods
+				failures = append(failures, common.Failure{
+					Text:          fmt.Sprintf("Service %s not found as mapped to by Mutating Webhook %s", svc.Name, webhook.Name),
+					KubernetesDoc: apiDoc.GetApiDocV2("spec.webhook.clientConfig.service"),
+					Sensitive: []common.Sensitive{
+						{
+							Unmasked: webhookConfig.Namespace,
+							Masked:   util.MaskString(webhookConfig.Namespace),
+						},
+						{
+							Unmasked: svc.Name,
+							Masked:   util.MaskString(svc.Name),
+						},
+					},
+				})
+				continue
+			}
+
+			// Get pods within service
+			pods, err := a.Client.GetClient().CoreV1().Pods(svc.Namespace).List(context.Background(), v1.ListOptions{
+				LabelSelector: util.MapToString(service.Spec.Selector),
+			})
+			if err != nil {
+				return nil, err
+			}
+
+			if len(pods.Items) == 0 {
+				failures = append(failures, common.Failure{
+					Text:          fmt.Sprintf("No active pods found within service %s as mapped to by Mutating Webhook %s", svc.Name, webhook.Name),
+					KubernetesDoc: apiDoc.GetApiDocV2("spec.webhook.clientConfig.service"),
+					Sensitive: []common.Sensitive{
+						{
+							Unmasked: webhookConfig.Namespace,
+							Masked:   util.MaskString(webhookConfig.Namespace),
+						},
+					},
+				})
+
+			}
+			for _, pod := range pods.Items {
+				if pod.Status.Phase != "Running" {
+					doc := apiDoc.GetApiDocV2("spec.webhook")
+					failures = append(failures, common.Failure{
+						Text: fmt.Sprintf(
+							"Mutating Webhook (%s) is pointing to an inactive receiver pod (%s)",
+							webhook.Name,
+							pod.Name,
+						),
+						KubernetesDoc: doc,
+						Sensitive: []common.Sensitive{
+							{
+								Unmasked: webhookConfig.Namespace,
+								Masked:   util.MaskString(webhookConfig.Namespace),
+							},
+							{
+								Unmasked: webhook.Name,
+								Masked:   util.MaskString(webhook.Name),
+							},
+							{
+								Unmasked: pod.Name,
+								Masked:   util.MaskString(pod.Name),
+							},
+						},
+					})
+				}
+			}
+			if len(failures) > 0 {
+				preAnalysis[fmt.Sprintf("%s/%s", webhookConfig.Namespace, webhook.Name)] = common.PreAnalysis{
+					MutatingWebhook: webhookConfig,
+					FailureDetails:  failures,
+				}
+				AnalyzerErrorsMetric.WithLabelValues(kind, webhook.Name, webhookConfig.Namespace).Set(float64(len(failures)))
+			}
+		}
+	}
+	for key, value := range preAnalysis {
+		var currentAnalysis = common.Result{
+			Kind:  kind,
+			Name:  key,
+			Error: value.FailureDetails,
+		}
+
+		parent, _ := util.GetParent(a.Client, value.MutatingWebhook.ObjectMeta)
+		currentAnalysis.ParentObject = parent
+		a.Results = append(a.Results, currentAnalysis)
+	}
+
+	return a.Results, nil
+}
diff --git a/pkg/analyzer/validating_webhook.go b/pkg/analyzer/validating_webhook.go
new file mode 100644
index 0000000000..45855e5067
--- /dev/null
+++ b/pkg/analyzer/validating_webhook.go
@@ -0,0 +1,147 @@
+/*
+Copyright 2023 The K8sGPT Authors.
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+    http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package analyzer
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/k8sgpt-ai/k8sgpt/pkg/common"
+	"github.com/k8sgpt-ai/k8sgpt/pkg/kubernetes"
+	"github.com/k8sgpt-ai/k8sgpt/pkg/util"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+)
+
+type ValidatingWebhookAnalyzer struct{}
+
+func (ValidatingWebhookAnalyzer) Analyze(a common.Analyzer) ([]common.Result, error) {
+
+	kind := "ValidatingWebhookConfgiguration"
+	apiDoc := kubernetes.K8sApiReference{
+		Kind: kind,
+		ApiVersion: schema.GroupVersion{
+			Group:   "apps",
+			Version: "v1",
+		},
+		OpenapiSchema: a.OpenapiSchema,
+	}
+
+	AnalyzerErrorsMetric.DeletePartialMatch(map[string]string{
+		"analyzer_name": kind,
+	})
+
+	validatingWebhooks, err := a.Client.GetClient().AdmissionregistrationV1().ValidatingWebhookConfigurations().List(context.Background(), v1.ListOptions{})
+	if err != nil {
+		return nil, err
+	}
+	var preAnalysis = map[string]common.PreAnalysis{}
+
+	for _, webhookConfig := range validatingWebhooks.Items {
+		for _, webhook := range webhookConfig.Webhooks {
+			var failures []common.Failure
+
+			svc := webhook.ClientConfig.Service
+			// Get the service
+			service, err := a.Client.GetClient().CoreV1().Services(svc.Namespace).Get(context.Background(), svc.Name, v1.GetOptions{})
+			if err != nil {
+				// If the service is not found, we can't check the pods
+				failures = append(failures, common.Failure{
+					Text:          fmt.Sprintf("Service %s not found as mapped to by Validating Webhook %s", svc.Name, webhook.Name),
+					KubernetesDoc: apiDoc.GetApiDocV2("spec.webhook.clientConfig.service"),
+					Sensitive: []common.Sensitive{
+						{
+							Unmasked: webhookConfig.Namespace,
+							Masked:   util.MaskString(webhookConfig.Namespace),
+						},
+						{
+							Unmasked: svc.Name,
+							Masked:   util.MaskString(svc.Name),
+						},
+					},
+				})
+				continue
+			}
+
+			// Get pods within service
+			pods, err := a.Client.GetClient().CoreV1().Pods(svc.Namespace).List(context.Background(), v1.ListOptions{
+				LabelSelector: util.MapToString(service.Spec.Selector),
+			})
+			if err != nil {
+				return nil, err
+			}
+
+			if len(pods.Items) == 0 {
+				failures = append(failures, common.Failure{
+					Text:          fmt.Sprintf("No active pods found within service %s as mapped to by Validating Webhook %s", svc.Name, webhook.Name),
+					KubernetesDoc: apiDoc.GetApiDocV2("spec.webhook.clientConfig.service"),
+					Sensitive: []common.Sensitive{
+						{
+							Unmasked: webhookConfig.Namespace,
+							Masked:   util.MaskString(webhookConfig.Namespace),
+						},
+					},
+				})
+
+			}
+			for _, pod := range pods.Items {
+				if pod.Status.Phase != "Running" {
+					doc := apiDoc.GetApiDocV2("spec.webhook")
+					failures = append(failures, common.Failure{
+						Text: fmt.Sprintf(
+							"Validating Webhook (%s) is pointing to an inactive receiver pod (%s)",
+							webhook.Name,
+							pod.Name,
+						),
+						KubernetesDoc: doc,
+						Sensitive: []common.Sensitive{
+							{
+								Unmasked: webhookConfig.Namespace,
+								Masked:   util.MaskString(webhookConfig.Namespace),
+							},
+							{
+								Unmasked: webhook.Name,
+								Masked:   util.MaskString(webhook.Name),
+							},
+							{
+								Unmasked: pod.Name,
+								Masked:   util.MaskString(pod.Name),
+							},
+						},
+					})
+				}
+			}
+			if len(failures) > 0 {
+				preAnalysis[fmt.Sprintf("%s/%s", webhookConfig.Namespace, webhook.Name)] = common.PreAnalysis{
+					ValidatingWebhook: webhookConfig,
+					FailureDetails:    failures,
+				}
+				AnalyzerErrorsMetric.WithLabelValues(kind, webhook.Name, webhookConfig.Namespace).Set(float64(len(failures)))
+			}
+		}
+	}
+	for key, value := range preAnalysis {
+		var currentAnalysis = common.Result{
+			Kind:  kind,
+			Name:  key,
+			Error: value.FailureDetails,
+		}
+
+		parent, _ := util.GetParent(a.Client, value.ValidatingWebhook.ObjectMeta)
+		currentAnalysis.ParentObject = parent
+		a.Results = append(a.Results, currentAnalysis)
+	}
+
+	return a.Results, nil
+}
diff --git a/pkg/common/types.go b/pkg/common/types.go
index 35bd539442..1ec0e04f03 100644
--- a/pkg/common/types.go
+++ b/pkg/common/types.go
@@ -20,6 +20,7 @@ import (
 	openapi_v2 "github.com/google/gnostic/openapiv2"
 	"github.com/k8sgpt-ai/k8sgpt/pkg/ai"
 	"github.com/k8sgpt-ai/k8sgpt/pkg/kubernetes"
+	regv1 "k8s.io/api/admissionregistration/v1"
 	appsv1 "k8s.io/api/apps/v1"
 	autov1 "k8s.io/api/autoscaling/v1"
 	v1 "k8s.io/api/core/v1"
@@ -54,6 +55,8 @@ type PreAnalysis struct {
 	StatefulSet              appsv1.StatefulSet
 	NetworkPolicy            networkv1.NetworkPolicy
 	Node                     v1.Node
+	ValidatingWebhook        regv1.ValidatingWebhookConfiguration
+	MutatingWebhook          regv1.MutatingWebhookConfiguration
 	// Integrations
 	TrivyVulnerabilityReport trivy.VulnerabilityReport
 }
diff --git a/pkg/util/util.go b/pkg/util/util.go
index d6b2f975ea..317a7de51e 100644
--- a/pkg/util/util.go
+++ b/pkg/util/util.go
@@ -94,6 +94,26 @@ func GetParent(client *kubernetes.Client, meta metav1.ObjectMeta) (string, bool)
 					return GetParent(client, ds.ObjectMeta)
 				}
 				return "Ingress/" + ds.Name, false
+
+			case "MutatingWebhookConfiguration":
+				mw, err := client.GetClient().AdmissionregistrationV1().MutatingWebhookConfigurations().Get(context.Background(), owner.Name, metav1.GetOptions{})
+				if err != nil {
+					return "", false
+				}
+				if mw.OwnerReferences != nil {
+					return GetParent(client, mw.ObjectMeta)
+				}
+				return "MutatingWebhook/" + mw.Name, false
+
+			case "ValidatingWebhookConfiguration":
+				vw, err := client.GetClient().AdmissionregistrationV1().ValidatingWebhookConfigurations().Get(context.Background(), owner.Name, metav1.GetOptions{})
+				if err != nil {
+					return "", false
+				}
+				if vw.OwnerReferences != nil {
+					return GetParent(client, vw.ObjectMeta)
+				}
+				return "ValidatingWebhook/" + vw.Name, false
 			}
 		}
 	}
@@ -191,3 +211,11 @@ func EnsureDirExists(dir string) error {
 
 	return err
 }
+
+func MapToString(m map[string]string) string {
+	var result string
+	for k, v := range m {
+		result += fmt.Sprintf("%s=%s,", k, v)
+	}
+	return result[:len(result)-1]
+}