feat(azure): add Deploy-to-Azure Bicep template and certificate auth

This commit is contained in:
Lydia Vilchez committed 2026-08-12 09:48:34 +02:00
1 parent d2df95546b
commit a47d94954a
16 files changed
+206 -92

No files matched your search

+11 -12
View File
@@ -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
@@ -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.
<Note>
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
@@ -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
+19 -12
View File
@@ -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
<https://github.com/Azure/bicep/releases>.
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
<https://github.com/Azure/bicep/releases>. 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.
@@ -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"
}
}
@@ -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'
@@ -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": {
+50 -24
View File
@@ -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()
-7
View File
@@ -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
@@ -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.
@@ -99,10 +99,11 @@ export const AzureCertificateCredentialsForm = ({
</div>
<p className="text-text-neutral-tertiary text-xs">
Generate a self-signed X.509 certificate right in your browser. The
<strong> private key never leaves your device</strong> — it goes
straight into the field below. The public certificate downloads as a
text file for you to paste into the Bicep{" "}
<code>Certificate Base64</code> parameter in the Portal.
<strong> private key is generated locally</strong> 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 <code>Certificate Base64</code> parameter in
the Portal.
</p>
<Button
type="button"
@@ -6,8 +6,16 @@ import { WizardRadioCard } from "@/components/providers/workflow/forms/fields";
import { FormMessage } from "@/components/shadcn/form";
import { RadioGroup } from "@/components/shadcn/radio-group/radio-group";
// Local form shape for this selector: only the radio value lives here, the
// actual credential fields belong to the downstream credentials form. Kept
// exported so `SelectViaAzure` and the tests bind the same type instead of
// falling back to `any`.
export type AzureCredentialsTypeFormValues = {
azureCredentialsType: "app_certificate" | "app_client_secret" | "";
};
type RadioGroupAzureViaCredentialsFormProps = {
control: Control<any>;
control: Control<AzureCredentialsTypeFormValues>;
isInvalid: boolean;
errorMessage?: string;
onChange?: (value: string) => void;
@@ -5,7 +5,10 @@ import { useForm } from "react-hook-form";
import { Form } from "@/components/shadcn/form";
import { RadioGroupAzureViaCredentialsTypeForm } from "./radio-group-azure-via-credentials-type-form";
import {
AzureCredentialsTypeFormValues,
RadioGroupAzureViaCredentialsTypeForm,
} from "./radio-group-azure-via-credentials-type-form";
interface SelectViaAzureProps {
initialVia?: string;
@@ -17,9 +20,12 @@ export const SelectViaAzure = ({
onViaChange,
}: SelectViaAzureProps) => {
const router = useRouter();
const form = useForm({
const form = useForm<AzureCredentialsTypeFormValues>({
defaultValues: {
azureCredentialsType: initialVia || "",
azureCredentialsType:
initialVia === "app_certificate" || initialVia === "app_client_secret"
? initialVia
: "",
},
});
+1 -1
View File
@@ -1,4 +1,5 @@
import { webcrypto } from "node:crypto";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
@@ -12,7 +13,6 @@ import {
// module-level `cryptoProvider.set(...)` inside the SUT resolves it.
beforeAll(() => {
if (!globalThis.crypto || !globalThis.crypto.subtle) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Object.defineProperty(globalThis, "crypto", {
value: webcrypto,
configurable: true,
+6 -7
View File
@@ -25,13 +25,6 @@ import {
X509CertificateGenerator,
} from "@peculiar/x509";
// Bind @peculiar/x509 to the browser's native SubtleCrypto. Without this the
// library falls back to a Node-only crypto provider that vitest+jsdom does
// not expose, and the helper would throw in tests.
if (typeof globalThis !== "undefined" && globalThis.crypto?.subtle) {
cryptoProvider.set(globalThis.crypto);
}
export interface GeneratedProwlerCertificate {
/**
* Base64 of the DER-encoded X.509 public certificate. Feed this into the
@@ -104,6 +97,12 @@ export async function generateProwlerCertificate(
"Web Crypto API is not available in this browser. Use the openssl or PowerShell instructions from the certificate generation guide instead.",
);
}
// Bind @peculiar/x509 to the browser's native SubtleCrypto here rather
// than at module load time: doing it inside the function keeps the check
// above authoritative (the crypto object could have been swapped by a
// test harness between import and call) and avoids side-effects when
// consumers only import the types.
cryptoProvider.set(globalThis.crypto);
const keyPair = (await subtle.generateKey(
{
+3 -3
View File
@@ -417,9 +417,9 @@ export const getAWSOrgDeploymentQuickLink = ({
// Builds the Azure Portal deep link that opens the "Custom deployment" wizard
// pre-loaded with the Prowler Bicep template. The Portal expects
// `#create/Microsoft.Template/uri/<double-encoded-template-url>` — the URL
// fragment is re-parsed by the Portal SPA which URL-decodes the segment once
// more, so the template URL must be percent-encoded exactly once via
// `#create/Microsoft.Template/uri/<url-encoded-template-url>` — the URL
// fragment is re-parsed by the Portal SPA which URL-decodes the segment
// once, so the template URL must be percent-encoded exactly once via
// `encodeURIComponent`. This is the Azure analogue of the AWS CloudFormation
// quick-create link built by `getAWSOrgDeploymentQuickLink` above.
//