diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index b917037e21..478464231f 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -24,6 +24,7 @@ All notable changes to the **Prowler API** are documented in this file. ### 🔐 Security - User profile updates now allow users to update their own account while requiring user-management permissions to update other users in the same tenant [(#11792)](https://github.com/prowler-cloud/prowler/pull/11792) +- Kubernetes provider credentials now reject kubeconfigs using `exec` authentication in Prowler Cloud, preventing user-supplied commands from running on Cloud workers [(#11753)](https://github.com/prowler-cloud/prowler/pull/11753) --- @@ -44,6 +45,8 @@ All notable changes to the **Prowler API** are documented in this file. - Attack Paths: Provider graph cleanup now deletes Neo4j and Neptune relationships in directed batches before deleting nodes [(#11755)](https://github.com/prowler-cloud/prowler/pull/11755) - `scan-perform` no longer reports an error when a provider is deleted during a running scan [(#11696)](https://github.com/prowler-cloud/prowler/pull/11696) +--- + ## [1.32.1] (Prowler v5.31.1) ### 🐞 Fixed 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/tests/test_views.py b/api/src/backend/api/tests/test_views.py index b3a0f47e87..1348d068e2 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -2960,7 +2960,24 @@ class TestProviderSecretViewSet: Provider.ProviderChoices.KUBERNETES.value, ProviderSecret.TypeChoices.STATIC, { - "kubeconfig_content": "kubeconfig-content", + "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 +""", }, ), # M365 client secret credentials 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 a88f53fc7d..9cb9591b35 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/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx b/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx index 9ca791110f..81d482146e 100644 --- a/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx +++ b/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx @@ -23,6 +23,10 @@ title: 'Getting Started with Kubernetes' For Kubernetes, Prowler App uses a `kubeconfig` file to authenticate. Paste the contents of your `kubeconfig` file into the `Kubeconfig content` field. + +Kubeconfigs that use `users[].user.exec` authentication are not supported in Prowler Cloud/App. For security reasons, Prowler Cloud does not run commands declared by uploaded kubeconfigs. Use kubeconfig credentials that do not rely on `exec` authentication, such as the ServiceAccount token flow documented below. + + By default, the `kubeconfig` file is located at `~/.kube/config`. ![Kubernetes Credentials](/images/kubernetes-credentials.png) diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 08459d0c8f..388d6efa07 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -20,6 +20,10 @@ All notable changes to the **Prowler UI** are documented in this file. - Invitation callback paths are now preserved when invited users continue with Google, GitHub, or SAML authentication [(#11752)](https://github.com/prowler-cloud/prowler/pull/11752) +### 🔐 Security + +- Kubernetes provider credential forms now reject kubeconfigs using `exec` authentication in Prowler Cloud before submission [(#11753)](https://github.com/prowler-cloud/prowler/pull/11753) + --- ## [1.32.0] (Prowler v5.32.0) diff --git a/ui/components/providers/workflow/forms/via-credentials/k8s-credentials-form.tsx b/ui/components/providers/workflow/forms/via-credentials/k8s-credentials-form.tsx index 701bffcba5..37d0f9e47a 100644 --- a/ui/components/providers/workflow/forms/via-credentials/k8s-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/via-credentials/k8s-credentials-form.tsx @@ -1,13 +1,28 @@ +"use client"; + import { Control } from "react-hook-form"; +import { useWatch } from "react-hook-form"; import { WizardTextareaField } from "@/components/providers/workflow/forms/fields"; import { KubernetesCredentials } from "@/types"; +import { + KUBECONFIG_EXEC_AUTHENTICATION_ERROR, + kubeconfigContainsExecAuthentication, +} from "@/types/formSchemas"; export const KubernetesCredentialsForm = ({ control, }: { control: Control; }) => { + const kubeconfigContent = useWatch({ + control, + name: "kubeconfig_content", + }); + const hasExecAuthentication = kubeconfigContainsExecAuthentication( + kubeconfigContent ?? "", + ); + return ( <>
@@ -28,6 +43,11 @@ export const KubernetesCredentialsForm = ({ minRows={10} isRequired /> + {hasExecAuthentication && ( +

+ {KUBECONFIG_EXEC_AUTHENTICATION_ERROR} +

+ )} ); }; diff --git a/ui/types/formSchemas.test.ts b/ui/types/formSchemas.test.ts index e4fa3ea663..58eb33128f 100644 --- a/ui/types/formSchemas.test.ts +++ b/ui/types/formSchemas.test.ts @@ -150,3 +150,73 @@ 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], + }), + ); + }); + + it("accepts malformed kubeconfig content for backend validation", () => { + const schema = addCredentialsFormSchema("kubernetes"); + + const result = schema.safeParse({ + ...BASE_KUBERNETES_VALUES, + [ProviderCredentialFields.KUBECONFIG_CONTENT]: "apiVersion: [", + }); + + expect(result.success).toBe(true); + }); + + it("accepts non-mapping kubeconfig content for backend validation", () => { + const schema = addCredentialsFormSchema("kubernetes"); + + const result = schema.safeParse({ + ...BASE_KUBERNETES_VALUES, + [ProviderCredentialFields.KUBECONFIG_CONTENT]: "[]", + }); + + expect(result.success).toBe(true); + }); +}); diff --git a/ui/types/formSchemas.ts b/ui/types/formSchemas.ts index 81aa0889fa..bba1027585 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,35 @@ import { validateMutelistYaml, validateYaml } from "@/lib/yaml"; import { PROVIDER_TYPES, ProviderType } from "./providers"; +export 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); +}; + +export 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 +237,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" ? {