mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-26 13:24:20 +00:00
fix(api): persist per-provider secret normalization and drop M365 scope creep
This commit is contained in:
1 parent
ef62731c0a
commit
721a80921b
4 files changed
+174
-31
No files matched your search
@@ -21369,9 +21369,8 @@ components:
|
||||
where the application is registered.
|
||||
certificate_content:
|
||||
type: string
|
||||
description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate
|
||||
and its matching private key for certificate-based authentication.
|
||||
maxLength: 68268
|
||||
description: The certificate content in base64 format for certificate-based
|
||||
authentication.
|
||||
required:
|
||||
- client_id
|
||||
- tenant_id
|
||||
@@ -23520,9 +23519,8 @@ components:
|
||||
the application is registered.
|
||||
certificate_content:
|
||||
type: string
|
||||
description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate
|
||||
and its matching private key for certificate-based authentication.
|
||||
maxLength: 68268
|
||||
description: The certificate content in base64 format for certificate-based
|
||||
authentication.
|
||||
required:
|
||||
- client_id
|
||||
- tenant_id
|
||||
@@ -23987,9 +23985,8 @@ components:
|
||||
where the application is registered.
|
||||
certificate_content:
|
||||
type: string
|
||||
description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate
|
||||
and its matching private key for certificate-based authentication.
|
||||
maxLength: 68268
|
||||
description: The certificate content in base64 format for certificate-based
|
||||
authentication.
|
||||
required:
|
||||
- client_id
|
||||
- tenant_id
|
||||
@@ -24476,9 +24473,8 @@ components:
|
||||
the application is registered.
|
||||
certificate_content:
|
||||
type: string
|
||||
description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate
|
||||
and its matching private key for certificate-based authentication.
|
||||
maxLength: 68268
|
||||
description: The certificate content in base64 format for certificate-based
|
||||
authentication.
|
||||
required:
|
||||
- client_id
|
||||
- tenant_id
|
||||
|
||||
@@ -3444,6 +3444,153 @@ current-context: test-context
|
||||
provider_secret.refresh_from_db()
|
||||
assert "region" not in provider_secret.secret
|
||||
|
||||
@staticmethod
|
||||
def _azure_certificate_bundle_base64():
|
||||
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_provider_secrets_create_azure_persists_whitespace_stripped_certificate(
|
||||
self,
|
||||
authenticated_client,
|
||||
azure_provider,
|
||||
):
|
||||
# `AzureProviderSecret.validate_certificate_content` strips whitespace
|
||||
# inside the base64 payload. The outer write path must reassign the
|
||||
# validator's normalized output so the value that reaches Fernet
|
||||
# storage matches what the SDK will later decode.
|
||||
clean_b64 = self._azure_certificate_bundle_base64()
|
||||
wrapped_b64 = (
|
||||
" "
|
||||
+ "\r\n".join(clean_b64[i : i + 64] for i in range(0, len(clean_b64), 64))
|
||||
+ " "
|
||||
)
|
||||
|
||||
data = {
|
||||
"data": {
|
||||
"type": "provider-secrets",
|
||||
"attributes": {
|
||||
"name": "Azure Cert Secret",
|
||||
"secret_type": ProviderSecret.TypeChoices.STATIC,
|
||||
"secret": {
|
||||
"client_id": "87654321-4321-4321-4321-210987654321",
|
||||
"tenant_id": "12345678-1234-1234-1234-123456789012",
|
||||
"certificate_content": wrapped_b64,
|
||||
},
|
||||
},
|
||||
"relationships": {
|
||||
"provider": {
|
||||
"data": {"type": "providers", "id": str(azure_provider.id)}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
reverse("providersecret-list"),
|
||||
data=json.dumps(data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED, response.content
|
||||
provider_secret = ProviderSecret.objects.get(id=response.json()["data"]["id"])
|
||||
# Persisted value is the whitespace-stripped payload — proves the
|
||||
# per-provider validator's return value is what Fernet encrypted,
|
||||
# not the raw upload.
|
||||
assert provider_secret.secret["certificate_content"] == clean_b64
|
||||
|
||||
def test_provider_secrets_update_azure_persists_whitespace_stripped_certificate(
|
||||
self,
|
||||
authenticated_client,
|
||||
azure_provider,
|
||||
):
|
||||
create_response = authenticated_client.post(
|
||||
reverse("providersecret-list"),
|
||||
data=json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"type": "provider-secrets",
|
||||
"attributes": {
|
||||
"name": "Azure Cert Secret",
|
||||
"secret_type": ProviderSecret.TypeChoices.STATIC,
|
||||
"secret": {
|
||||
"client_id": "87654321-4321-4321-4321-210987654321",
|
||||
"tenant_id": "12345678-1234-1234-1234-123456789012",
|
||||
"certificate_content": self._azure_certificate_bundle_base64(),
|
||||
},
|
||||
},
|
||||
"relationships": {
|
||||
"provider": {
|
||||
"data": {
|
||||
"type": "providers",
|
||||
"id": str(azure_provider.id),
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
assert create_response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
provider_secret = ProviderSecret.objects.get(
|
||||
id=create_response.json()["data"]["id"]
|
||||
)
|
||||
|
||||
clean_b64 = self._azure_certificate_bundle_base64()
|
||||
wrapped_b64 = "\r\n".join(
|
||||
clean_b64[i : i + 64] for i in range(0, len(clean_b64), 64)
|
||||
)
|
||||
response = authenticated_client.patch(
|
||||
reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
|
||||
data=json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"type": "provider-secrets",
|
||||
"id": str(provider_secret.id),
|
||||
"attributes": {
|
||||
"secret": {
|
||||
"client_id": "87654321-4321-4321-4321-210987654321",
|
||||
"tenant_id": "12345678-1234-1234-1234-123456789012",
|
||||
"certificate_content": wrapped_b64,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK, response.content
|
||||
provider_secret.refresh_from_db()
|
||||
assert provider_secret.secret["certificate_content"] == clean_b64
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"attributes, error_code, error_pointer",
|
||||
(
|
||||
|
||||
@@ -169,7 +169,7 @@ from rest_framework_json_api import serializers
|
||||
},
|
||||
"certificate_content": {
|
||||
"type": "string",
|
||||
"description": "Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate and its matching private key for certificate-based authentication.",
|
||||
"description": "The certificate content in base64 format for certificate-based authentication.",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -1871,20 +1871,16 @@ class M365ProviderSecret(serializers.Serializer):
|
||||
tenant_id = serializers.CharField()
|
||||
user = serializers.EmailField(required=False)
|
||||
password = serializers.CharField(required=False)
|
||||
certificate_content = serializers.CharField(
|
||||
required=False, max_length=_MAX_CERTIFICATE_CONTENT_LENGTH
|
||||
)
|
||||
certificate_content = serializers.CharField(required=False)
|
||||
|
||||
def validate(self, attrs):
|
||||
if attrs.get("client_secret") and attrs.get("certificate_content"):
|
||||
raise serializers.ValidationError(
|
||||
"You cannot provide both client_secret and certificate_content.",
|
||||
code="m365-credential-mutex",
|
||||
"You cannot provide both client_secret and certificate_content."
|
||||
)
|
||||
if not attrs.get("client_secret") and not attrs.get("certificate_content"):
|
||||
raise serializers.ValidationError(
|
||||
"You must provide either client_secret or certificate_content.",
|
||||
code="m365-credential-required",
|
||||
"You must provide either client_secret or certificate_content."
|
||||
)
|
||||
return super().validate(attrs)
|
||||
|
||||
@@ -1893,13 +1889,13 @@ class M365ProviderSecret(serializers.Serializer):
|
||||
if certificate_content:
|
||||
try:
|
||||
base64.b64decode(certificate_content, validate=True)
|
||||
# `base64.b64decode(validate=True)` raises `binascii.Error`; the
|
||||
# legacy alias `ValueError` is preserved for older builds.
|
||||
except (binascii.Error, ValueError) as e:
|
||||
# DRF field validators are already keyed to `certificate_content`,
|
||||
# so raising a dict here would double-nest the JSON:API pointer.
|
||||
raise serializers.ValidationError(
|
||||
f"The provided certificate content is not valid base64 encoded data: {str(e)}",
|
||||
except Exception as e:
|
||||
raise ValidationError(
|
||||
{
|
||||
"certificate_content": [
|
||||
f"The provided certificate content is not valid base64 encoded data: {str(e)}"
|
||||
]
|
||||
},
|
||||
code="m365-certificate-content",
|
||||
)
|
||||
return certificate_content
|
||||
@@ -2167,8 +2163,11 @@ class ProviderSecretCreateSerializer(
|
||||
validated_secret = self.validate_secret_based_on_provider(
|
||||
provider.provider, secret_type, secret
|
||||
)
|
||||
if provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
|
||||
validated_attrs["secret"] = validated_secret
|
||||
# Persist the per-provider validator's normalized output (e.g.
|
||||
# `AzureProviderSecret` strips whitespace inside `certificate_content`).
|
||||
# Previously this reassignment only happened for OCI, so every
|
||||
# per-provider validator's normalization was discarded on write.
|
||||
validated_attrs["secret"] = validated_secret
|
||||
return validated_attrs
|
||||
|
||||
|
||||
@@ -2203,8 +2202,9 @@ class ProviderSecretUpdateSerializer(BaseWriteProviderSecretSerializer):
|
||||
validated_secret = self.validate_secret_based_on_provider(
|
||||
provider.provider, secret_type, secret
|
||||
)
|
||||
if provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
|
||||
validated_attrs["secret"] = validated_secret
|
||||
# Persist the per-provider validator's normalized output — same
|
||||
# rationale as `ProviderSecretCreateSerializer.validate`.
|
||||
validated_attrs["secret"] = validated_secret
|
||||
return validated_attrs
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user