mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 12:31:54 +00:00
fix(kubernetes): reject exec auth in cloud kubeconfigs (#11753)
This commit is contained in:
committed by
GitHub
parent
6cae37174c
commit
ad04e69c35
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Note>
|
||||
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.
|
||||
</Note>
|
||||
|
||||
By default, the `kubeconfig` file is located at `~/.kube/config`.
|
||||
|
||||

|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<KubernetesCredentials>;
|
||||
}) => {
|
||||
const kubeconfigContent = useWatch({
|
||||
control,
|
||||
name: "kubeconfig_content",
|
||||
});
|
||||
const hasExecAuthentication = kubeconfigContainsExecAuthentication(
|
||||
kubeconfigContent ?? "",
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
@@ -28,6 +43,11 @@ export const KubernetesCredentialsForm = ({
|
||||
minRows={10}
|
||||
isRequired
|
||||
/>
|
||||
{hasExecAuthentication && (
|
||||
<p className="text-text-error-primary text-xs">
|
||||
{KUBECONFIG_EXEC_AUTHENTICATION_ERROR}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+37
-1
@@ -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<string, unknown> => {
|
||||
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"
|
||||
? {
|
||||
|
||||
Reference in New Issue
Block a user