diff --git a/api/src/backend/api/tests/test_serializers.py b/api/src/backend/api/tests/test_serializers.py index ea01075934..02559ac91c 100644 --- a/api/src/backend/api/tests/test_serializers.py +++ b/api/src/backend/api/tests/test_serializers.py @@ -1,6 +1,6 @@ import pytest from api.v1.serializer_utils.integrations import S3ConfigSerializer -from api.v1.serializers import ImageProviderSecret +from api.v1.serializers import ImageProviderSecret, KubernetesProviderSecret from rest_framework.exceptions import ValidationError @@ -132,3 +132,74 @@ class TestImageProviderSecret: serializer = ImageProviderSecret(data={"registry_password": "pass"}) assert not serializer.is_valid() assert "non_field_errors" in serializer.errors + + +class TestKubernetesProviderSecret: + def test_valid_static_kubeconfig_is_accepted(self): + kubeconfig_content = """ +apiVersion: v1 +kind: Config +clusters: + - name: test-cluster + cluster: + server: https://kubernetes.example.test +users: + - name: test-user + user: + token: test-token +contexts: + - name: test-context + context: + cluster: test-cluster + user: test-user +current-context: test-context +""" + + serializer = KubernetesProviderSecret( + data={"kubeconfig_content": kubeconfig_content} + ) + + assert serializer.is_valid() + + def test_kubeconfig_with_exec_authentication_is_rejected(self): + kubeconfig_content = """ +apiVersion: v1 +kind: Config +clusters: + - name: test-cluster + cluster: + server: https://kubernetes.example.test +users: + - name: test-user + user: + exec: + apiVersion: client.authentication.k8s.io/v1 + command: kubectl +contexts: + - name: test-context + context: + cluster: test-cluster + user: test-user +current-context: test-context +""" + + serializer = KubernetesProviderSecret( + data={"kubeconfig_content": kubeconfig_content} + ) + + assert not serializer.is_valid() + assert "kubeconfig_content" in serializer.errors + + def test_malformed_kubeconfig_is_rejected(self): + serializer = KubernetesProviderSecret( + data={"kubeconfig_content": "apiVersion: ["} + ) + + assert not serializer.is_valid() + assert "kubeconfig_content" in serializer.errors + + def test_non_mapping_kubeconfig_is_rejected(self): + serializer = KubernetesProviderSecret(data={"kubeconfig_content": "[]"}) + + assert not serializer.is_valid() + assert "kubeconfig_content" in serializer.errors diff --git a/api/src/backend/api/v1/serializer_utils/providers.py b/api/src/backend/api/v1/serializer_utils/providers.py index 0b8b4eacf4..50c1c7376c 100644 --- a/api/src/backend/api/v1/serializer_utils/providers.py +++ b/api/src/backend/api/v1/serializer_utils/providers.py @@ -213,7 +213,8 @@ from rest_framework_json_api import serializers "properties": { "kubeconfig_content": { "type": "string", - "description": "The content of the Kubernetes kubeconfig file, encoded as a string.", + "description": "The content of the Kubernetes kubeconfig file, encoded as a string. " + "Kubeconfig exec authentication is not supported in Prowler Cloud for security reasons.", } }, "required": ["kubeconfig_content"], diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 1d160b4048..774da0aaac 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -2,6 +2,7 @@ import base64 import json from datetime import UTC, datetime, timedelta +import yaml from api.db_router import MainRouter from api.exceptions import ConflictException from api.models import ( @@ -1530,6 +1531,32 @@ class FindingMetadataSerializer(BaseSerializerV1): # Provider secrets +KUBERNETES_KUBECONFIG_EXEC_ERROR = ( + "Kubernetes kubeconfig exec authentication is not supported in Prowler Cloud " + "for security reasons." +) +KUBERNETES_KUBECONFIG_INVALID_ERROR = "Invalid Kubernetes kubeconfig content." + + +def kubeconfig_contains_exec_auth(kubeconfig: dict) -> bool: + users = kubeconfig.get("users", []) + if not isinstance(users, list): + raise ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR) + + for user_entry in users: + if not isinstance(user_entry, dict): + raise ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR) + + user = user_entry.get("user", {}) + if not isinstance(user, dict): + raise ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR) + + if "exec" in user: + return True + + return False + + class BaseWriteProviderSecretSerializer(BaseWriteSerializer): @staticmethod def validate_secret_based_on_provider( @@ -1711,6 +1738,22 @@ class MongoDBAtlasProviderSecret(serializers.Serializer): class KubernetesProviderSecret(serializers.Serializer): kubeconfig_content = serializers.CharField() + def validate_kubeconfig_content(self, kubeconfig_content): + try: + kubeconfig = yaml.safe_load(kubeconfig_content) + except yaml.YAMLError as exc: + raise serializers.ValidationError( + KUBERNETES_KUBECONFIG_INVALID_ERROR + ) from exc + + if not isinstance(kubeconfig, dict): + raise serializers.ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR) + + if kubeconfig_contains_exec_auth(kubeconfig): + raise serializers.ValidationError(KUBERNETES_KUBECONFIG_EXEC_ERROR) + + return kubeconfig_content + class Meta: resource_name = "provider-secrets" diff --git a/ui/types/formSchemas.test.ts b/ui/types/formSchemas.test.ts index e4fa3ea663..c2e5be53bb 100644 --- a/ui/types/formSchemas.test.ts +++ b/ui/types/formSchemas.test.ts @@ -150,3 +150,51 @@ describe("addCredentialsFormSchema - okta", () => { ); }); }); + +describe("addCredentialsFormSchema - kubernetes", () => { + const BASE_KUBERNETES_VALUES = { + [ProviderCredentialFields.PROVIDER_ID]: "provider-kubernetes-1", + [ProviderCredentialFields.PROVIDER_TYPE]: "kubernetes", + } as const; + + it("accepts kubeconfig content without exec authentication", () => { + const schema = addCredentialsFormSchema("kubernetes"); + + const result = schema.safeParse({ + ...BASE_KUBERNETES_VALUES, + [ProviderCredentialFields.KUBECONFIG_CONTENT]: `apiVersion: v1 +kind: Config +users: + - name: test-user + user: + token: test-token`, + }); + + expect(result.success).toBe(true); + }); + + it("reports kubeconfig exec authentication on kubeconfig_content field", () => { + const schema = addCredentialsFormSchema("kubernetes"); + + const result = schema.safeParse({ + ...BASE_KUBERNETES_VALUES, + [ProviderCredentialFields.KUBECONFIG_CONTENT]: `apiVersion: v1 +kind: Config +users: + - name: test-user + user: + exec: + apiVersion: client.authentication.k8s.io/v1 + command: kubectl`, + }); + + expect(result.success).toBe(false); + if (result.success) return; + + expect(result.error.issues).toContainEqual( + expect.objectContaining({ + path: [ProviderCredentialFields.KUBECONFIG_CONTENT], + }), + ); + }); +}); diff --git a/ui/types/formSchemas.ts b/ui/types/formSchemas.ts index 15751c565c..11f3c10faf 100644 --- a/ui/types/formSchemas.ts +++ b/ui/types/formSchemas.ts @@ -1,3 +1,4 @@ +import yaml from "js-yaml"; import { z } from "zod"; import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; @@ -5,6 +6,33 @@ import { validateMutelistYaml, validateYaml } from "@/lib/yaml"; import { PROVIDER_TYPES, ProviderType } from "./providers"; +const KUBECONFIG_EXEC_AUTHENTICATION_ERROR = + "Kubernetes kubeconfig exec authentication is not supported in Prowler Cloud for security reasons."; + +const isRecord = (value: unknown): value is Record => { + return typeof value === "object" && value !== null && !Array.isArray(value); +}; + +const kubeconfigContainsExecAuthentication = (value: string): boolean => { + try { + const parsed = yaml.load(value); + + if (!isRecord(parsed) || !Array.isArray(parsed.users)) { + return false; + } + + return parsed.users.some((userEntry) => { + if (!isRecord(userEntry) || !isRecord(userEntry.user)) { + return false; + } + + return "exec" in userEntry.user; + }); + } catch { + return false; + } +}; + export const addRoleFormSchema = z.object({ name: z.string().min(1, "Name is required"), manage_users: z.boolean().default(false), @@ -207,7 +235,13 @@ export const addCredentialsFormSchema = ( ? { [ProviderCredentialFields.KUBECONFIG_CONTENT]: z .string() - .min(1, "Kubeconfig Content is required"), + .min(1, "Kubeconfig Content is required") + .refine( + (value) => !kubeconfigContainsExecAuthentication(value), + { + error: KUBECONFIG_EXEC_AUTHENTICATION_ERROR, + }, + ), } : providerType === "m365" ? {