From a47d94954ac683f0f1cf07d1a07ad4d1b8e7b9bb Mon Sep 17 00:00:00 2001 From: Lydia Vilchez Date: Tue, 11 Aug 2026 17:17:37 +0200 Subject: [PATCH] feat(azure): add Deploy-to-Azure Bicep template and certificate auth --- api/src/backend/api/v1/serializers.py | 23 +++--- .../providers/azure/authentication.mdx | 16 ++-- .../providers/azure/getting-started-azure.mdx | 4 +- permissions/templates/azure/bicep/README.md | 31 +++++--- .../templates/azure/bicep/bicepconfig.json | 2 +- .../templates/azure/bicep/prowler-scan.bicep | 7 +- .../templates/azure/bicep/prowler-scan.json | 8 +- prowler/providers/azure/azure_provider.py | 74 +++++++++++++------ prowler/providers/azure/models.py | 7 -- tests/providers/azure/azure_provider_test.py | 74 +++++++++++++++++++ .../azure-certificate-credentials-form.tsx | 9 ++- ...-group-azure-via-credentials-type-form.tsx | 10 ++- .../azure/select-via-azure.tsx | 12 ++- ui/lib/azure-cert-generator.test.ts | 2 +- ui/lib/azure-cert-generator.ts | 13 ++-- ui/lib/external-urls.ts | 6 +- 16 files changed, 206 insertions(+), 92 deletions(-) diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 7f42357f83..0a7346177a 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -1808,12 +1808,13 @@ class AzureProviderSecret(serializers.Serializer): try: base64.b64decode(certificate_content, validate=True) except Exception as e: - raise ValidationError( - { - "certificate_content": [ - f"The provided certificate content is not valid base64 encoded data: {str(e)}" - ] - }, + # Field validators are invoked per-field; DRF already knows + # this error belongs to `certificate_content` and will nest + # the message under that key. Raising a dict here would + # 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)}", code="azure-certificate-content", ) return certificate_content @@ -1847,12 +1848,10 @@ class M365ProviderSecret(serializers.Serializer): try: base64.b64decode(certificate_content, validate=True) except Exception as e: - raise ValidationError( - { - "certificate_content": [ - f"The provided certificate content is not valid base64 encoded data: {str(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)}", code="m365-certificate-content", ) return certificate_content diff --git a/docs/user-guide/providers/azure/authentication.mdx b/docs/user-guide/providers/azure/authentication.mdx index 397a699bd4..9d925e4cbb 100644 --- a/docs/user-guide/providers/azure/authentication.mdx +++ b/docs/user-guide/providers/azure/authentication.mdx @@ -226,8 +226,8 @@ Use it from the Prowler Cloud **add-provider wizard**: 1. Pick **Azure**, then choose **Certificate Authentication (Recommended)** in the credential-type selector. 2. Click **Deploy to Azure**. The button opens the Azure Portal deployment blade pre-loaded with the template. -3. Generate a certificate (see [Certificate Authentication](#certificate-authentication) below) and paste the base64-encoded public certificate and its SHA-1 thumbprint into the Bicep parameters. -4. Deploy and, when the stack finishes, copy the `tenantId`, `applicationId`, and `certificateThumbprint` outputs plus the private key back into Prowler. +3. Generate a certificate (see [Certificate Authentication](#certificate-authentication) below) and paste the base64-encoded public certificate into the Bicep `certificateBase64` parameter. +4. Deploy and, when the stack finishes, copy the `tenantId` and `applicationId` outputs into Prowler along with the base64-encoded PEM bundle (certificate + private key) you generated locally. The `certificateThumbprint` output is informational — use it to reconcile that the deployed keyCredential matches your local certificate. The Deploy to Azure button is not available for sovereign clouds (Azure Government, Azure China, Azure Germany) or personal Microsoft accounts. Follow the manual [Certificate Authentication](#certificate-authentication) or [Service Principal](#service-principal-application-authentication-recommended) instructions instead. @@ -250,12 +250,10 @@ openssl req -x509 -newkey rsa:4096 -keyout prowler.key -out prowler.crt \ # Base64 of the public certificate — feeds `certificateBase64` in the Bicep template openssl x509 -in prowler.crt -outform DER | base64 | tr -d '\n' -# SHA-1 thumbprint — feeds `certificateThumbprint` in the Bicep template -openssl x509 -in prowler.crt -noout -fingerprint -sha1 \ - | cut -d= -f2 | tr -d ':' | tr '[:lower:]' '[:upper:]' - -# Base64 of the private key — paste into Prowler as "Certificate Content" -base64 -i prowler.key | tr -d '\n' +# Base64 of a PEM bundle containing the certificate AND the private key — +# paste into Prowler as "Certificate Content". A key-only file fails. +cat prowler.crt prowler.key > prowler-bundle.pem +base64 < prowler-bundle.pem | tr -d '\n' ``` **Windows (PowerShell):** @@ -276,7 +274,7 @@ Paste the following into the Certificate Authentication form of the Azure add-pr - **Tenant ID** — copy from the deployment outputs. - **Client ID** — copy from the deployment outputs (`applicationId`). -- **Certificate Content** — the base64-encoded **private key** (never the public certificate). +- **Certificate Content** — the base64-encoded **PEM bundle** (certificate + private key) or PKCS#12 export. The Bicep template only received the public certificate; Prowler needs both parts here to sign the token request. ### Prowler CLI diff --git a/docs/user-guide/providers/azure/getting-started-azure.mdx b/docs/user-guide/providers/azure/getting-started-azure.mdx index 4f19bcce05..80e6f55d4d 100644 --- a/docs/user-guide/providers/azure/getting-started-azure.mdx +++ b/docs/user-guide/providers/azure/getting-started-azure.mdx @@ -61,8 +61,8 @@ The one-click flow: click **Deploy to Azure** in the wizard and let the Bicep te 1. In the Azure wizard, select **Certificate Authentication (Recommended)**. 2. Click the **Deploy to Azure** button. This opens the Azure Portal deployment blade pre-loaded with the [Prowler Bicep template](https://github.com/prowler-cloud/prowler/blob/master/permissions/templates/azure/bicep/prowler-scan.bicep). -3. Generate a certificate locally and fill the Bicep parameters (`certificateBase64` and `certificateThumbprint`). See [Certificate Authentication → Generating the certificate](/user-guide/providers/azure/authentication#generating-the-certificate) for the exact commands. -4. When the deployment finishes, copy the `tenantId` and `applicationId` outputs back to Prowler along with the base64-encoded **private key** you generated earlier, and pastes them into the Certificate Content field. +3. Generate a certificate locally and paste the base64-encoded public certificate into the Bicep `certificateBase64` parameter. See [Certificate Authentication → Generating the certificate](/user-guide/providers/azure/authentication#generating-the-certificate) for the exact commands. +4. When the deployment finishes, copy the `tenantId` and `applicationId` outputs into Prowler along with the base64-encoded **PEM bundle** (certificate + private key) you generated earlier, and paste it into the Certificate Content field. 5. Click **Next**, then **Launch Scan**. #### Service Principal with Client Secret diff --git a/permissions/templates/azure/bicep/README.md b/permissions/templates/azure/bicep/README.md index 0f72deeae7..1a81d5d31c 100644 --- a/permissions/templates/azure/bicep/README.md +++ b/permissions/templates/azure/bicep/README.md @@ -51,9 +51,13 @@ After editing `prowler-scan.bicep`, run: make build ``` -Requires the Bicep CLI (`>= 0.30`, for the Microsoft.Graph extension). -Install with `az bicep install` or grab the standalone binary from -. +Requires the Bicep CLI **>= 0.36.1** (needed by the GA release of the +Microsoft Graph Bicep extension, `v1.0.0`). Install with `az bicep install` +or grab the standalone binary from +. Note: the `apiVersion: v1.0` on +`Microsoft.Graph/applications` in the .bicep source refers to the Graph API +version, not the extension release channel — the extension version is pinned +in `bicepconfig.json`. ## Generating the certificate @@ -74,13 +78,15 @@ openssl req -x509 -newkey rsa:4096 -keyout prowler.key -out prowler.crt \ # file through base64 double-encodes it and the deployment will fail. CERT_BASE64=$(openssl x509 -in prowler.crt -outform DER | base64 | tr -d '\n') -# 3. Base64-encode the private key so you can paste it into Prowler as the -# Certificate Private Key field. Keep this value secret. Using `< file` -# (not `-i`) works identically on macOS (BSD) and Linux (GNU). -CERT_KEY_BASE64=$(base64 < prowler.key | tr -d '\n') +# 3. Concatenate the certificate AND the private key into a single PEM +# bundle, then base64-encode the whole thing. `azure.identity.CertificateCredential` +# needs BOTH parts — a key-only file fails with "No certificate found". +# Keep this value secret. Using `< file` (not `-i`) works on macOS and Linux. +cat prowler.crt prowler.key > prowler-bundle.pem +CERT_BUNDLE_BASE64=$(base64 < prowler-bundle.pem | tr -d '\n') -echo "certificateBase64 (paste into the Bicep parameter): $CERT_BASE64" -echo "private key (paste into Prowler UI, keep secret): $CERT_KEY_BASE64" +echo "certificateBase64 (paste into the Bicep parameter): $CERT_BASE64" +echo "certificate bundle (paste into Prowler UI, keep secret): $CERT_BUNDLE_BASE64" ``` ### Windows (PowerShell) @@ -129,9 +135,10 @@ Paste the following into the Prowler wizard's Certificate Authentication form: - **Tenant ID** — `tenantId` output. - **Client ID** — `applicationId` output. -- **Certificate Content** — the base64-encoded **private key** you generated - above. This is *not* the certificate you fed into the Bicep template — the - public cert is already stored inside Entra ID. +- **Certificate Content** — the base64-encoded **PEM bundle** (certificate + + private key) or PKCS#12 export you generated above. The Bicep template + received only the public certificate; Prowler needs the certificate paired + with the private key to sign token requests. The `certificateThumbprint` output is informational; use it to verify the deployed keyCredential matches the certificate you generated locally. diff --git a/permissions/templates/azure/bicep/bicepconfig.json b/permissions/templates/azure/bicep/bicepconfig.json index ba59c78d61..05d5a46af3 100644 --- a/permissions/templates/azure/bicep/bicepconfig.json +++ b/permissions/templates/azure/bicep/bicepconfig.json @@ -3,6 +3,6 @@ "extensibility": true }, "extensions": { - "microsoftGraphV1": "br:mcr.microsoft.com/bicep/extensions/microsoftgraph/v1.0:0.1.8-preview" + "microsoftGraphV1": "br:mcr.microsoft.com/bicep/extensions/microsoftgraph/v1.0:1.0.0" } } diff --git a/permissions/templates/azure/bicep/prowler-scan.bicep b/permissions/templates/azure/bicep/prowler-scan.bicep index 4d0454e680..36f9ec9193 100644 --- a/permissions/templates/azure/bicep/prowler-scan.bicep +++ b/permissions/templates/azure/bicep/prowler-scan.bicep @@ -39,8 +39,11 @@ param certificateDisplayName string = 'Prowler Certificate' @description('When the certificate becomes valid. Defaults to now.') param certificateStartDateTime string = utcNow() -@description('When the certificate expires. Defaults to 1 year from now. Rotate before this date.') -param certificateEndDateTime string = dateTimeAdd(utcNow(), 'P1Y') +// Derived from `certificateStartDateTime` (not a fresh `utcNow()`) so the +// pair is always exactly 1 year apart — Microsoft Graph rejects certificates +// whose `endDateTime - startDateTime` exceeds the 1-year cap. +@description('When the certificate expires. Defaults to 1 year after certificateStartDateTime. Rotate before this date.') +param certificateEndDateTime string = dateTimeAdd(certificateStartDateTime, 'P1Y') @description('Name of the extra role Prowler creates. Keep the default unless your org already uses this name.') param customRoleName string = 'ProwlerRole' diff --git a/permissions/templates/azure/bicep/prowler-scan.json b/permissions/templates/azure/bicep/prowler-scan.json index 3ab4f4b7bd..e14bf25a34 100644 --- a/permissions/templates/azure/bicep/prowler-scan.json +++ b/permissions/templates/azure/bicep/prowler-scan.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.46.1.21595", - "templateHash": "17365420263047938168" + "templateHash": "10591927438861815055" } }, "parameters": { @@ -39,9 +39,9 @@ }, "certificateEndDateTime": { "type": "string", - "defaultValue": "[dateTimeAdd(utcNow(), 'P1Y')]", + "defaultValue": "[dateTimeAdd(parameters('certificateStartDateTime'), 'P1Y')]", "metadata": { - "description": "When the certificate expires. Defaults to 1 year from now. Rotate before this date." + "description": "When the certificate expires. Defaults to 1 year after certificateStartDateTime. Rotate before this date." } }, "customRoleName": { @@ -59,7 +59,7 @@ "imports": { "microsoftGraphV1": { "provider": "MicrosoftGraph", - "version": "0.1.8-preview" + "version": "1.0.0" } }, "resources": { diff --git a/prowler/providers/azure/azure_provider.py b/prowler/providers/azure/azure_provider.py index be5ca94be9..e8e9704743 100644 --- a/prowler/providers/azure/azure_provider.py +++ b/prowler/providers/azure/azure_provider.py @@ -76,6 +76,36 @@ from prowler.providers.common.provider import Provider _PROWLER_CERT_THUMBPRINT_ATTR = "_prowler_certificate_thumbprint" +def _build_certificate_credential( + tenant_id: str, + client_id: str, + certificate_data: bytes, + authority: Optional[str], +) -> "CertificateCredential": + """Build a `CertificateCredential` and stash the SHA-1 thumbprint on it. + + Centralises the three-step ritual (construct credential, compute + thumbprint, `setattr` it under `_PROWLER_CERT_THUMBPRINT_ATTR`) that + `setup_session` and friends would otherwise repeat identically for every + cert entry point (static content, static path, env-var cert-auth). Missing + the `setattr` in any one call site silently degrades the identity report + to "Unknown certificate thumbprint", so keeping it in one place is a real + correctness win. + """ + credentials = CertificateCredential( + tenant_id=tenant_id, + client_id=client_id, + certificate_data=certificate_data, + authority=authority, + ) + setattr( + credentials, + _PROWLER_CERT_THUMBPRINT_ATTR, + _compute_certificate_thumbprint(certificate_data), + ) + return credentials + + def _compute_certificate_thumbprint(cert_data: bytes) -> Optional[str]: """Compute the SHA-1 thumbprint of an X.509 certificate. @@ -635,36 +665,25 @@ class AzureProvider(Provider): if azure_credentials: try: if azure_credentials.get("certificate_content"): - certificate_data = base64.b64decode( - azure_credentials["certificate_content"] - ) - credentials = CertificateCredential( + credentials = _build_certificate_credential( tenant_id=azure_credentials["tenant_id"], client_id=azure_credentials["client_id"], - certificate_data=certificate_data, + certificate_data=base64.b64decode( + azure_credentials["certificate_content"] + ), authority=region_config.authority, ) - setattr( - credentials, - _PROWLER_CERT_THUMBPRINT_ATTR, - _compute_certificate_thumbprint(certificate_data), - ) elif azure_credentials.get("certificate_path"): with open( azure_credentials["certificate_path"], "rb" ) as cert_file: certificate_data = cert_file.read() - credentials = CertificateCredential( + credentials = _build_certificate_credential( tenant_id=azure_credentials["tenant_id"], client_id=azure_credentials["client_id"], certificate_data=certificate_data, authority=region_config.authority, ) - setattr( - credentials, - _PROWLER_CERT_THUMBPRINT_ATTR, - _compute_certificate_thumbprint(certificate_data), - ) else: credentials = ClientSecretCredential( tenant_id=azure_credentials["tenant_id"], @@ -703,17 +722,12 @@ class AzureProvider(Provider): certificate_data = base64.b64decode( getenv("AZURE_CERTIFICATE_CONTENT") ) - credentials = CertificateCredential( + credentials = _build_certificate_credential( tenant_id=getenv("AZURE_TENANT_ID"), client_id=getenv("AZURE_CLIENT_ID"), certificate_data=certificate_data, authority=region_config.authority, ) - setattr( - credentials, - _PROWLER_CERT_THUMBPRINT_ATTR, - _compute_certificate_thumbprint(certificate_data), - ) return credentials except ClientAuthenticationError as error: logger.error( @@ -1174,9 +1188,15 @@ class AzureProvider(Provider): ) # since that exception is not considered as critical, we keep filling another identity fields if isinstance(credentials, CertificateCredential): + # `getenv("AZURE_CLIENT_ID", default=client_id)` already + # covers both the env-var cert-auth path (env is set) and + # the static-credentials path (constructor param supplied + # `client_id`). Reaching into `credentials._client_id` + # would be the same class of azure-identity private state + # we deliberately avoided for the thumbprint. identity.identity_id = getenv( "AZURE_CLIENT_ID", default=client_id - ) or getattr(credentials, "_client_id", "") + ) identity.identity_type = "Service Principal with Certificate" # The SHA-1 thumbprint is computed from the certificate # bytes by `_compute_certificate_thumbprint` at @@ -1600,7 +1620,13 @@ class AzureProvider(Provider): "client_secret": client_secret, "scope": region_config.graph_scope, } - response = requests.post(url, headers=headers, data=data).json() + # Hard timeout so a hung Entra ID endpoint cannot stall the + # calling worker (this runs on request threads and Celery tasks + # in the API path). 30s covers the p99 of the token endpoint + # comfortably. + response = requests.post( + url, headers=headers, data=data, timeout=30 + ).json() if ( "access_token" not in response.keys() and "error_codes" in response.keys() diff --git a/prowler/providers/azure/models.py b/prowler/providers/azure/models.py index b80d5076f2..c6c882ed66 100644 --- a/prowler/providers/azure/models.py +++ b/prowler/providers/azure/models.py @@ -16,13 +16,6 @@ class AzureIdentityInfo(BaseModel): locations: dict = {} -class AzureCredentials(BaseModel): - client_id: str = "" - client_secret: Optional[str] = None - tenant_id: str = "" - certificate_content: Optional[str] = None - - class AzureRegionConfig(BaseModel): name: str = "" authority: Optional[str] = None diff --git a/tests/providers/azure/azure_provider_test.py b/tests/providers/azure/azure_provider_test.py index 818dc8d6b4..a8505c5b0a 100644 --- a/tests/providers/azure/azure_provider_test.py +++ b/tests/providers/azure/azure_provider_test.py @@ -1401,6 +1401,80 @@ class TestAzureProviderCertificateAuth: assert kwargs["tenant_id"] == self._TENANT_ID assert kwargs["client_id"] == self._CLIENT_ID + def test_setup_session_attaches_computed_thumbprint(self): + # Regression guard for the thumbprint handoff added in PROWLER-2378. + # The earlier `test_setup_session_static_credentials_cert_content_...` + # test patches `CertificateCredential` with a `MagicMock` which + # silently accepts any `setattr`, so even a missing hand-off would + # look green. This test uses a REAL certificate so + # `_compute_certificate_thumbprint` returns a real value and asserts + # the credential object actually carries it under + # `_PROWLER_CERT_THUMBPRINT_ATTR` — the sole reason that attribute + # exists is `setup_identity`'s cert-branch reading it. + import base64 + from datetime import datetime, timedelta, timezone + + from cryptography import x509 + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.hazmat.primitives.serialization import pkcs12 + from cryptography.x509.oid import NameOID + + from prowler.providers.azure.azure_provider import ( + _PROWLER_CERT_THUMBPRINT_ATTR, + ) + + key = rsa.generate_private_key( + public_exponent=65537, key_size=2048, backend=default_backend() + ) + subject = issuer = x509.Name( + [x509.NameAttribute(NameOID.COMMON_NAME, "prowler-test")] + ) + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.now(timezone.utc)) + .not_valid_after(datetime.now(timezone.utc) + timedelta(days=1)) + .sign(key, hashes.SHA256(), default_backend()) + ) + expected_thumbprint = cert.fingerprint(hashes.SHA1()).hex().upper() + # azure-identity accepts PKCS#12; encode it so the dict looks like + # what the API layer stores after the serializer runs. + pfx = pkcs12.serialize_key_and_certificates( + name=b"prowler", + key=key, + cert=cert, + cas=None, + encryption_algorithm=serialization.NoEncryption(), + ) + cert_content_b64 = base64.b64encode(pfx).decode("ascii") + + credentials = AzureProvider.setup_session( + az_cli_auth=False, + sp_env_auth=False, + browser_auth=False, + managed_identity_auth=False, + certificate_auth=False, + certificate_path=None, + tenant_id=self._TENANT_ID, + azure_credentials={ + "tenant_id": self._TENANT_ID, + "client_id": self._CLIENT_ID, + "client_secret": None, + "certificate_content": cert_content_b64, + "certificate_path": None, + }, + region_config=self._region_config(), + ) + assert ( + getattr(credentials, _PROWLER_CERT_THUMBPRINT_ATTR, None) + == expected_thumbprint + ) + def test_setup_session_static_credentials_client_secret_still_works(self): # Regression guard: adding cert branches to setup_session must not # break the pre-existing client-secret static credentials flow. diff --git a/ui/components/providers/workflow/forms/select-credentials-type/azure/credentials-type/azure-certificate-credentials-form.tsx b/ui/components/providers/workflow/forms/select-credentials-type/azure/credentials-type/azure-certificate-credentials-form.tsx index c6b3f052a0..38c3b3e331 100644 --- a/ui/components/providers/workflow/forms/select-credentials-type/azure/credentials-type/azure-certificate-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/select-credentials-type/azure/credentials-type/azure-certificate-credentials-form.tsx @@ -99,10 +99,11 @@ export const AzureCertificateCredentialsForm = ({

Generate a self-signed X.509 certificate right in your browser. The - private key never leaves your device — it goes - straight into the field below. The public certificate downloads as a - text file for you to paste into the Bicep{" "} - Certificate Base64 parameter in the Portal. + private key is generated locally and goes straight + into the field below (it is then submitted with the rest of the + form). The public certificate downloads as a text file for you to + paste into the Bicep Certificate Base64 parameter in + the Portal.