mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-19 09:54:16 +00:00
fix(azure): validate certificate key pairs
- Reject malformed, key-only, and mismatched certificate bundles - Support matching PEM and unencrypted PKCS#12 credentials - Cover SDK and API validation paths
This commit is contained in:
8 files changed
+420
-20
No files matched your search
@@ -208,9 +208,37 @@ class TestAzureProviderSecret:
|
||||
"client_id": "87654321-4321-4321-4321-210987654321",
|
||||
"tenant_id": "12345678-1234-1234-1234-123456789012",
|
||||
}
|
||||
# Valid base64 of a tiny DER-shaped payload; the serializer only checks
|
||||
# that the string decodes as base64, not that it parses as a real cert.
|
||||
CERT_CONTENT_B64 = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA"
|
||||
|
||||
@staticmethod
|
||||
def certificate_bundle():
|
||||
import base64
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Prowler")])
|
||||
certificate = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
.issuer_name(subject)
|
||||
.public_key(private_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(datetime.now(UTC))
|
||||
.not_valid_after(datetime.now(UTC) + timedelta(days=1))
|
||||
.sign(private_key, hashes.SHA256())
|
||||
)
|
||||
bundle = certificate.public_bytes(
|
||||
serialization.Encoding.PEM
|
||||
) + private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
return base64.b64encode(bundle).decode("ascii")
|
||||
|
||||
def test_accepts_client_secret_only(self):
|
||||
# Backwards-compatibility guard: rows saved by the previous serializer
|
||||
@@ -223,11 +251,12 @@ class TestAzureProviderSecret:
|
||||
assert "certificate_content" not in serializer.validated_data
|
||||
|
||||
def test_accepts_certificate_content_only(self):
|
||||
certificate_content = self.certificate_bundle()
|
||||
serializer = AzureProviderSecret(
|
||||
data={**self.BASE, "certificate_content": self.CERT_CONTENT_B64}
|
||||
data={**self.BASE, "certificate_content": certificate_content}
|
||||
)
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert serializer.validated_data["certificate_content"] == self.CERT_CONTENT_B64
|
||||
assert serializer.validated_data["certificate_content"] == certificate_content
|
||||
assert "client_secret" not in serializer.validated_data
|
||||
|
||||
def test_rejects_both_client_secret_and_certificate_content(self):
|
||||
@@ -238,7 +267,7 @@ class TestAzureProviderSecret:
|
||||
data={
|
||||
**self.BASE,
|
||||
"client_secret": "fake-client-secret",
|
||||
"certificate_content": self.CERT_CONTENT_B64,
|
||||
"certificate_content": self.certificate_bundle(),
|
||||
}
|
||||
)
|
||||
assert not serializer.is_valid()
|
||||
@@ -259,6 +288,41 @@ class TestAzureProviderSecret:
|
||||
assert not serializer.is_valid()
|
||||
assert "certificate_content" in serializer.errors
|
||||
|
||||
def test_rejects_invalid_tenant_and_client_ids(self):
|
||||
serializer = AzureProviderSecret(
|
||||
data={
|
||||
"tenant_id": "not-a-uuid",
|
||||
"client_id": "also-not-a-uuid",
|
||||
"client_secret": "fake-client-secret",
|
||||
}
|
||||
)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "tenant_id" in serializer.errors
|
||||
assert "client_id" in serializer.errors
|
||||
|
||||
def test_rejects_key_only_certificate_content(self):
|
||||
import base64
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
key_only_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
serializer = AzureProviderSecret(
|
||||
data={
|
||||
**self.BASE,
|
||||
"certificate_content": base64.b64encode(key_only_pem).decode("ascii"),
|
||||
}
|
||||
)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "certificate_content" in serializer.errors
|
||||
|
||||
def test_rejects_empty_strings_for_both(self):
|
||||
# DRF's CharField rejects "" at field-level before `validate()` runs.
|
||||
# The errors surface per-field rather than as non_field_errors, but
|
||||
|
||||
@@ -2,6 +2,7 @@ import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
import yaml
|
||||
from api.celery_utils import decode_celery_field
|
||||
@@ -60,7 +61,10 @@ from api.v1.serializer_utils.lighthouse import (
|
||||
)
|
||||
from api.v1.serializer_utils.processors import ProcessorConfigField
|
||||
from api.v1.serializer_utils.providers import ProviderSecretField
|
||||
from api.validators import validate_lighthouse_openai_compatible_base_url
|
||||
from api.validators import (
|
||||
validate_certificate_bundle,
|
||||
validate_lighthouse_openai_compatible_base_url,
|
||||
)
|
||||
from config.custom_logging import BackendLogger
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import authenticate
|
||||
@@ -1791,6 +1795,24 @@ class AzureProviderSecret(serializers.Serializer):
|
||||
tenant_id = serializers.CharField()
|
||||
certificate_content = serializers.CharField(required=False)
|
||||
|
||||
def validate_client_id(self, client_id):
|
||||
try:
|
||||
UUID(client_id)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise serializers.ValidationError(
|
||||
"Client ID must be a valid UUID."
|
||||
) from error
|
||||
return client_id
|
||||
|
||||
def validate_tenant_id(self, tenant_id):
|
||||
try:
|
||||
UUID(tenant_id)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise serializers.ValidationError(
|
||||
"Tenant ID must be a valid UUID."
|
||||
) from error
|
||||
return tenant_id
|
||||
|
||||
def validate(self, attrs):
|
||||
if attrs.get("client_secret") and attrs.get("certificate_content"):
|
||||
raise serializers.ValidationError(
|
||||
@@ -1803,10 +1825,11 @@ class AzureProviderSecret(serializers.Serializer):
|
||||
return super().validate(attrs)
|
||||
|
||||
def validate_certificate_content(self, certificate_content):
|
||||
"""Validate that Azure certificate content is valid base64 encoded data."""
|
||||
"""Validate the Azure certificate and matching private-key bundle."""
|
||||
if certificate_content:
|
||||
try:
|
||||
base64.b64decode(certificate_content, validate=True)
|
||||
certificate_data = base64.b64decode(certificate_content, validate=True)
|
||||
validate_certificate_bundle(certificate_data)
|
||||
except Exception as e:
|
||||
# Field validators are invoked per-field; DRF already knows
|
||||
# this error belongs to `certificate_content` and will nest
|
||||
@@ -1814,9 +1837,9 @@ class AzureProviderSecret(serializers.Serializer):
|
||||
# double-nest the JSON:API pointer as
|
||||
# `/certificate_content/certificate_content`.
|
||||
raise serializers.ValidationError(
|
||||
f"The provided certificate content is not valid base64 encoded data: {str(e)}",
|
||||
"Certificate content must be valid base64 containing an X.509 certificate and its matching private key.",
|
||||
code="azure-certificate-content",
|
||||
)
|
||||
) from e
|
||||
return certificate_content
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import ipaddress
|
||||
import re
|
||||
import socket
|
||||
import string
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -20,6 +25,45 @@ LIGHTHOUSE_BLOCKED_METADATA_HOSTS = frozenset(
|
||||
)
|
||||
|
||||
|
||||
def validate_certificate_bundle(certificate_data: bytes) -> None:
|
||||
"""Validate that certificate data contains a matching certificate and key."""
|
||||
try:
|
||||
private_key, certificate, _ = pkcs12.load_key_and_certificates(
|
||||
certificate_data, None, default_backend()
|
||||
)
|
||||
except ValueError:
|
||||
certificate_match = re.search(
|
||||
rb"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----",
|
||||
certificate_data,
|
||||
re.DOTALL,
|
||||
)
|
||||
private_key_match = re.search(
|
||||
rb"-----BEGIN (?:RSA |EC )?PRIVATE KEY-----.*?-----END (?:RSA |EC )?PRIVATE KEY-----",
|
||||
certificate_data,
|
||||
re.DOTALL,
|
||||
)
|
||||
if not certificate_match or not private_key_match:
|
||||
raise ValueError(
|
||||
"the payload must contain a certificate and its private key"
|
||||
)
|
||||
certificate = x509.load_pem_x509_certificate(
|
||||
certificate_match.group(), default_backend()
|
||||
)
|
||||
private_key = serialization.load_pem_private_key(
|
||||
private_key_match.group(), password=None, backend=default_backend()
|
||||
)
|
||||
|
||||
if certificate is None or private_key is None:
|
||||
raise ValueError("the payload must contain a certificate and its private key")
|
||||
|
||||
encoding = serialization.Encoding.DER
|
||||
public_format = serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
if certificate.public_key().public_bytes(
|
||||
encoding, public_format
|
||||
) != private_key.public_key().public_bytes(encoding, public_format):
|
||||
raise ValueError("the certificate does not match the private key")
|
||||
|
||||
|
||||
def _normalize_hostname(hostname: str) -> str:
|
||||
return hostname.rstrip(".").lower()
|
||||
|
||||
|
||||
Reference in new issue
Block a user