mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-14 07:24:15 +00:00
feat(azure): add Deploy-to-Azure Bicep template and certificate auth
This commit is contained in:
1 parent
d41b2eaa0f
commit
ab738e8a4e
34 files changed
+1953
-93
No files matched your search
@@ -0,0 +1 @@
|
||||
Accept an optional `certificate_content` field on the Azure provider secret and enforce mutual exclusion with `client_secret` in the serializer, matching the M365 provider secret shape
|
||||
@@ -5,6 +5,7 @@ from api.v1.serializer_utils.integrations import (
|
||||
)
|
||||
from api.v1.serializer_utils.providers import ProviderSecretField
|
||||
from api.v1.serializers import (
|
||||
AzureProviderSecret,
|
||||
ImageProviderSecret,
|
||||
IntegrationSerializer,
|
||||
IntegrationUpdateSerializer,
|
||||
@@ -198,6 +199,79 @@ class TestImageProviderSecret:
|
||||
assert "non_field_errors" in serializer.errors
|
||||
|
||||
|
||||
class TestAzureProviderSecret:
|
||||
"""Coverage for the Azure provider secret serializer, including the
|
||||
certificate authentication path added for the Deploy-to-Azure quick-start
|
||||
(PROWLER-2378)."""
|
||||
|
||||
BASE = {
|
||||
"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"
|
||||
|
||||
def test_accepts_client_secret_only(self):
|
||||
# Backwards-compatibility guard: rows saved by the previous serializer
|
||||
# only carry `client_secret` and must keep round-tripping cleanly.
|
||||
serializer = AzureProviderSecret(
|
||||
data={**self.BASE, "client_secret": "fake-client-secret"}
|
||||
)
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert serializer.validated_data["client_secret"] == "fake-client-secret"
|
||||
assert "certificate_content" not in serializer.validated_data
|
||||
|
||||
def test_accepts_certificate_content_only(self):
|
||||
serializer = AzureProviderSecret(
|
||||
data={**self.BASE, "certificate_content": self.CERT_CONTENT_B64}
|
||||
)
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert serializer.validated_data["certificate_content"] == self.CERT_CONTENT_B64
|
||||
assert "client_secret" not in serializer.validated_data
|
||||
|
||||
def test_rejects_both_client_secret_and_certificate_content(self):
|
||||
# Mutually exclusive: the backend must reject a payload carrying both
|
||||
# so the ambiguity never reaches the SDK where `certificate_content`
|
||||
# silently wins.
|
||||
serializer = AzureProviderSecret(
|
||||
data={
|
||||
**self.BASE,
|
||||
"client_secret": "fake-client-secret",
|
||||
"certificate_content": self.CERT_CONTENT_B64,
|
||||
}
|
||||
)
|
||||
assert not serializer.is_valid()
|
||||
assert "non_field_errors" in serializer.errors
|
||||
|
||||
def test_rejects_missing_secret_and_certificate(self):
|
||||
# At least one credential material must be provided.
|
||||
serializer = AzureProviderSecret(data=self.BASE)
|
||||
assert not serializer.is_valid()
|
||||
assert "non_field_errors" in serializer.errors
|
||||
|
||||
def test_rejects_non_base64_certificate_content(self):
|
||||
# `validate_certificate_content` short-circuits obvious garbage before
|
||||
# it reaches the SDK, which would otherwise fail deep in azure-identity.
|
||||
serializer = AzureProviderSecret(
|
||||
data={**self.BASE, "certificate_content": "not!valid@base64$$"}
|
||||
)
|
||||
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
|
||||
# the important thing is that empty strings NEVER get persisted as
|
||||
# credentials.
|
||||
serializer = AzureProviderSecret(
|
||||
data={**self.BASE, "client_secret": "", "certificate_content": ""}
|
||||
)
|
||||
assert not serializer.is_valid()
|
||||
assert "client_secret" in serializer.errors
|
||||
assert "certificate_content" in serializer.errors
|
||||
|
||||
|
||||
class TestOracleCloudProviderSecret:
|
||||
def valid_secret(self, **overrides):
|
||||
secret = {
|
||||
|
||||
@@ -1787,8 +1787,36 @@ class AwsProviderSecret(serializers.Serializer):
|
||||
|
||||
class AzureProviderSecret(serializers.Serializer):
|
||||
client_id = serializers.CharField()
|
||||
client_secret = serializers.CharField()
|
||||
client_secret = serializers.CharField(required=False)
|
||||
tenant_id = serializers.CharField()
|
||||
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."
|
||||
)
|
||||
if not attrs.get("client_secret") and not attrs.get("certificate_content"):
|
||||
raise serializers.ValidationError(
|
||||
"You must provide either client_secret or certificate_content."
|
||||
)
|
||||
return super().validate(attrs)
|
||||
|
||||
def validate_certificate_content(self, certificate_content):
|
||||
"""Validate that Azure certificate content is valid base64 encoded data."""
|
||||
if certificate_content:
|
||||
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)}"
|
||||
]
|
||||
},
|
||||
code="azure-certificate-content",
|
||||
)
|
||||
return certificate_content
|
||||
|
||||
class Meta:
|
||||
resource_name = "provider-secrets"
|
||||
|
||||
@@ -6,11 +6,13 @@ Prowler for Azure supports multiple authentication types. Authentication methods
|
||||
|
||||
**Prowler Cloud:**
|
||||
|
||||
- [**Service Principal Application**](#service-principal-application-authentication-recommended)
|
||||
- [**Certificate Authentication**](#certificate-authentication) (**Recommended** — one-click setup via the [Deploy to Azure](#deploy-to-azure-quick-start) quick-start)
|
||||
- [**Service Principal Application**](#service-principal-application-authentication-recommended) with a client secret
|
||||
|
||||
**Prowler CLI:**
|
||||
|
||||
- [**Service Principal Application**](#service-principal-application-authentication-recommended) (**Recommended**)
|
||||
- [**Certificate Authentication**](#certificate-authentication) (**Recommended**)
|
||||
- [**Service Principal Application**](#service-principal-application-authentication-recommended) with a client secret
|
||||
- [**AZ CLI credentials**](#az-cli-authentication)
|
||||
- [**Interactive browser authentication**](#browser-authentication)
|
||||
- [**Managed Identity Authentication**](#managed-identity-authentication)
|
||||
@@ -216,6 +218,84 @@ The following security checks require the `ProwlerRole` permissions for executio
|
||||
|
||||
---
|
||||
|
||||
## Deploy to Azure (Quick-Start)
|
||||
|
||||
Prowler ships a public [Bicep template](https://github.com/prowler-cloud/prowler/blob/master/permissions/templates/azure/bicep/prowler-scan.bicep) that provisions the App Registration, Service Principal, built-in `Reader` role assignment, and the custom `ProwlerRole` — all bound to an X.509 certificate credential — in a single deployment. This is the Azure analogue of the AWS CloudFormation quick-create flow.
|
||||
|
||||
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.
|
||||
|
||||
<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.
|
||||
</Note>
|
||||
|
||||
## Certificate Authentication
|
||||
|
||||
Certificate authentication is the recommended way to run Prowler against Azure because Entra ID keeps only the public certificate (no shared secrets to rotate), and Bicep can bind the certificate to the App Registration natively via `keyCredentials`.
|
||||
|
||||
### Generating the certificate
|
||||
|
||||
Prowler never sees the private key during deployment — the user generates the keypair locally.
|
||||
|
||||
**macOS / Linux (OpenSSL):**
|
||||
|
||||
```console
|
||||
openssl req -x509 -newkey rsa:4096 -keyout prowler.key -out prowler.crt \
|
||||
-days 365 -nodes -subj "/CN=Prowler"
|
||||
|
||||
# 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'
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
|
||||
```powershell
|
||||
$cert = New-SelfSignedCertificate -Subject "CN=Prowler" `
|
||||
-CertStoreLocation "Cert:\CurrentUser\My" `
|
||||
-KeyExportPolicy Exportable -KeySpec Signature `
|
||||
-KeyLength 4096 -HashAlgorithm SHA256
|
||||
|
||||
[Convert]::ToBase64String($cert.RawData) # certificateBase64
|
||||
$cert.Thumbprint # certificateThumbprint
|
||||
```
|
||||
|
||||
### Prowler Cloud
|
||||
|
||||
Paste the following into the Certificate Authentication form of the Azure add-provider wizard:
|
||||
|
||||
- **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).
|
||||
|
||||
### Prowler CLI
|
||||
|
||||
Set the following environment variables and invoke Prowler with `--certificate-auth`:
|
||||
|
||||
```console
|
||||
export AZURE_CLIENT_ID="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
|
||||
export AZURE_TENANT_ID="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
|
||||
export AZURE_CERTIFICATE_CONTENT="<base64-encoded private key>"
|
||||
|
||||
prowler azure --certificate-auth
|
||||
```
|
||||
|
||||
Alternatively, point Prowler at a certificate file on disk:
|
||||
|
||||
```console
|
||||
prowler azure --certificate-auth --certificate-path /path/to/prowler.key
|
||||
```
|
||||
|
||||
## Service Principal Application Authentication (Recommended)
|
||||
|
||||
This method is required for Prowler Cloud and recommended for Prowler CLI.
|
||||
|
||||
@@ -53,26 +53,29 @@ For detailed instructions on how to create the Service Principal and configure p
|
||||
|
||||
### Step 3: Add Credentials to Prowler Cloud
|
||||
|
||||
For Azure, Prowler Cloud uses a service principal application to authenticate. For more information about the process of creating and adding permissions to a service principal refer to this [section](/user-guide/providers/azure/authentication). When you finish creating and adding the [Entra](/user-guide/providers/azure/create-prowler-service-principal#assigning-proper-permissions) and [Subscription](/user-guide/providers/azure/subscriptions) scope permissions to the service principal, enter the `Tenant ID`, `Client ID` and `Client Secret` of the service principal application.
|
||||
Azure supports two authentication methods in the add-provider wizard. Prowler Cloud shows a credential-type selector where you can pick the one that fits.
|
||||
|
||||
#### Certificate Authentication (Recommended)
|
||||
|
||||
1. Go to your App Registration overview and copy the `Client ID` and `Tenant ID`
|
||||
The one-click flow: click **Deploy to Azure** in the wizard and let the Bicep template create the App Registration, Service Principal, `Reader` role assignment, and custom `ProwlerRole` with a certificate credential in a single deployment.
|
||||
|
||||

|
||||
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.
|
||||
5. Click **Next**, then **Launch Scan**.
|
||||
|
||||
2. Go to Prowler Cloud and paste:
|
||||
#### Service Principal with Client Secret
|
||||
|
||||
- `Client ID`
|
||||
- `Tenant ID`
|
||||
- `Client Secret` from [earlier](/user-guide/providers/azure/authentication#service-principal-application-authentication-recommended)
|
||||
The manual fallback that mirrors the previous Prowler Cloud flow, and the only option for sovereign clouds and personal accounts.
|
||||
|
||||
1. Follow [Creating Prowler Service Principal](/user-guide/providers/azure/create-prowler-service-principal) to create the App Registration, assign the [Entra](/user-guide/providers/azure/create-prowler-service-principal#assigning-proper-permissions) and [Subscription](/user-guide/providers/azure/subscriptions) scope permissions, and issue a client secret.
|
||||
2. In the Azure wizard, select **Service Principal with Client Secret**.
|
||||
3. Paste `Tenant ID`, `Client ID`, and `Client Secret`.
|
||||
|
||||

|
||||
|
||||
3. Click `Next`
|
||||
|
||||

|
||||
|
||||
4. Click "Launch Scan"
|
||||
4. Click `Next`, then **Launch Scan**.
|
||||
|
||||

|
||||
|
||||
@@ -86,7 +89,29 @@ To authenticate with Azure, Prowler CLI supports multiple authentication methods
|
||||
|
||||
For detailed authentication setup instructions, see [Authentication](/user-guide/providers/azure/authentication).
|
||||
|
||||
**Service Principal (Recommended)**
|
||||
**Certificate Authentication (Recommended)**
|
||||
|
||||
Set up environment variables with the base64-encoded private key:
|
||||
|
||||
```console
|
||||
export AZURE_CLIENT_ID="XXXXXXXXX"
|
||||
export AZURE_TENANT_ID="XXXXXXXXX"
|
||||
export AZURE_CERTIFICATE_CONTENT="<base64-encoded private key>"
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```console
|
||||
prowler azure --certificate-auth
|
||||
```
|
||||
|
||||
Or point Prowler at a certificate file on disk:
|
||||
|
||||
```console
|
||||
prowler azure --certificate-auth --certificate-path /path/to/prowler.key
|
||||
```
|
||||
|
||||
**Service Principal with Client Secret**
|
||||
|
||||
Set up environment variables:
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Regenerate the ARM JSON template from the Bicep source. The Azure Portal
|
||||
# "Deploy to Azure" deep link (`#create/Microsoft.Template/uri/<url>`) only
|
||||
# accepts ARM JSON, not raw Bicep source, so the .json file is the artifact
|
||||
# actually consumed by users — keep it committed and in sync with the .bicep.
|
||||
#
|
||||
# Requires Bicep CLI (>= 0.30 for the Microsoft.Graph extension). Install
|
||||
# with `az bicep install` (via Azure CLI) or download the standalone binary
|
||||
# from https://github.com/Azure/bicep/releases.
|
||||
#
|
||||
# `bicepconfig.json` in this directory registers the Microsoft.Graph
|
||||
# extension against the public Microsoft Container Registry, so the CLI can
|
||||
# resolve `extension microsoftGraphV1` without extra flags.
|
||||
|
||||
BICEP ?= bicep
|
||||
|
||||
.PHONY: build check clean
|
||||
|
||||
build: prowler-scan.json
|
||||
|
||||
prowler-scan.json: prowler-scan.bicep bicepconfig.json
|
||||
$(BICEP) build $<
|
||||
|
||||
# CI hook: fail if the JSON is out of sync with the Bicep source. Run
|
||||
# `make build` locally and commit both files before opening the PR.
|
||||
check: prowler-scan.bicep bicepconfig.json
|
||||
$(BICEP) build prowler-scan.bicep --outfile /tmp/prowler-scan.check.json
|
||||
diff -q prowler-scan.json /tmp/prowler-scan.check.json
|
||||
rm /tmp/prowler-scan.check.json
|
||||
|
||||
clean:
|
||||
rm -f prowler-scan.json
|
||||
@@ -0,0 +1,141 @@
|
||||
# Prowler Azure Bicep quick-start
|
||||
|
||||
This directory hosts the Bicep template that powers the **Deploy to Azure**
|
||||
button in the Prowler UI (`add-provider` wizard → Azure → Certificate
|
||||
authentication). It is the Azure equivalent of the CloudFormation quick-create
|
||||
stack under `../../cloudformation/`.
|
||||
|
||||
Deploying `prowler-scan.bicep` at subscription scope provisions everything
|
||||
Prowler needs to authenticate against the subscription and enumerate resources:
|
||||
|
||||
1. An Entra ID App Registration with an X.509 `keyCredentials` entry
|
||||
(certificate authentication — no client secrets).
|
||||
2. A Service Principal for that application.
|
||||
3. A subscription-scoped assignment of the built-in `Reader` role.
|
||||
4. A subscription-scoped custom role (`ProwlerRole`) with the two extra
|
||||
read/list actions the built-in Reader is missing, and its role assignment.
|
||||
|
||||
The template is idempotent: role definitions and role assignments use
|
||||
deterministic GUIDs derived from `subscription().id` and `applicationName`,
|
||||
so redeploying updates the existing resources instead of creating duplicates.
|
||||
|
||||
## Files
|
||||
|
||||
- `prowler-scan.bicep` — Bicep source (source of truth). Uses the
|
||||
`Microsoft.Graph` extension registered in `bicepconfig.json`.
|
||||
- `prowler-scan.json` — compiled ARM JSON. **This is the artifact the
|
||||
Deploy to Azure button loads** — Azure Portal's
|
||||
`#create/Microsoft.Template/uri/<url>` deep link only accepts ARM JSON,
|
||||
not raw Bicep source. Keep it committed and in sync with the `.bicep`.
|
||||
- `bicepconfig.json` — registers the Microsoft.Graph Bicep extension against
|
||||
the public Microsoft Container Registry.
|
||||
- `Makefile` — `make build` regenerates the JSON; `make check` fails when
|
||||
the JSON is stale relative to the Bicep source (used by CI).
|
||||
|
||||
Both files are hosted at:
|
||||
|
||||
```text
|
||||
https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/azure/bicep/prowler-scan.json
|
||||
https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/azure/bicep/prowler-scan.bicep
|
||||
```
|
||||
|
||||
The Deploy to Azure button opens
|
||||
`https://portal.azure.com/#create/Microsoft.Template/uri/<encoded-json-url>`,
|
||||
which loads the ARM JSON into the Portal deployment wizard.
|
||||
|
||||
## Regenerating the ARM JSON
|
||||
|
||||
After editing `prowler-scan.bicep`, run:
|
||||
|
||||
```bash
|
||||
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>.
|
||||
|
||||
## Generating the certificate
|
||||
|
||||
The user supplies the certificate — the template only receives the
|
||||
base64-encoded DER of the public certificate. Prowler never sees the private
|
||||
key during deployment; Microsoft Graph computes and stores the SHA-1
|
||||
thumbprint from the certificate bytes and returns it as a template output.
|
||||
|
||||
### macOS / Linux (OpenSSL)
|
||||
|
||||
```bash
|
||||
# 1. Generate a 4096-bit RSA private key and a matching self-signed cert
|
||||
openssl req -x509 -newkey rsa:4096 -keyout prowler.key -out prowler.crt \
|
||||
-days 365 -nodes -subj "/CN=Prowler"
|
||||
|
||||
# 2. Base64-encode the DER form of the public certificate. Feeds
|
||||
# `certificateBase64`. IMPORTANT: use `-outform DER` — piping a PEM
|
||||
# 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')
|
||||
|
||||
echo "certificateBase64 (paste into the Bicep parameter): $CERT_BASE64"
|
||||
echo "private key (paste into Prowler UI, keep secret): $CERT_KEY_BASE64"
|
||||
```
|
||||
|
||||
### Windows (PowerShell)
|
||||
|
||||
```powershell
|
||||
$cert = New-SelfSignedCertificate -Subject "CN=Prowler" `
|
||||
-CertStoreLocation "Cert:\CurrentUser\My" `
|
||||
-KeyExportPolicy Exportable -KeySpec Signature `
|
||||
-KeyLength 4096 -HashAlgorithm SHA256
|
||||
|
||||
# certificateBase64: DER of the public certificate, base64-encoded
|
||||
$certBase64 = [Convert]::ToBase64String($cert.RawData)
|
||||
|
||||
# Private key (PKCS#12), base64-encoded — paste into Prowler as
|
||||
# "Certificate Content", keep secret
|
||||
$pfxBytes = $cert.Export('Pfx', '')
|
||||
$keyBase64 = [Convert]::ToBase64String($pfxBytes)
|
||||
|
||||
Write-Host "certificateBase64 (paste into the Bicep parameter): $certBase64"
|
||||
Write-Host "private key (paste into Prowler UI, keep secret): $keyBase64"
|
||||
```
|
||||
|
||||
## Deploying manually (CLI, when the button is not an option)
|
||||
|
||||
Deploy the compiled ARM JSON (recommended, matches what the Portal loads):
|
||||
|
||||
```bash
|
||||
az deployment sub create \
|
||||
--location westeurope \
|
||||
--template-file prowler-scan.json \
|
||||
--parameters certificateBase64="$CERT_BASE64"
|
||||
```
|
||||
|
||||
Or deploy the Bicep source directly (Azure CLI compiles it on the fly):
|
||||
|
||||
```bash
|
||||
az deployment sub create \
|
||||
--location westeurope \
|
||||
--template-file prowler-scan.bicep \
|
||||
--parameters certificateBase64="$CERT_BASE64"
|
||||
```
|
||||
|
||||
## After the deployment
|
||||
|
||||
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.
|
||||
|
||||
The `certificateThumbprint` output is informational; use it to verify the
|
||||
deployed keyCredential matches the certificate you generated locally.
|
||||
|
||||
The manual fallback described in the Azure authentication docs (client
|
||||
secrets, sovereign clouds, personal accounts) remains supported for
|
||||
environments where the Bicep template cannot be deployed.
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"experimentalFeaturesEnabled": {
|
||||
"extensibility": true
|
||||
},
|
||||
"extensions": {
|
||||
"microsoftGraphV1": "br:mcr.microsoft.com/bicep/extensions/microsoftgraph/v1.0:0.1.8-preview"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// Prowler quick-start deployment
|
||||
//
|
||||
// One-shot Bicep template that stands up everything Prowler needs to scan an
|
||||
// Azure subscription with certificate-based authentication:
|
||||
// 1. An Entra ID App Registration with an X.509 certificate credential
|
||||
// (`keyCredentials`) — no client secrets, no deploymentScripts.
|
||||
// 2. A Service Principal for that App Registration.
|
||||
// 3. A role assignment granting the built-in `Reader` role at subscription
|
||||
// scope so Prowler can inventory resources.
|
||||
// 4. A subscription-scoped custom "ProwlerRole" that grants the two extra
|
||||
// read/list actions the built-in Reader is missing
|
||||
// (`Microsoft.Web/sites/host/listkeys/action` and
|
||||
// `Microsoft.Web/sites/config/list/Action`), plus its role assignment.
|
||||
//
|
||||
// This mirrors the AWS CloudFormation quick-create flow shipped in
|
||||
// `permissions/templates/cloudformation/prowler-scan-role.yml`, and matches
|
||||
// the "manual" instructions in
|
||||
// `docs/user-guide/providers/azure/authentication.mdx`. Keep them in sync
|
||||
// when adding or removing permissions here.
|
||||
//
|
||||
// After the deployment succeeds, copy the outputs (tenantId, clientId, and
|
||||
// certificateThumbprint) into Prowler along with the private key that pairs
|
||||
// with `certificateBase64`.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
targetScope = 'subscription'
|
||||
|
||||
@description('Display name of the Entra ID App Registration that Prowler will authenticate as.')
|
||||
param applicationName string = 'ProwlerApp'
|
||||
|
||||
@description('Base64-encoded DER of the X.509 public certificate that Entra ID will pin to the App Registration as a keyCredential. Generate with `openssl x509 -outform DER | base64` (see README). The matching private key stays with the user — Prowler never receives it during deployment.')
|
||||
@secure()
|
||||
param certificateBase64 string
|
||||
|
||||
@description('Optional friendly display name for the certificate credential.')
|
||||
param certificateDisplayName string = 'Prowler Certificate'
|
||||
|
||||
@description('Start date (ISO 8601) for the certificate credential validity window. Defaults to the deployment time.')
|
||||
param certificateStartDateTime string = utcNow()
|
||||
|
||||
@description('End date (ISO 8601) for the certificate credential validity window. Defaults to one year after the deployment.')
|
||||
param certificateEndDateTime string = dateTimeAdd(utcNow(), 'P1Y')
|
||||
|
||||
@description('Name of the custom role Prowler creates for its extra read actions.')
|
||||
param customRoleName string = 'ProwlerRole'
|
||||
|
||||
// Deterministic GUIDs derived from `subscription().id` and the params so
|
||||
// re-deploying the same template into the same subscription is idempotent —
|
||||
// the role definition and the two role assignments are found and updated
|
||||
// instead of duplicated. `guid()` is safe to call at subscription scope.
|
||||
// The role-assignment names cannot reference `servicePrincipal.id`
|
||||
// because ARM needs those names calculable before the deployment starts;
|
||||
// pinning to `applicationName` gives a stable identity per app.
|
||||
var customRoleDefinitionName = guid(subscription().id, customRoleName)
|
||||
var readerRoleDefinitionId = subscriptionResourceId(
|
||||
'Microsoft.Authorization/roleDefinitions',
|
||||
'acdd72a7-3385-48ef-bd42-f606fba81ae7' // built-in Reader
|
||||
)
|
||||
|
||||
// Microsoft Graph resources are provisioned through the `Microsoft.Graph`
|
||||
// Bicep extension. `apiVersion: v1.0` is the stable channel; the resource
|
||||
// names below are the type names Graph exposes for Bicep, not ARM.
|
||||
|
||||
extension microsoftGraphV1
|
||||
|
||||
// `uniqueName` on Microsoft.Graph/applications is tenant-scoped (an existing
|
||||
// value UPSERTs the resource), so two subscriptions in the same tenant
|
||||
// deploying with the default `applicationName` would otherwise overwrite
|
||||
// each other's keyCredentials and silently break the previous scan. Suffix
|
||||
// with the subscription id to keep each deployment isolated. The visible
|
||||
// display name stays as the user-provided `applicationName`.
|
||||
resource application 'Microsoft.Graph/applications@v1.0' = {
|
||||
uniqueName: '${applicationName}-${subscription().subscriptionId}'
|
||||
displayName: applicationName
|
||||
description: 'Deployed by Prowler quick-start Bicep template for read-only security scanning.'
|
||||
signInAudience: 'AzureADMyOrg'
|
||||
keyCredentials: [
|
||||
{
|
||||
displayName: certificateDisplayName
|
||||
type: 'AsymmetricX509Cert'
|
||||
usage: 'Verify'
|
||||
// Microsoft Graph computes `customKeyIdentifier` (the base64-encoded
|
||||
// SHA-1 thumbprint) from the certificate bytes automatically, so we
|
||||
// deliberately omit it. Passing the hex thumbprint here would fail the
|
||||
// keyCredentials contract, which expects base64-encoded bytes.
|
||||
key: certificateBase64
|
||||
startDateTime: certificateStartDateTime
|
||||
endDateTime: certificateEndDateTime
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
resource servicePrincipal 'Microsoft.Graph/servicePrincipals@v1.0' = {
|
||||
appId: application.appId
|
||||
displayName: applicationName
|
||||
}
|
||||
|
||||
// Custom role: only the two read/list actions the built-in Reader is missing.
|
||||
// Keep this list in sync with `permissions/prowler-azure-custom-role.json`.
|
||||
resource prowlerRole 'Microsoft.Authorization/roleDefinitions@2022-05-01-preview' = {
|
||||
name: customRoleDefinitionName
|
||||
properties: {
|
||||
roleName: customRoleName
|
||||
description: 'Role used by Prowler for checks that require read-only access to Azure resources beyond the built-in Reader role.'
|
||||
type: 'CustomRole'
|
||||
assignableScopes: [
|
||||
subscription().id
|
||||
]
|
||||
permissions: [
|
||||
{
|
||||
actions: [
|
||||
'Microsoft.Web/sites/host/listkeys/action'
|
||||
'Microsoft.Web/sites/config/list/Action'
|
||||
]
|
||||
notActions: []
|
||||
dataActions: []
|
||||
notDataActions: []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
resource readerAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
|
||||
name: guid(subscription().id, applicationName, 'Reader')
|
||||
properties: {
|
||||
principalId: servicePrincipal.id
|
||||
principalType: 'ServicePrincipal'
|
||||
roleDefinitionId: readerRoleDefinitionId
|
||||
}
|
||||
}
|
||||
|
||||
resource prowlerRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
|
||||
name: guid(subscription().id, applicationName, customRoleName)
|
||||
properties: {
|
||||
principalId: servicePrincipal.id
|
||||
principalType: 'ServicePrincipal'
|
||||
roleDefinitionId: prowlerRole.id
|
||||
}
|
||||
}
|
||||
|
||||
output tenantId string = subscription().tenantId
|
||||
output subscriptionId string = subscription().subscriptionId
|
||||
output applicationId string = application.appId
|
||||
output servicePrincipalObjectId string = servicePrincipal.id
|
||||
// Microsoft Graph populates `customKeyIdentifier` (the base64-encoded SHA-1
|
||||
// thumbprint) on the created keyCredential; expose it as an output so users
|
||||
// can reconcile the deployed cert against the fingerprint they generated
|
||||
// locally.
|
||||
output certificateThumbprint string = application.keyCredentials[0].customKeyIdentifier
|
||||
output prowlerRoleDefinitionId string = prowlerRole.id
|
||||
@@ -0,0 +1,175 @@
|
||||
{
|
||||
"$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#",
|
||||
"languageVersion": "2.0",
|
||||
"contentVersion": "1.0.0.0",
|
||||
"metadata": {
|
||||
"_generator": {
|
||||
"name": "bicep",
|
||||
"version": "0.46.1.21595",
|
||||
"templateHash": "6180354331557312661"
|
||||
}
|
||||
},
|
||||
"parameters": {
|
||||
"applicationName": {
|
||||
"type": "string",
|
||||
"defaultValue": "ProwlerApp",
|
||||
"metadata": {
|
||||
"description": "Display name of the Entra ID App Registration that Prowler will authenticate as."
|
||||
}
|
||||
},
|
||||
"certificateBase64": {
|
||||
"type": "securestring",
|
||||
"metadata": {
|
||||
"description": "Base64-encoded DER of the X.509 public certificate that Entra ID will pin to the App Registration as a keyCredential. Generate with `openssl x509 -outform DER | base64` (see README). The matching private key stays with the user — Prowler never receives it during deployment."
|
||||
}
|
||||
},
|
||||
"certificateDisplayName": {
|
||||
"type": "string",
|
||||
"defaultValue": "Prowler Certificate",
|
||||
"metadata": {
|
||||
"description": "Optional friendly display name for the certificate credential."
|
||||
}
|
||||
},
|
||||
"certificateStartDateTime": {
|
||||
"type": "string",
|
||||
"defaultValue": "[utcNow()]",
|
||||
"metadata": {
|
||||
"description": "Start date (ISO 8601) for the certificate credential validity window. Defaults to the deployment time."
|
||||
}
|
||||
},
|
||||
"certificateEndDateTime": {
|
||||
"type": "string",
|
||||
"defaultValue": "[dateTimeAdd(utcNow(), 'P1Y')]",
|
||||
"metadata": {
|
||||
"description": "End date (ISO 8601) for the certificate credential validity window. Defaults to one year after the deployment."
|
||||
}
|
||||
},
|
||||
"customRoleName": {
|
||||
"type": "string",
|
||||
"defaultValue": "ProwlerRole",
|
||||
"metadata": {
|
||||
"description": "Name of the custom role Prowler creates for its extra read actions."
|
||||
}
|
||||
}
|
||||
},
|
||||
"variables": {
|
||||
"customRoleDefinitionName": "[guid(subscription().id, parameters('customRoleName'))]",
|
||||
"readerRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'acdd72a7-3385-48ef-bd42-f606fba81ae7')]"
|
||||
},
|
||||
"imports": {
|
||||
"microsoftGraphV1": {
|
||||
"provider": "MicrosoftGraph",
|
||||
"version": "0.1.8-preview"
|
||||
}
|
||||
},
|
||||
"resources": {
|
||||
"application": {
|
||||
"import": "microsoftGraphV1",
|
||||
"type": "Microsoft.Graph/applications@v1.0",
|
||||
"properties": {
|
||||
"uniqueName": "[format('{0}-{1}', parameters('applicationName'), subscription().subscriptionId)]",
|
||||
"displayName": "[parameters('applicationName')]",
|
||||
"description": "Deployed by Prowler quick-start Bicep template for read-only security scanning.",
|
||||
"signInAudience": "AzureADMyOrg",
|
||||
"keyCredentials": [
|
||||
{
|
||||
"displayName": "[parameters('certificateDisplayName')]",
|
||||
"type": "AsymmetricX509Cert",
|
||||
"usage": "Verify",
|
||||
"key": "[parameters('certificateBase64')]",
|
||||
"startDateTime": "[parameters('certificateStartDateTime')]",
|
||||
"endDateTime": "[parameters('certificateEndDateTime')]"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"servicePrincipal": {
|
||||
"import": "microsoftGraphV1",
|
||||
"type": "Microsoft.Graph/servicePrincipals@v1.0",
|
||||
"properties": {
|
||||
"appId": "[reference('application').appId]",
|
||||
"displayName": "[parameters('applicationName')]"
|
||||
},
|
||||
"dependsOn": [
|
||||
"application"
|
||||
]
|
||||
},
|
||||
"prowlerRole": {
|
||||
"type": "Microsoft.Authorization/roleDefinitions",
|
||||
"apiVersion": "2022-05-01-preview",
|
||||
"name": "[variables('customRoleDefinitionName')]",
|
||||
"properties": {
|
||||
"roleName": "[parameters('customRoleName')]",
|
||||
"description": "Role used by Prowler for checks that require read-only access to Azure resources beyond the built-in Reader role.",
|
||||
"type": "CustomRole",
|
||||
"assignableScopes": [
|
||||
"[subscription().id]"
|
||||
],
|
||||
"permissions": [
|
||||
{
|
||||
"actions": [
|
||||
"Microsoft.Web/sites/host/listkeys/action",
|
||||
"Microsoft.Web/sites/config/list/Action"
|
||||
],
|
||||
"notActions": [],
|
||||
"dataActions": [],
|
||||
"notDataActions": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"readerAssignment": {
|
||||
"type": "Microsoft.Authorization/roleAssignments",
|
||||
"apiVersion": "2022-04-01",
|
||||
"name": "[guid(subscription().id, parameters('applicationName'), 'Reader')]",
|
||||
"properties": {
|
||||
"principalId": "[reference('servicePrincipal').id]",
|
||||
"principalType": "ServicePrincipal",
|
||||
"roleDefinitionId": "[variables('readerRoleDefinitionId')]"
|
||||
},
|
||||
"dependsOn": [
|
||||
"servicePrincipal"
|
||||
]
|
||||
},
|
||||
"prowlerRoleAssignment": {
|
||||
"type": "Microsoft.Authorization/roleAssignments",
|
||||
"apiVersion": "2022-04-01",
|
||||
"name": "[guid(subscription().id, parameters('applicationName'), parameters('customRoleName'))]",
|
||||
"properties": {
|
||||
"principalId": "[reference('servicePrincipal').id]",
|
||||
"principalType": "ServicePrincipal",
|
||||
"roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('customRoleDefinitionName'))]"
|
||||
},
|
||||
"dependsOn": [
|
||||
"prowlerRole",
|
||||
"servicePrincipal"
|
||||
]
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"tenantId": {
|
||||
"type": "string",
|
||||
"value": "[subscription().tenantId]"
|
||||
},
|
||||
"subscriptionId": {
|
||||
"type": "string",
|
||||
"value": "[subscription().subscriptionId]"
|
||||
},
|
||||
"applicationId": {
|
||||
"type": "string",
|
||||
"value": "[reference('application').appId]"
|
||||
},
|
||||
"servicePrincipalObjectId": {
|
||||
"type": "string",
|
||||
"value": "[reference('servicePrincipal').id]"
|
||||
},
|
||||
"certificateThumbprint": {
|
||||
"type": "string",
|
||||
"value": "[reference('application').keyCredentials[0].customKeyIdentifier]"
|
||||
},
|
||||
"prowlerRoleDefinitionId": {
|
||||
"type": "string",
|
||||
"value": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('customRoleDefinitionName'))]"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Add certificate-based service principal authentication to the Azure provider. `AzureProvider` now accepts a `keyCredentials` certificate — as base64 content or file path — and authenticates via `azure.identity.CertificateCredential`, mirroring the existing M365 provider flow
|
||||
@@ -1,16 +1,18 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from argparse import ArgumentTypeError
|
||||
from itertools import chain
|
||||
from os import getenv
|
||||
from typing import Union
|
||||
from typing import Optional, Union
|
||||
from uuid import UUID
|
||||
|
||||
import requests
|
||||
from azure.core.exceptions import ClientAuthenticationError, HttpResponseError
|
||||
from azure.identity import (
|
||||
CertificateCredential,
|
||||
ClientSecretCredential,
|
||||
CredentialUnavailableError,
|
||||
DefaultAzureCredential,
|
||||
@@ -19,6 +21,10 @@ from azure.identity import (
|
||||
from azure.mgmt.resource import ResourceManagementClient
|
||||
from azure.mgmt.subscription import SubscriptionClient
|
||||
from colorama import Fore, Style
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||
from msgraph import GraphServiceClient
|
||||
|
||||
from prowler.config.config import (
|
||||
@@ -44,6 +50,8 @@ from prowler.providers.azure.exceptions.exceptions import (
|
||||
AzureNoAuthenticationMethodError,
|
||||
AzureNoSubscriptionsError,
|
||||
AzureNotTenantIdButClientIdAndClienSecretError,
|
||||
AzureNotValidCertificateContentError,
|
||||
AzureNotValidCertificatePathError,
|
||||
AzureNotValidClientIdError,
|
||||
AzureNotValidClientSecretError,
|
||||
AzureNotValidTenantIdError,
|
||||
@@ -61,6 +69,36 @@ from prowler.providers.azure.models import AzureIdentityInfo, AzureRegionConfig
|
||||
from prowler.providers.common.models import Audit_Metadata, Connection
|
||||
from prowler.providers.common.provider import Provider
|
||||
|
||||
# Attribute we stash on `CertificateCredential` instances so `setup_identity`
|
||||
# can surface the certificate SHA-1 thumbprint without reaching into
|
||||
# azure-identity's private `_client_credential` dict (which can — and does —
|
||||
# change shape across azure-identity versions).
|
||||
_PROWLER_CERT_THUMBPRINT_ATTR = "_prowler_certificate_thumbprint"
|
||||
|
||||
|
||||
def _compute_certificate_thumbprint(cert_data: bytes) -> Optional[str]:
|
||||
"""Compute the SHA-1 thumbprint of an X.509 certificate.
|
||||
|
||||
Accepts the same certificate_data formats `azure.identity.CertificateCredential`
|
||||
accepts (PEM, DER, or PKCS#12/PFX with the private key) and returns the
|
||||
thumbprint as an uppercase hex string, matching how Entra ID displays it
|
||||
in the App Registration blade. Returns None if none of the parsers can
|
||||
read the bytes so the caller can fall back to a placeholder.
|
||||
"""
|
||||
for loader in (
|
||||
lambda data: pkcs12.load_key_and_certificates(data, None, default_backend())[1],
|
||||
lambda data: x509.load_pem_x509_certificate(data, default_backend()),
|
||||
lambda data: x509.load_der_x509_certificate(data, default_backend()),
|
||||
):
|
||||
try:
|
||||
cert = loader(cert_data)
|
||||
except Exception:
|
||||
continue
|
||||
if cert is None:
|
||||
continue
|
||||
return cert.fingerprint(hashes.SHA1()).hex().upper()
|
||||
return None
|
||||
|
||||
|
||||
class AzureProvider(Provider):
|
||||
"""
|
||||
@@ -125,6 +163,9 @@ class AzureProvider(Provider):
|
||||
mutelist_content: dict = None,
|
||||
client_id: str = None,
|
||||
client_secret: str = None,
|
||||
certificate_auth: bool = False,
|
||||
certificate_content: str = None,
|
||||
certificate_path: str = None,
|
||||
resource_groups: list = [],
|
||||
):
|
||||
"""
|
||||
@@ -145,6 +186,9 @@ class AzureProvider(Provider):
|
||||
mutelist_content (dict): The mutelist content.
|
||||
client_id (str): The Azure client ID.
|
||||
client_secret (str): The Azure client secret.
|
||||
certificate_auth (bool): Flag indicating whether to use certificate authentication with environment variables (AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CERTIFICATE_CONTENT).
|
||||
certificate_content (str): Base64-encoded certificate content bound to the app registration's `keyCredentials`.
|
||||
certificate_path (str): Path to a certificate file bound to the app registration's `keyCredentials`.
|
||||
resource_groups (list): List of resource group names.
|
||||
|
||||
Returns:
|
||||
@@ -239,9 +283,12 @@ class AzureProvider(Provider):
|
||||
sp_env_auth,
|
||||
browser_auth,
|
||||
managed_identity_auth,
|
||||
certificate_auth,
|
||||
tenant_id,
|
||||
client_id,
|
||||
client_secret,
|
||||
certificate_content,
|
||||
certificate_path,
|
||||
)
|
||||
|
||||
logger.info("Checking if region is different than default one")
|
||||
@@ -249,11 +296,17 @@ class AzureProvider(Provider):
|
||||
|
||||
# Get the dict from the static credentials
|
||||
azure_credentials = None
|
||||
if tenant_id and client_id and client_secret:
|
||||
if (
|
||||
tenant_id
|
||||
and client_id
|
||||
and (client_secret or certificate_content or certificate_path)
|
||||
):
|
||||
azure_credentials = self.validate_static_credentials(
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
certificate_content=certificate_content,
|
||||
certificate_path=certificate_path,
|
||||
region_config=self._region_config,
|
||||
)
|
||||
|
||||
@@ -263,6 +316,8 @@ class AzureProvider(Provider):
|
||||
sp_env_auth,
|
||||
browser_auth,
|
||||
managed_identity_auth,
|
||||
certificate_auth,
|
||||
certificate_path,
|
||||
tenant_id,
|
||||
azure_credentials,
|
||||
self._region_config,
|
||||
@@ -274,6 +329,7 @@ class AzureProvider(Provider):
|
||||
sp_env_auth,
|
||||
browser_auth,
|
||||
managed_identity_auth,
|
||||
certificate_auth,
|
||||
subscription_ids,
|
||||
client_id,
|
||||
)
|
||||
@@ -361,9 +417,12 @@ class AzureProvider(Provider):
|
||||
sp_env_auth: bool,
|
||||
browser_auth: bool,
|
||||
managed_identity_auth: bool,
|
||||
certificate_auth: bool,
|
||||
tenant_id: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
certificate_content: str,
|
||||
certificate_path: str,
|
||||
):
|
||||
"""
|
||||
Validates the authentication arguments for the Azure provider.
|
||||
@@ -373,15 +432,23 @@ class AzureProvider(Provider):
|
||||
sp_env_auth (bool): Flag indicating whether Service Principal environment authentication is enabled.
|
||||
browser_auth (bool): Flag indicating whether browser authentication is enabled.
|
||||
managed_identity_auth (bool): Flag indicating whether managed identity authentication is enabled.
|
||||
certificate_auth (bool): Flag indicating whether certificate authentication is enabled.
|
||||
tenant_id (str): The Azure Tenant ID.
|
||||
client_id (str): The Azure Client ID.
|
||||
client_secret (str): The Azure Client Secret.
|
||||
certificate_content (str): The base64-encoded Azure certificate content.
|
||||
certificate_path (str): The path to the Azure certificate file.
|
||||
|
||||
Raises:
|
||||
AzureBrowserAuthNoTenantIDError: If browser authentication is enabled but the tenant ID is not found.
|
||||
"""
|
||||
|
||||
if not client_id and not client_secret:
|
||||
if (
|
||||
not client_id
|
||||
and not client_secret
|
||||
and not certificate_content
|
||||
and not certificate_path
|
||||
):
|
||||
if not browser_auth and tenant_id:
|
||||
raise AzureTenantIDNoBrowserAuthError(
|
||||
file=os.path.basename(__file__),
|
||||
@@ -392,10 +459,11 @@ class AzureProvider(Provider):
|
||||
and not sp_env_auth
|
||||
and not browser_auth
|
||||
and not managed_identity_auth
|
||||
and not certificate_auth
|
||||
):
|
||||
raise AzureNoAuthenticationMethodError(
|
||||
file=os.path.basename(__file__),
|
||||
message="Azure provider requires at least one authentication method set: [--az-cli-auth | --sp-env-auth | --browser-auth | --managed-identity-auth]",
|
||||
message="Azure provider requires at least one authentication method set: [--az-cli-auth | --sp-env-auth | --browser-auth | --managed-identity-auth | --certificate-auth]",
|
||||
)
|
||||
elif browser_auth and not tenant_id:
|
||||
raise AzureBrowserAuthNoTenantIDError(
|
||||
@@ -408,6 +476,25 @@ class AzureProvider(Provider):
|
||||
file=os.path.basename(__file__),
|
||||
message="Tenant Id is required for Azure static credentials. Make sure you are using the correct credentials.",
|
||||
)
|
||||
if not client_secret and not certificate_content and not certificate_path:
|
||||
raise AzureConfigCredentialsError(
|
||||
file=os.path.basename(__file__),
|
||||
message="You must provide a client secret, certificate content or certificate path for Azure static credentials.",
|
||||
)
|
||||
# Client secret and certificate are mutually exclusive: `setup_session`
|
||||
# short-circuits on the certificate branch and silently drops the
|
||||
# secret, which is confusing. Fail fast at argument-validation time.
|
||||
if client_secret and (certificate_content or certificate_path):
|
||||
raise AzureConfigCredentialsError(
|
||||
file=os.path.basename(__file__),
|
||||
message="Provide either a client secret or a certificate (content/path) for Azure static credentials, not both.",
|
||||
)
|
||||
# Certificate content and path are also mutually exclusive.
|
||||
if certificate_content and certificate_path:
|
||||
raise AzureConfigCredentialsError(
|
||||
file=os.path.basename(__file__),
|
||||
message="Provide either certificate content or a certificate path, not both.",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def setup_region_config(region):
|
||||
@@ -474,6 +561,10 @@ class AzureProvider(Provider):
|
||||
f"Azure Resource Groups: {Fore.YELLOW}{sorted({rg for rgs in self._resource_groups.values() for rg in rgs}) if any(self._resource_groups.values()) else ('NONE (no matching resource groups found)' if self._resource_groups else 'ALL')}{Style.RESET_ALL}",
|
||||
f"Azure Identity Type: {Fore.YELLOW}{self._identity.identity_type}{Style.RESET_ALL} Azure Identity ID: {Fore.YELLOW}{self._identity.identity_id}{Style.RESET_ALL}",
|
||||
]
|
||||
if self._identity.certificate_thumbprint:
|
||||
report_lines.append(
|
||||
f"Azure Certificate Thumbprint: {Fore.YELLOW}{self._identity.certificate_thumbprint}{Style.RESET_ALL}"
|
||||
)
|
||||
report_title = (
|
||||
f"{Style.BRIGHT}Using the Azure credentials below:{Style.RESET_ALL}"
|
||||
)
|
||||
@@ -487,6 +578,8 @@ class AzureProvider(Provider):
|
||||
sp_env_auth: bool,
|
||||
browser_auth: bool,
|
||||
managed_identity_auth: bool,
|
||||
certificate_auth: bool,
|
||||
certificate_path: str,
|
||||
tenant_id: str,
|
||||
azure_credentials: dict,
|
||||
region_config: AzureRegionConfig,
|
||||
@@ -500,11 +593,15 @@ class AzureProvider(Provider):
|
||||
sp_env_auth (bool): Flag indicating whether to use Service Principal authentication with environment variables.
|
||||
browser_auth (bool): Flag indicating whether to use interactive browser authentication.
|
||||
managed_identity_auth (bool): Flag indicating whether to use managed identity authentication.
|
||||
certificate_auth (bool): Flag indicating whether to use certificate authentication with environment variables.
|
||||
certificate_path (str): Path to a certificate file used when certificate_auth is enabled and certificate content is not supplied via env var.
|
||||
tenant_id (str): The Azure Active Directory tenant ID.
|
||||
azure_credentials (dict): The Azure configuration object. It contains the following keys:
|
||||
- tenant_id: The Azure Active Directory tenant ID.
|
||||
- client_id: The Azure client ID.
|
||||
- client_secret: The Azure client secret
|
||||
- client_secret: The Azure client secret.
|
||||
- certificate_content: The base64-encoded Azure certificate content.
|
||||
- certificate_path: The path to the Azure certificate file.
|
||||
region_config (AzureRegionConfig): The region configuration object.
|
||||
|
||||
Returns:
|
||||
@@ -524,15 +621,57 @@ class AzureProvider(Provider):
|
||||
f"{environment_credentials_error.__class__.__name__}[{environment_credentials_error.__traceback__.tb_lineno}] -- {environment_credentials_error}"
|
||||
)
|
||||
raise environment_credentials_error
|
||||
elif certificate_auth:
|
||||
try:
|
||||
AzureProvider.check_certificate_creds_env_vars(
|
||||
check_certificate_content=not certificate_path
|
||||
)
|
||||
except AzureEnvironmentVariableError as environment_variable_error:
|
||||
logger.critical(
|
||||
f"{environment_variable_error.__class__.__name__}[{environment_variable_error.__traceback__.tb_lineno}] -- {environment_variable_error}"
|
||||
)
|
||||
raise environment_variable_error
|
||||
try:
|
||||
if azure_credentials:
|
||||
try:
|
||||
credentials = ClientSecretCredential(
|
||||
tenant_id=azure_credentials["tenant_id"],
|
||||
client_id=azure_credentials["client_id"],
|
||||
client_secret=azure_credentials["client_secret"],
|
||||
authority=region_config.authority,
|
||||
)
|
||||
if azure_credentials.get("certificate_content"):
|
||||
certificate_data = base64.b64decode(
|
||||
azure_credentials["certificate_content"]
|
||||
)
|
||||
credentials = CertificateCredential(
|
||||
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),
|
||||
)
|
||||
elif azure_credentials.get("certificate_path"):
|
||||
with open(
|
||||
azure_credentials["certificate_path"], "rb"
|
||||
) as cert_file:
|
||||
certificate_data = cert_file.read()
|
||||
credentials = CertificateCredential(
|
||||
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"],
|
||||
client_id=azure_credentials["client_id"],
|
||||
client_secret=azure_credentials["client_secret"],
|
||||
authority=region_config.authority,
|
||||
)
|
||||
return credentials
|
||||
except ClientAuthenticationError as error:
|
||||
logger.error(
|
||||
@@ -555,6 +694,34 @@ class AzureProvider(Provider):
|
||||
raise AzureConfigCredentialsError(
|
||||
file=os.path.basename(__file__), original_exception=error
|
||||
)
|
||||
elif certificate_auth:
|
||||
try:
|
||||
if certificate_path:
|
||||
with open(certificate_path, "rb") as cert_file:
|
||||
certificate_data = cert_file.read()
|
||||
else:
|
||||
certificate_data = base64.b64decode(
|
||||
getenv("AZURE_CERTIFICATE_CONTENT")
|
||||
)
|
||||
credentials = CertificateCredential(
|
||||
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(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
|
||||
)
|
||||
raise AzureClientAuthenticationError(
|
||||
file=os.path.basename(__file__), original_exception=error
|
||||
)
|
||||
else:
|
||||
# Since the authentication method to be used will come as True, we have to negate it since
|
||||
# DefaultAzureCredential sets just one authentication method, excluding the others
|
||||
@@ -632,6 +799,13 @@ class AzureProvider(Provider):
|
||||
raise_on_exception=True,
|
||||
client_id=None,
|
||||
client_secret=None,
|
||||
# Certificate-based auth is keyword-only so callers cannot
|
||||
# accidentally bind `provider_id` (which used to sit right after
|
||||
# `client_secret`) to any of these when calling positionally.
|
||||
*,
|
||||
certificate_auth: bool = False,
|
||||
certificate_content: str = None,
|
||||
certificate_path: str = None,
|
||||
provider_id=None,
|
||||
) -> Connection:
|
||||
"""Test connection to Azure subscription.
|
||||
@@ -677,19 +851,28 @@ class AzureProvider(Provider):
|
||||
sp_env_auth,
|
||||
browser_auth,
|
||||
managed_identity_auth,
|
||||
certificate_auth,
|
||||
tenant_id,
|
||||
client_id,
|
||||
client_secret,
|
||||
certificate_content,
|
||||
certificate_path,
|
||||
)
|
||||
region_config = AzureProvider.setup_region_config(region)
|
||||
|
||||
# Get the dict from the static credentials
|
||||
azure_credentials = None
|
||||
if tenant_id and client_id and client_secret:
|
||||
if (
|
||||
tenant_id
|
||||
and client_id
|
||||
and (client_secret or certificate_content or certificate_path)
|
||||
):
|
||||
azure_credentials = AzureProvider.validate_static_credentials(
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
certificate_content=certificate_content,
|
||||
certificate_path=certificate_path,
|
||||
region_config=region_config,
|
||||
)
|
||||
|
||||
@@ -699,6 +882,8 @@ class AzureProvider(Provider):
|
||||
sp_env_auth,
|
||||
browser_auth,
|
||||
managed_identity_auth,
|
||||
certificate_auth,
|
||||
certificate_path,
|
||||
tenant_id,
|
||||
azure_credentials,
|
||||
region_config,
|
||||
@@ -890,12 +1075,43 @@ class AzureProvider(Provider):
|
||||
message=f"Missing environment variable {env_var} required to authenticate.",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def check_certificate_creds_env_vars(check_certificate_content: bool):
|
||||
"""
|
||||
Checks the presence of required environment variables for certificate-based
|
||||
service principal authentication against Azure.
|
||||
|
||||
This method checks for the presence of the following environment variables:
|
||||
- AZURE_CLIENT_ID: Azure client ID
|
||||
- AZURE_TENANT_ID: Azure tenant ID
|
||||
- AZURE_CERTIFICATE_CONTENT: base64-encoded certificate content (only
|
||||
required when a certificate file path is not provided)
|
||||
|
||||
Raises:
|
||||
AzureEnvironmentVariableError: If any required environment variable
|
||||
is missing.
|
||||
"""
|
||||
logger.info("Azure provider: checking certificate environment variables ...")
|
||||
env_vars = ["AZURE_CLIENT_ID", "AZURE_TENANT_ID"]
|
||||
if check_certificate_content:
|
||||
env_vars.append("AZURE_CERTIFICATE_CONTENT")
|
||||
for env_var in env_vars:
|
||||
if not getenv(env_var):
|
||||
logger.critical(
|
||||
f"Azure provider: Missing environment variable {env_var} needed to authenticate against Azure"
|
||||
)
|
||||
raise AzureEnvironmentVariableError(
|
||||
file=os.path.basename(__file__),
|
||||
message=f"Missing environment variable {env_var} required to authenticate.",
|
||||
)
|
||||
|
||||
def setup_identity(
|
||||
self,
|
||||
az_cli_auth,
|
||||
sp_env_auth,
|
||||
browser_auth,
|
||||
managed_identity_auth,
|
||||
certificate_auth,
|
||||
subscription_ids,
|
||||
client_id,
|
||||
):
|
||||
@@ -920,7 +1136,7 @@ class AzureProvider(Provider):
|
||||
# the identity can access AAD and retrieve the tenant domain name.
|
||||
# With cli also should be possible but right now it does not work, azure python package issue is coming
|
||||
# At the time of writting this with az cli creds is not working, despite that is included
|
||||
if sp_env_auth or browser_auth or az_cli_auth or client_id:
|
||||
if sp_env_auth or browser_auth or az_cli_auth or certificate_auth or client_id:
|
||||
|
||||
async def get_azure_identity():
|
||||
# Trying to recover tenant domain info
|
||||
@@ -957,7 +1173,23 @@ class AzureProvider(Provider):
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
|
||||
)
|
||||
# since that exception is not considered as critical, we keep filling another identity fields
|
||||
if sp_env_auth or client_id:
|
||||
if isinstance(credentials, CertificateCredential):
|
||||
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
|
||||
# `setup_session` time and stashed on the credential via
|
||||
# `_PROWLER_CERT_THUMBPRINT_ATTR`. This avoids the older
|
||||
# pattern of reaching into `credentials._client_credential`,
|
||||
# which is azure-identity private state and drifts across
|
||||
# library versions.
|
||||
identity.certificate_thumbprint = (
|
||||
getattr(credentials, _PROWLER_CERT_THUMBPRINT_ATTR, None)
|
||||
or "Unknown certificate thumbprint"
|
||||
)
|
||||
elif sp_env_auth or client_id:
|
||||
# The id of the sp can be retrieved from environment variables
|
||||
identity.identity_id = getenv("AZURE_CLIENT_ID", default=client_id)
|
||||
identity.identity_type = "Service Principal"
|
||||
@@ -1186,6 +1418,8 @@ class AzureProvider(Provider):
|
||||
tenant_id: str = None,
|
||||
client_id: str = None,
|
||||
client_secret: str = None,
|
||||
certificate_content: str = None,
|
||||
certificate_path: str = None,
|
||||
region_config: AzureRegionConfig = None,
|
||||
) -> dict:
|
||||
"""
|
||||
@@ -1195,6 +1429,8 @@ class AzureProvider(Provider):
|
||||
tenant_id (str): The Azure Active Directory tenant ID.
|
||||
client_id (str): The Azure client ID.
|
||||
client_secret (str): The Azure client secret.
|
||||
certificate_content (str): The base64-encoded Azure certificate content.
|
||||
certificate_path (str): The path to the Azure certificate file.
|
||||
region_config (AzureRegionConfig): The region configuration used to
|
||||
build the per-cloud login endpoint and Graph scope. Defaults to
|
||||
the public-cloud configuration when not provided.
|
||||
@@ -1203,6 +1439,8 @@ class AzureProvider(Provider):
|
||||
AzureNotValidTenantIdError: If the provided Azure Tenant ID is not valid.
|
||||
AzureNotValidClientIdError: If the provided Azure Client ID is not valid.
|
||||
AzureNotValidClientSecretError: If the provided Azure Client Secret is not valid.
|
||||
AzureNotValidCertificateContentError: If the provided base64 certificate content is not valid.
|
||||
AzureNotValidCertificatePathError: If the provided certificate path cannot be read.
|
||||
AzureClientIdAndClientSecretNotBelongingToTenantIdError: If the provided Azure Client ID and Client Secret do not belong to the specified Tenant ID.
|
||||
AzureTenantIdAndClientSecretNotBelongingToClientIdError: If the provided Azure Tenant ID and Client Secret do not belong to the specified Client ID.
|
||||
AzureTenantIdAndClientIdNotBelongingToClientSecretError: If the provided Azure Tenant ID and Client ID do not belong to the specified Client Secret.
|
||||
@@ -1227,24 +1465,63 @@ class AzureProvider(Provider):
|
||||
file=os.path.basename(__file__),
|
||||
message="The provided Azure Client ID is not valid.",
|
||||
)
|
||||
# Validate the Client Secret
|
||||
if not re.match("^[a-zA-Z0-9._~-]+$", client_secret):
|
||||
|
||||
if not client_secret and not certificate_content and not certificate_path:
|
||||
raise AzureNotValidClientSecretError(
|
||||
file=os.path.basename(__file__),
|
||||
message="You must provide a client secret, certificate content or certificate path. Please check your credentials and try again.",
|
||||
)
|
||||
|
||||
# Validate the Client Secret only when using the client-secret path.
|
||||
# For certificate auth this check must be skipped so the None value
|
||||
# does not trip the regex.
|
||||
if client_secret and not re.match("^[a-zA-Z0-9._~-]+$", client_secret):
|
||||
raise AzureNotValidClientSecretError(
|
||||
file=os.path.basename(__file__),
|
||||
message="The provided Azure Client Secret is not valid.",
|
||||
)
|
||||
|
||||
if certificate_content:
|
||||
try:
|
||||
# Confirm the payload is valid base64 before handing it off to
|
||||
# azure-identity: `CertificateCredential` raises an opaque
|
||||
# exception several call frames deeper if this fails, which
|
||||
# makes for a bad UX in the API/UI.
|
||||
base64.b64decode(certificate_content, validate=True)
|
||||
except Exception as e:
|
||||
raise AzureNotValidCertificateContentError(
|
||||
file=os.path.basename(__file__),
|
||||
message=f"The provided certificate content is not valid base64 encoded data: {str(e)}",
|
||||
)
|
||||
|
||||
if certificate_path:
|
||||
try:
|
||||
with open(certificate_path, "rb") as cert_file:
|
||||
cert_file.read()
|
||||
except Exception as e:
|
||||
raise AzureNotValidCertificatePathError(
|
||||
file=os.path.basename(__file__),
|
||||
message=f"The provided certificate path is not valid: {str(e)}",
|
||||
)
|
||||
|
||||
if region_config is None:
|
||||
region_config = AzureProvider.setup_region_config("AzureCloud")
|
||||
|
||||
try:
|
||||
AzureProvider.verify_client(
|
||||
tenant_id, client_id, client_secret, region_config
|
||||
tenant_id,
|
||||
client_id,
|
||||
client_secret,
|
||||
region_config,
|
||||
certificate_content=certificate_content,
|
||||
certificate_path=certificate_path,
|
||||
)
|
||||
return {
|
||||
"tenant_id": tenant_id,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"certificate_content": certificate_content,
|
||||
"certificate_path": certificate_path,
|
||||
}
|
||||
except AzureNotValidTenantIdError as tenant_id_error:
|
||||
logger.error(
|
||||
@@ -1273,10 +1550,16 @@ class AzureProvider(Provider):
|
||||
|
||||
@staticmethod
|
||||
def verify_client(
|
||||
tenant_id, client_id, client_secret, region_config: AzureRegionConfig = None
|
||||
tenant_id,
|
||||
client_id,
|
||||
client_secret,
|
||||
region_config: AzureRegionConfig = None,
|
||||
certificate_content: str = None,
|
||||
certificate_path: str = None,
|
||||
) -> None:
|
||||
"""
|
||||
Verifies the Azure client credentials using the specified tenant ID, client ID, and client secret.
|
||||
Verifies the Azure client credentials using the specified tenant ID, client ID, and either
|
||||
a client secret or a certificate.
|
||||
|
||||
Args:
|
||||
tenant_id (str): The Azure Active Directory tenant ID.
|
||||
@@ -1285,49 +1568,100 @@ class AzureProvider(Provider):
|
||||
region_config (AzureRegionConfig): The region configuration used to
|
||||
build the per-cloud login endpoint and Graph scope. Defaults to
|
||||
the public-cloud configuration when not provided.
|
||||
certificate_content (str): The base64-encoded Azure certificate content.
|
||||
certificate_path (str): The path to the Azure certificate file.
|
||||
|
||||
Raises:
|
||||
AzureNotValidTenantIdError: If the provided Azure Tenant ID is not valid.
|
||||
AzureNotValidClientIdError: If the provided Azure Client ID is not valid.
|
||||
AzureNotValidClientSecretError: If the provided Azure Client Secret is not valid.
|
||||
AzureNotValidCertificateContentError: If the provided certificate content cannot obtain a token.
|
||||
AzureNotValidCertificatePathError: If the provided certificate file cannot obtain a token.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
if region_config is None:
|
||||
region_config = AzureProvider.setup_region_config("AzureCloud")
|
||||
# `authority` is None for the public cloud and a bare host (e.g.
|
||||
# `login.chinacloudapi.cn`) for sovereign clouds, mirroring the
|
||||
# `AzureAuthorityHosts` constants used by azure-identity.
|
||||
login_endpoint = region_config.authority or "login.microsoftonline.com"
|
||||
url = f"https://{login_endpoint}/{tenant_id}/oauth2/v2.0/token"
|
||||
headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
data = {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"scope": region_config.graph_scope,
|
||||
}
|
||||
response = requests.post(url, headers=headers, data=data).json()
|
||||
if "access_token" not in response.keys() and "error_codes" in response.keys():
|
||||
if f"Tenant '{tenant_id}'" in response["error_description"]:
|
||||
raise AzureNotValidTenantIdError(
|
||||
file=os.path.basename(__file__),
|
||||
message="The provided Azure Tenant ID is not valid for the specified Client ID and Client Secret.",
|
||||
)
|
||||
|
||||
if client_secret:
|
||||
# `authority` is None for the public cloud and a bare host (e.g.
|
||||
# `login.chinacloudapi.cn`) for sovereign clouds, mirroring the
|
||||
# `AzureAuthorityHosts` constants used by azure-identity.
|
||||
login_endpoint = region_config.authority or "login.microsoftonline.com"
|
||||
url = f"https://{login_endpoint}/{tenant_id}/oauth2/v2.0/token"
|
||||
headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
data = {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"scope": region_config.graph_scope,
|
||||
}
|
||||
response = requests.post(url, headers=headers, data=data).json()
|
||||
if (
|
||||
f"Application with identifier '{client_id}'"
|
||||
in response["error_description"]
|
||||
"access_token" not in response.keys()
|
||||
and "error_codes" in response.keys()
|
||||
):
|
||||
raise AzureNotValidClientIdError(
|
||||
if f"Tenant '{tenant_id}'" in response["error_description"]:
|
||||
raise AzureNotValidTenantIdError(
|
||||
file=os.path.basename(__file__),
|
||||
message="The provided Azure Tenant ID is not valid for the specified Client ID and Client Secret.",
|
||||
)
|
||||
if (
|
||||
f"Application with identifier '{client_id}'"
|
||||
in response["error_description"]
|
||||
):
|
||||
raise AzureNotValidClientIdError(
|
||||
file=os.path.basename(__file__),
|
||||
message="The provided Azure Client ID is not valid for the specified Tenant ID and Client Secret.",
|
||||
)
|
||||
if "Invalid client secret provided" in response["error_description"]:
|
||||
raise AzureNotValidClientSecretError(
|
||||
file=os.path.basename(__file__),
|
||||
message="The provided Azure Client Secret is not valid for the specified Tenant ID and Client ID.",
|
||||
)
|
||||
return
|
||||
|
||||
# Certificate-based flows: instantiate a `CertificateCredential` and
|
||||
# attempt a Graph call. If the credential itself is invalid, msal
|
||||
# raises inside the constructor. If it is valid but the app has no
|
||||
# Graph permissions, `get_token` still returns a token — the point
|
||||
# here is to prove the private key matches an active `keyCredentials`
|
||||
# entry on the app registration.
|
||||
try:
|
||||
if certificate_content:
|
||||
certificate_data = base64.b64decode(certificate_content)
|
||||
elif certificate_path:
|
||||
with open(certificate_path, "rb") as cert_file:
|
||||
certificate_data = cert_file.read()
|
||||
else:
|
||||
return
|
||||
|
||||
credential = CertificateCredential(
|
||||
client_id=client_id,
|
||||
tenant_id=tenant_id,
|
||||
certificate_data=certificate_data,
|
||||
authority=region_config.authority,
|
||||
)
|
||||
# Acquire a token to force msal to validate the certificate
|
||||
# against Entra ID. A failure raises `ClientAuthenticationError`,
|
||||
# which we translate into the specific certificate error below.
|
||||
credential.get_token(region_config.graph_scope)
|
||||
except ClientAuthenticationError as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
|
||||
)
|
||||
if certificate_content:
|
||||
raise AzureNotValidCertificateContentError(
|
||||
file=os.path.basename(__file__),
|
||||
message="The provided Azure Client ID is not valid for the specified Tenant ID and Client Secret.",
|
||||
)
|
||||
if "Invalid client secret provided" in response["error_description"]:
|
||||
raise AzureNotValidClientSecretError(
|
||||
file=os.path.basename(__file__),
|
||||
message="The provided Azure Client Secret is not valid for the specified Tenant ID and Client ID.",
|
||||
original_exception=error,
|
||||
message="The provided certificate content is not valid for the specified Tenant ID and Client ID.",
|
||||
)
|
||||
raise AzureNotValidCertificatePathError(
|
||||
file=os.path.basename(__file__),
|
||||
original_exception=error,
|
||||
message="The provided certificate is not valid for the specified Tenant ID and Client ID.",
|
||||
)
|
||||
@@ -102,6 +102,14 @@ class AzureBaseException(ProwlerException):
|
||||
"message": "The provided provider_id does not match with the available subscriptions",
|
||||
"remediation": "Check the provider_id and ensure it is a valid subscription for the given credentials.",
|
||||
},
|
||||
(2024, "AzureNotValidCertificateContentError"): {
|
||||
"message": "The provided certificate content is not valid",
|
||||
"remediation": "Check that the certificate content is a valid base64-encoded PEM or PFX bound to the app registration's keyCredentials.",
|
||||
},
|
||||
(2025, "AzureNotValidCertificatePathError"): {
|
||||
"message": "The provided certificate path is not valid",
|
||||
"remediation": "Check that the certificate file exists, is readable, and matches an entry in the app registration's keyCredentials.",
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, code, file=None, original_exception=None, message=None):
|
||||
@@ -291,3 +299,17 @@ class AzureInvalidProviderIdError(AzureBaseException):
|
||||
super().__init__(
|
||||
2023, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class AzureNotValidCertificateContentError(AzureCredentialsError):
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
2024, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class AzureNotValidCertificatePathError(AzureCredentialsError):
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
2025, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
@@ -11,10 +11,18 @@ class AzureIdentityInfo(BaseModel):
|
||||
identity_type: str = ""
|
||||
tenant_ids: list[str] = []
|
||||
tenant_domain: str = "Unknown tenant domain (missing AAD permissions)"
|
||||
certificate_thumbprint: str = ""
|
||||
subscriptions: dict = {}
|
||||
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
|
||||
|
||||
@@ -16,9 +16,13 @@ from prowler.config.config import (
|
||||
from prowler.providers.azure.azure_provider import AzureProvider
|
||||
from prowler.providers.azure.exceptions.exceptions import (
|
||||
AzureBrowserAuthNoTenantIDError,
|
||||
AzureConfigCredentialsError,
|
||||
AzureEnvironmentVariableError,
|
||||
AzureHTTPResponseError,
|
||||
AzureInvalidProviderIdError,
|
||||
AzureNoAuthenticationMethodError,
|
||||
AzureNotValidCertificateContentError,
|
||||
AzureNotValidCertificatePathError,
|
||||
AzureTenantIDNoBrowserAuthError,
|
||||
)
|
||||
from prowler.providers.azure.models import AzureIdentityInfo, AzureRegionConfig
|
||||
@@ -158,7 +162,7 @@ class TestAzureProvider:
|
||||
)
|
||||
assert exception.type == AzureNoAuthenticationMethodError
|
||||
assert (
|
||||
"Azure provider requires at least one authentication method set: [--az-cli-auth | --sp-env-auth | --browser-auth | --managed-identity-auth]"
|
||||
"Azure provider requires at least one authentication method set: [--az-cli-auth | --sp-env-auth | --browser-auth | --managed-identity-auth | --certificate-auth]"
|
||||
in exception.value.args[0]
|
||||
)
|
||||
|
||||
@@ -475,7 +479,7 @@ class TestAzureProvider:
|
||||
|
||||
assert exception.type == AzureNoAuthenticationMethodError
|
||||
assert (
|
||||
"[2003] Azure provider requires at least one authentication method set: [--az-cli-auth | --sp-env-auth | --browser-auth | --managed-identity-auth]"
|
||||
"[2003] Azure provider requires at least one authentication method set: [--az-cli-auth | --sp-env-auth | --browser-auth | --managed-identity-auth | --certificate-auth]"
|
||||
in exception.value.args[0]
|
||||
)
|
||||
|
||||
@@ -746,6 +750,7 @@ class TestAzureProviderSetupIdentitySubscriptions:
|
||||
sp_env_auth=False,
|
||||
browser_auth=False,
|
||||
managed_identity_auth=False,
|
||||
certificate_auth=False,
|
||||
subscription_ids=[],
|
||||
client_id=None,
|
||||
)
|
||||
@@ -778,6 +783,7 @@ class TestAzureProviderSetupIdentitySubscriptions:
|
||||
sp_env_auth=False,
|
||||
browser_auth=False,
|
||||
managed_identity_auth=False,
|
||||
certificate_auth=False,
|
||||
subscription_ids=[],
|
||||
client_id=None,
|
||||
)
|
||||
@@ -807,6 +813,7 @@ class TestAzureProviderSetupIdentitySubscriptions:
|
||||
sp_env_auth=False,
|
||||
browser_auth=False,
|
||||
managed_identity_auth=False,
|
||||
certificate_auth=False,
|
||||
subscription_ids=[first_id, second_id],
|
||||
client_id=None,
|
||||
)
|
||||
@@ -837,6 +844,7 @@ class TestAzureProviderSetupIdentitySubscriptions:
|
||||
sp_env_auth=False,
|
||||
browser_auth=False,
|
||||
managed_identity_auth=False,
|
||||
certificate_auth=False,
|
||||
subscription_ids=[first_id, second_id],
|
||||
client_id=None,
|
||||
)
|
||||
@@ -941,6 +949,8 @@ class TestAzureProviderSovereignCloudSupport:
|
||||
sp_env_auth=False,
|
||||
browser_auth=False,
|
||||
managed_identity_auth=False,
|
||||
certificate_auth=False,
|
||||
certificate_path=None,
|
||||
tenant_id=azure_credentials["tenant_id"],
|
||||
azure_credentials=azure_credentials,
|
||||
region_config=region_config,
|
||||
@@ -978,6 +988,8 @@ class TestAzureProviderSovereignCloudSupport:
|
||||
sp_env_auth=False,
|
||||
browser_auth=True,
|
||||
managed_identity_auth=False,
|
||||
certificate_auth=False,
|
||||
certificate_path=None,
|
||||
tenant_id=tenant_id,
|
||||
azure_credentials=None,
|
||||
region_config=region_config,
|
||||
@@ -1012,6 +1024,8 @@ class TestAzureProviderSovereignCloudSupport:
|
||||
sp_env_auth=False,
|
||||
browser_auth=False,
|
||||
managed_identity_auth=False,
|
||||
certificate_auth=False,
|
||||
certificate_path=None,
|
||||
tenant_id=None,
|
||||
azure_credentials=None,
|
||||
region_config=region_config,
|
||||
@@ -1214,6 +1228,7 @@ class TestAzureProviderSetupIdentityEventLoop:
|
||||
sp_env_auth=True,
|
||||
browser_auth=False,
|
||||
managed_identity_auth=False,
|
||||
certificate_auth=False,
|
||||
subscription_ids=[],
|
||||
client_id="00000000-0000-0000-0000-000000000000",
|
||||
)
|
||||
@@ -1224,3 +1239,288 @@ class TestAzureProviderSetupIdentityEventLoop:
|
||||
assert isinstance(identity, AzureIdentityInfo)
|
||||
assert identity.subscriptions == {sub_id: "Sub"}
|
||||
graph_client.domains.get.assert_awaited_once()
|
||||
|
||||
|
||||
class TestAzureProviderCertificateAuth:
|
||||
"""Coverage for the certificate-based service-principal auth path added
|
||||
by the Deploy-to-Azure quick-start (PROWLER-2378)."""
|
||||
|
||||
_TENANT_ID = "12345678-1234-1234-1234-123456789012"
|
||||
_CLIENT_ID = "87654321-4321-4321-4321-210987654321"
|
||||
# Base64 of a valid tiny DER payload — the tests only check that base64
|
||||
# decoding succeeds inside `validate_static_credentials`; the actual
|
||||
# certificate parsing is mocked at the `CertificateCredential` boundary.
|
||||
_CERT_CONTENT_B64 = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA"
|
||||
|
||||
def _region_config(self):
|
||||
return AzureProvider.setup_region_config("AzureCloud")
|
||||
|
||||
def test_validate_arguments_rejects_client_secret_and_cert_together(self):
|
||||
with pytest.raises(AzureConfigCredentialsError) as exception:
|
||||
AzureProvider.validate_arguments(
|
||||
az_cli_auth=False,
|
||||
sp_env_auth=False,
|
||||
browser_auth=False,
|
||||
managed_identity_auth=False,
|
||||
certificate_auth=False,
|
||||
tenant_id=self._TENANT_ID,
|
||||
client_id=self._CLIENT_ID,
|
||||
client_secret="some-secret",
|
||||
certificate_content=self._CERT_CONTENT_B64,
|
||||
certificate_path=None,
|
||||
)
|
||||
assert "not both" in exception.value.args[0]
|
||||
|
||||
def test_validate_arguments_rejects_cert_content_and_path_together(self):
|
||||
with pytest.raises(AzureConfigCredentialsError) as exception:
|
||||
AzureProvider.validate_arguments(
|
||||
az_cli_auth=False,
|
||||
sp_env_auth=False,
|
||||
browser_auth=False,
|
||||
managed_identity_auth=False,
|
||||
certificate_auth=False,
|
||||
tenant_id=self._TENANT_ID,
|
||||
client_id=self._CLIENT_ID,
|
||||
client_secret=None,
|
||||
certificate_content=self._CERT_CONTENT_B64,
|
||||
certificate_path="/tmp/anything",
|
||||
)
|
||||
assert "not both" in exception.value.args[0]
|
||||
|
||||
def test_validate_arguments_accepts_certificate_content_only(self):
|
||||
# Should not raise: cert content alone is a valid credential shape.
|
||||
AzureProvider.validate_arguments(
|
||||
az_cli_auth=False,
|
||||
sp_env_auth=False,
|
||||
browser_auth=False,
|
||||
managed_identity_auth=False,
|
||||
certificate_auth=False,
|
||||
tenant_id=self._TENANT_ID,
|
||||
client_id=self._CLIENT_ID,
|
||||
client_secret=None,
|
||||
certificate_content=self._CERT_CONTENT_B64,
|
||||
certificate_path=None,
|
||||
)
|
||||
|
||||
def test_check_certificate_creds_env_vars_missing_content(self, monkeypatch):
|
||||
monkeypatch.setenv("AZURE_CLIENT_ID", self._CLIENT_ID)
|
||||
monkeypatch.setenv("AZURE_TENANT_ID", self._TENANT_ID)
|
||||
monkeypatch.delenv("AZURE_CERTIFICATE_CONTENT", raising=False)
|
||||
with pytest.raises(AzureEnvironmentVariableError) as exception:
|
||||
AzureProvider.check_certificate_creds_env_vars(
|
||||
check_certificate_content=True
|
||||
)
|
||||
assert "AZURE_CERTIFICATE_CONTENT" in exception.value.args[0]
|
||||
|
||||
def test_check_certificate_creds_env_vars_skips_content_check_with_path(
|
||||
self, monkeypatch
|
||||
):
|
||||
# When a certificate path is provided at construction time, we must
|
||||
# NOT require AZURE_CERTIFICATE_CONTENT in the environment.
|
||||
monkeypatch.setenv("AZURE_CLIENT_ID", self._CLIENT_ID)
|
||||
monkeypatch.setenv("AZURE_TENANT_ID", self._TENANT_ID)
|
||||
monkeypatch.delenv("AZURE_CERTIFICATE_CONTENT", raising=False)
|
||||
# Should not raise
|
||||
AzureProvider.check_certificate_creds_env_vars(check_certificate_content=False)
|
||||
|
||||
def test_validate_static_credentials_rejects_non_base64_cert_content(self):
|
||||
with pytest.raises(AzureNotValidCertificateContentError):
|
||||
AzureProvider.validate_static_credentials(
|
||||
tenant_id=self._TENANT_ID,
|
||||
client_id=self._CLIENT_ID,
|
||||
client_secret=None,
|
||||
certificate_content="not_valid_base64_@@@",
|
||||
certificate_path=None,
|
||||
region_config=self._region_config(),
|
||||
)
|
||||
|
||||
def test_validate_static_credentials_rejects_missing_cert_file(self):
|
||||
with pytest.raises(AzureNotValidCertificatePathError):
|
||||
AzureProvider.validate_static_credentials(
|
||||
tenant_id=self._TENANT_ID,
|
||||
client_id=self._CLIENT_ID,
|
||||
client_secret=None,
|
||||
certificate_content=None,
|
||||
certificate_path="/tmp/does-not-exist-prowler-cert.pem",
|
||||
region_config=self._region_config(),
|
||||
)
|
||||
|
||||
def test_setup_session_static_credentials_cert_content_uses_certificate_credential(
|
||||
self,
|
||||
):
|
||||
# When the credentials dict carries a certificate_content, setup_session
|
||||
# must instantiate CertificateCredential (not ClientSecretCredential)
|
||||
# with the decoded bytes.
|
||||
with patch(
|
||||
"prowler.providers.azure.azure_provider.CertificateCredential"
|
||||
) as mock_cert_cred:
|
||||
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": self._CERT_CONTENT_B64,
|
||||
"certificate_path": None,
|
||||
},
|
||||
region_config=self._region_config(),
|
||||
)
|
||||
mock_cert_cred.assert_called_once()
|
||||
_, kwargs = mock_cert_cred.call_args
|
||||
assert kwargs["tenant_id"] == self._TENANT_ID
|
||||
assert kwargs["client_id"] == self._CLIENT_ID
|
||||
# The dict content is passed through base64.b64decode into bytes.
|
||||
assert isinstance(kwargs["certificate_data"], (bytes, bytearray))
|
||||
|
||||
def test_setup_session_env_cert_auth_uses_certificate_credential(self, monkeypatch):
|
||||
monkeypatch.setenv("AZURE_CLIENT_ID", self._CLIENT_ID)
|
||||
monkeypatch.setenv("AZURE_TENANT_ID", self._TENANT_ID)
|
||||
monkeypatch.setenv("AZURE_CERTIFICATE_CONTENT", self._CERT_CONTENT_B64)
|
||||
with patch(
|
||||
"prowler.providers.azure.azure_provider.CertificateCredential"
|
||||
) as mock_cert_cred:
|
||||
AzureProvider.setup_session(
|
||||
az_cli_auth=False,
|
||||
sp_env_auth=False,
|
||||
browser_auth=False,
|
||||
managed_identity_auth=False,
|
||||
certificate_auth=True,
|
||||
certificate_path=None,
|
||||
tenant_id=None,
|
||||
azure_credentials=None,
|
||||
region_config=self._region_config(),
|
||||
)
|
||||
mock_cert_cred.assert_called_once()
|
||||
_, kwargs = mock_cert_cred.call_args
|
||||
assert kwargs["tenant_id"] == self._TENANT_ID
|
||||
assert kwargs["client_id"] == self._CLIENT_ID
|
||||
|
||||
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.
|
||||
with patch(
|
||||
"prowler.providers.azure.azure_provider.ClientSecretCredential"
|
||||
) as mock_secret_cred:
|
||||
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": "fake-secret",
|
||||
"certificate_content": None,
|
||||
"certificate_path": None,
|
||||
},
|
||||
region_config=self._region_config(),
|
||||
)
|
||||
mock_secret_cred.assert_called_once_with(
|
||||
tenant_id=self._TENANT_ID,
|
||||
client_id=self._CLIENT_ID,
|
||||
client_secret="fake-secret",
|
||||
authority=self._region_config().authority,
|
||||
)
|
||||
|
||||
|
||||
class TestAzureProviderCertificateThumbprint:
|
||||
"""Coverage for `_compute_certificate_thumbprint` — the SHA-1 fingerprint
|
||||
helper that replaces azure-identity's private `_client_credential` API
|
||||
for the identity report."""
|
||||
|
||||
def _self_signed_pem_and_thumbprint(self):
|
||||
# Generate a real self-signed cert in-memory so the test verifies the
|
||||
# actual thumbprint computation instead of relying on hardcoded values.
|
||||
# This mirrors what an openssl-generated cert looks like on disk.
|
||||
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.x509.oid import NameOID
|
||||
|
||||
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())
|
||||
)
|
||||
pem = cert.public_bytes(serialization.Encoding.PEM)
|
||||
der = cert.public_bytes(serialization.Encoding.DER)
|
||||
thumbprint = cert.fingerprint(hashes.SHA1()).hex().upper()
|
||||
return pem, der, key, thumbprint
|
||||
|
||||
def test_computes_thumbprint_from_pem(self):
|
||||
from prowler.providers.azure.azure_provider import (
|
||||
_compute_certificate_thumbprint,
|
||||
)
|
||||
|
||||
pem, _, _, expected = self._self_signed_pem_and_thumbprint()
|
||||
assert _compute_certificate_thumbprint(pem) == expected
|
||||
|
||||
def test_computes_thumbprint_from_der(self):
|
||||
from prowler.providers.azure.azure_provider import (
|
||||
_compute_certificate_thumbprint,
|
||||
)
|
||||
|
||||
_, der, _, expected = self._self_signed_pem_and_thumbprint()
|
||||
assert _compute_certificate_thumbprint(der) == expected
|
||||
|
||||
def test_computes_thumbprint_from_pfx(self):
|
||||
# PowerShell's `Export('Pfx', '')` produces exactly this: PKCS#12 with
|
||||
# no password. `azure-identity.CertificateCredential` accepts it, so
|
||||
# our helper must recognise it too.
|
||||
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||
|
||||
from prowler.providers.azure.azure_provider import (
|
||||
_compute_certificate_thumbprint,
|
||||
)
|
||||
|
||||
pem, _, key, expected = self._self_signed_pem_and_thumbprint()
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
cert = x509.load_pem_x509_certificate(pem, default_backend())
|
||||
pfx = pkcs12.serialize_key_and_certificates(
|
||||
name=b"prowler",
|
||||
key=key,
|
||||
cert=cert,
|
||||
cas=None,
|
||||
encryption_algorithm=serialization_no_encryption(),
|
||||
)
|
||||
assert _compute_certificate_thumbprint(pfx) == expected
|
||||
|
||||
def test_returns_none_for_garbage_bytes(self):
|
||||
from prowler.providers.azure.azure_provider import (
|
||||
_compute_certificate_thumbprint,
|
||||
)
|
||||
|
||||
assert _compute_certificate_thumbprint(b"definitely not a cert") is None
|
||||
|
||||
|
||||
def serialization_no_encryption():
|
||||
# Kept out of the test method so the PKCS#12 test reads cleanly. Wraps
|
||||
# the deliberate "no password" export path.
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
return serialization.NoEncryption()
|
||||
@@ -0,0 +1 @@
|
||||
Add a Deploy to Azure quick-start to the add-provider wizard. Azure now shows a credential-type selector (Certificate authentication recommended, Service Principal with Client Secret fallback), and the certificate form exposes a one-click button that opens the Azure Portal deployment blade pre-loaded with the Prowler Bicep template (App Registration + Service Principal + Reader + custom ProwlerRole + certificate credential)
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { SelectViaAlibabaCloud } from "@/components/providers/workflow/forms/select-credentials-type/alibabacloud";
|
||||
import { SelectViaAWS } from "@/components/providers/workflow/forms/select-credentials-type/aws";
|
||||
import { SelectViaAzure } from "@/components/providers/workflow/forms/select-credentials-type/azure";
|
||||
import { SelectViaCloudflare } from "@/components/providers/workflow/forms/select-credentials-type/cloudflare";
|
||||
import { SelectViaGCP } from "@/components/providers/workflow/forms/select-credentials-type/gcp";
|
||||
import { SelectViaGitHub } from "@/components/providers/workflow/forms/select-credentials-type/github";
|
||||
@@ -21,6 +22,9 @@ export const CredentialsUpdateInfo = ({
|
||||
if (providerType === "aws") {
|
||||
return <SelectViaAWS initialVia={initialVia} />;
|
||||
}
|
||||
if (providerType === "azure") {
|
||||
return <SelectViaAzure initialVia={initialVia} />;
|
||||
}
|
||||
if (providerType === "gcp") {
|
||||
return <SelectViaGCP initialVia={initialVia} />;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ vi.mock("../../workflow/forms/select-credentials-type/aws", () => ({
|
||||
SelectViaAWS: () => <div>select-via-aws</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../../workflow/forms/select-credentials-type/azure", () => ({
|
||||
SelectViaAzure: () => <div>select-via-azure</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../../workflow/forms/select-credentials-type/alibabacloud", () => ({
|
||||
SelectViaAlibabaCloud: () => <div>select-via-alibabacloud</div>,
|
||||
}));
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "../../workflow/forms";
|
||||
import { SelectViaAlibabaCloud } from "../../workflow/forms/select-credentials-type/alibabacloud";
|
||||
import { SelectViaAWS } from "../../workflow/forms/select-credentials-type/aws";
|
||||
import { SelectViaAzure } from "../../workflow/forms/select-credentials-type/azure";
|
||||
import { SelectViaCloudflare } from "../../workflow/forms/select-credentials-type/cloudflare";
|
||||
import {
|
||||
AddViaServiceAccountForm,
|
||||
@@ -131,6 +132,14 @@ export function CredentialsStep({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (providerType === "azure") {
|
||||
return (
|
||||
<SelectViaAzure
|
||||
initialVia={via || undefined}
|
||||
onViaChange={handleViaChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (providerType === "gcp") {
|
||||
return (
|
||||
<SelectViaGCP
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
ApiResponse,
|
||||
AWSCredentials,
|
||||
AWSCredentialsRole,
|
||||
AzureCredentials,
|
||||
AzureCertificateCredentials,
|
||||
AzureClientSecretCredentials,
|
||||
CloudflareApiKeyCredentials,
|
||||
CloudflareTokenCredentials,
|
||||
GCPDefaultCredentials,
|
||||
@@ -44,6 +45,10 @@ import {
|
||||
} from "./select-credentials-type/alibabacloud/credentials-type";
|
||||
import { AWSStaticCredentialsForm } from "./select-credentials-type/aws/credentials-type";
|
||||
import { AWSRoleCredentialsForm } from "./select-credentials-type/aws/credentials-type/aws-role-credentials-form";
|
||||
import {
|
||||
AzureCertificateCredentialsForm,
|
||||
AzureServicePrincipalCredentialsForm,
|
||||
} from "./select-credentials-type/azure";
|
||||
import {
|
||||
CloudflareApiKeyCredentialsForm,
|
||||
CloudflareApiTokenCredentialsForm,
|
||||
@@ -54,7 +59,6 @@ import {
|
||||
M365CertificateCredentialsForm,
|
||||
M365ClientSecretCredentialsForm,
|
||||
} from "./select-credentials-type/m365";
|
||||
import { AzureCredentialsForm } from "./via-credentials/azure-credentials-form";
|
||||
import { GitHubCredentialsForm } from "./via-credentials/github-credentials-form";
|
||||
import { GoogleWorkspaceCredentialsForm } from "./via-credentials/googleworkspace-credentials-form";
|
||||
import { IacCredentialsForm } from "./via-credentials/iac-credentials-form";
|
||||
@@ -175,9 +179,18 @@ export const BaseCredentialsForm = ({
|
||||
control={form.control as unknown as Control<AWSCredentials>}
|
||||
/>
|
||||
)}
|
||||
{providerType === "azure" && (
|
||||
<AzureCredentialsForm
|
||||
control={form.control as unknown as Control<AzureCredentials>}
|
||||
{providerType === "azure" && effectiveVia === "app_client_secret" && (
|
||||
<AzureServicePrincipalCredentialsForm
|
||||
control={
|
||||
form.control as unknown as Control<AzureClientSecretCredentials>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{providerType === "azure" && effectiveVia === "app_certificate" && (
|
||||
<AzureCertificateCredentialsForm
|
||||
control={
|
||||
form.control as unknown as Control<AzureCertificateCredentials>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{providerType === "m365" && effectiveVia === "app_client_secret" && (
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { AzureCertificateCredentials } from "@/types";
|
||||
|
||||
import { AzureCertificateCredentialsForm } from "./azure-certificate-credentials-form";
|
||||
|
||||
// Wraps the form in a react-hook-form context so the WizardInputField and
|
||||
// WizardTextareaField mount without exploding. We are testing the Deploy to
|
||||
// Azure link and the field structure, not the input controls themselves.
|
||||
const Harness = () => {
|
||||
const form = useForm<AzureCertificateCredentials>();
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<AzureCertificateCredentialsForm control={form.control} />
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe("AzureCertificateCredentialsForm", () => {
|
||||
it("renders the Deploy to Azure link with the Portal custom-deployment URL and safe target attributes", () => {
|
||||
// Given
|
||||
render(<Harness />);
|
||||
|
||||
// When
|
||||
const link = screen.getByRole("link", { name: /deploy to azure/i });
|
||||
|
||||
// Then
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
|
||||
const href = link.getAttribute("href") ?? "";
|
||||
expect(
|
||||
href.startsWith(
|
||||
"https://portal.azure.com/#create/Microsoft.Template/uri/",
|
||||
),
|
||||
).toBe(true);
|
||||
// The Bicep template URL must be percent-encoded exactly once into the
|
||||
// fragment; asserting on the encoded slashes guards against a future
|
||||
// refactor that swaps `encodeURIComponent` for a helper that leaves them
|
||||
// raw and breaks the Portal parser.
|
||||
expect(href).toContain(
|
||||
"https%3A%2F%2Fprowler-cloud-public.s3.eu-west-1.amazonaws.com%2Fpermissions%2Ftemplates%2Fazure%2Fbicep%2Fprowler-scan.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the certificate generation docs link pointing at the Azure authentication doc", () => {
|
||||
// Given
|
||||
render(<Harness />);
|
||||
|
||||
// When
|
||||
const docsLink = screen.getByRole("link", {
|
||||
name: /certificate generation guide/i,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(docsLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://docs.prowler.com/user-guide/providers/azure/authentication#certificate-authentication",
|
||||
);
|
||||
expect(docsLink).toHaveAttribute("target", "_blank");
|
||||
expect(docsLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
});
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Control } from "react-hook-form";
|
||||
|
||||
import {
|
||||
WizardInputField,
|
||||
WizardTextareaField,
|
||||
} from "@/components/providers/workflow/forms/fields";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { getAzureDeploymentQuickLink } from "@/lib/external-urls";
|
||||
import { AzureCertificateCredentials } from "@/types";
|
||||
|
||||
export const AzureCertificateCredentialsForm = ({
|
||||
control,
|
||||
}: {
|
||||
control: Control<AzureCertificateCredentials>;
|
||||
}) => {
|
||||
// The Deploy-to-Azure link is a plain constant today: unlike the AWS
|
||||
// organisation link, it does not need any wizard-collected value in the
|
||||
// URL (subscription selection happens inside the Portal deployment blade).
|
||||
// If Prowler ever needs to pin the deployment to a specific subscription
|
||||
// or region, thread that value through `getAzureDeploymentQuickLink` here.
|
||||
const deployToAzureUrl = getAzureDeploymentQuickLink();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-md text-text-neutral-primary leading-9 font-bold">
|
||||
Certificate Authentication (Recommended)
|
||||
</div>
|
||||
<div className="text-text-neutral-tertiary text-sm">
|
||||
Deploy the Prowler Bicep template to provision the App Registration,
|
||||
Service Principal, and read-only roles in one click, then paste the
|
||||
resulting credentials below.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<Button variant="link" size="link-sm" asChild>
|
||||
<a href={deployToAzureUrl} target="_blank" rel="noopener noreferrer">
|
||||
Deploy to Azure
|
||||
</a>
|
||||
</Button>
|
||||
<p className="text-text-neutral-tertiary text-xs">
|
||||
After deployment, copy <strong>Tenant ID</strong> and{" "}
|
||||
<strong>Application (Client) ID</strong> from the deployment outputs —
|
||||
the certificate private key you generated locally goes in the
|
||||
“Certificate Private Key” field below.
|
||||
</p>
|
||||
</div>
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name="tenant_id"
|
||||
type="text"
|
||||
label="Tenant ID"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the Tenant ID"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
/>
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name="client_id"
|
||||
type="text"
|
||||
label="Client ID"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the Client ID"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
/>
|
||||
<WizardTextareaField
|
||||
control={control}
|
||||
name="certificate_content"
|
||||
label="Certificate Private Key (Base64)"
|
||||
labelPlacement="inside"
|
||||
placeholder="Paste the base64-encoded private key that pairs with the certificate deployed to Entra ID"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
minRows={4}
|
||||
/>
|
||||
<p className="text-text-neutral-tertiary text-sm">
|
||||
This is the <strong>base64-encoded private key</strong> that matches the
|
||||
certificate uploaded to Entra ID by the Bicep template (not the public
|
||||
certificate, and not the thumbprint). Generation instructions (openssl /
|
||||
PowerShell) are in the{" "}
|
||||
<Link
|
||||
href="https://docs.prowler.com/user-guide/providers/azure/authentication#certificate-authentication"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-button-tertiary p-0 text-sm"
|
||||
>
|
||||
certificate generation guide
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+19
-15
@@ -1,23 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { Control } from "react-hook-form";
|
||||
|
||||
import { WizardInputField } from "@/components/providers/workflow/forms/fields";
|
||||
import { AzureCredentials } from "@/types";
|
||||
import { AzureClientSecretCredentials } from "@/types";
|
||||
|
||||
export const AzureCredentialsForm = ({
|
||||
export const AzureServicePrincipalCredentialsForm = ({
|
||||
control,
|
||||
}: {
|
||||
control: Control<AzureCredentials>;
|
||||
control: Control<AzureClientSecretCredentials>;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-md text-text-neutral-primary leading-9 font-bold">
|
||||
Connect via Credentials
|
||||
Service Principal with Client Secret
|
||||
</div>
|
||||
<div className="text-text-neutral-tertiary text-sm">
|
||||
Please provide the information for your Azure credentials.
|
||||
Please provide the Azure Service Principal credentials issued from
|
||||
your App Registration's <em>Certificates & secrets</em>{" "}
|
||||
blade.
|
||||
</div>
|
||||
</div>
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name="tenant_id"
|
||||
type="text"
|
||||
label="Tenant ID"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the Tenant ID"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
/>
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name="client_id"
|
||||
@@ -38,16 +52,6 @@ export const AzureCredentialsForm = ({
|
||||
variant="bordered"
|
||||
isRequired
|
||||
/>
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name="tenant_id"
|
||||
type="text"
|
||||
label="Tenant ID"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the Tenant ID"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export { AzureCertificateCredentialsForm } from "./azure-certificate-credentials-form";
|
||||
export { AzureServicePrincipalCredentialsForm } from "./azure-service-principal-credentials-form";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
AzureCertificateCredentialsForm,
|
||||
AzureServicePrincipalCredentialsForm,
|
||||
} from "./credentials-type";
|
||||
export { SelectViaAzure } from "./select-via-azure";
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { Control, Controller } from "react-hook-form";
|
||||
|
||||
import { WizardRadioCard } from "@/components/providers/workflow/forms/fields";
|
||||
import { FormMessage } from "@/components/shadcn/form";
|
||||
import { RadioGroup } from "@/components/shadcn/radio-group/radio-group";
|
||||
|
||||
type RadioGroupAzureViaCredentialsFormProps = {
|
||||
control: Control<any>;
|
||||
isInvalid: boolean;
|
||||
errorMessage?: string;
|
||||
onChange?: (value: string) => void;
|
||||
};
|
||||
|
||||
// The via values (`app_client_secret` / `app_certificate`) mirror M365 on
|
||||
// purpose: both providers authenticate against an Entra ID App Registration
|
||||
// and reuse the same wizard state machine + per-method docs anchors.
|
||||
export const RadioGroupAzureViaCredentialsTypeForm = ({
|
||||
control,
|
||||
isInvalid,
|
||||
errorMessage,
|
||||
onChange,
|
||||
}: RadioGroupAzureViaCredentialsFormProps) => {
|
||||
return (
|
||||
<Controller
|
||||
name="azureCredentialsType"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<RadioGroup
|
||||
name={field.name}
|
||||
value={field.value || ""}
|
||||
onValueChange={(value: string) => {
|
||||
field.onChange(value);
|
||||
onChange?.(value);
|
||||
}}
|
||||
>
|
||||
<span className="text-text-neutral-tertiary text-sm">
|
||||
Select Authentication Method
|
||||
</span>
|
||||
<WizardRadioCard value="app_certificate" isInvalid={isInvalid}>
|
||||
Certificate Authentication (Recommended)
|
||||
</WizardRadioCard>
|
||||
<WizardRadioCard value="app_client_secret" isInvalid={isInvalid}>
|
||||
Service Principal with Client Secret
|
||||
</WizardRadioCard>
|
||||
</RadioGroup>
|
||||
{errorMessage && (
|
||||
<FormMessage className="text-text-error-primary">
|
||||
{errorMessage}
|
||||
</FormMessage>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { Form } from "@/components/shadcn/form";
|
||||
|
||||
import { RadioGroupAzureViaCredentialsTypeForm } from "./radio-group-azure-via-credentials-type-form";
|
||||
|
||||
interface SelectViaAzureProps {
|
||||
initialVia?: string;
|
||||
onViaChange?: (via: string) => void;
|
||||
}
|
||||
|
||||
export const SelectViaAzure = ({
|
||||
initialVia,
|
||||
onViaChange,
|
||||
}: SelectViaAzureProps) => {
|
||||
const router = useRouter();
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
azureCredentialsType: initialVia || "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSelectionChange = (value: string) => {
|
||||
if (onViaChange) {
|
||||
onViaChange(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("via", value);
|
||||
router.push(url.toString());
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<RadioGroupAzureViaCredentialsTypeForm
|
||||
control={form.control}
|
||||
isInvalid={!!form.formState.errors.azureCredentialsType}
|
||||
errorMessage={form.formState.errors.azureCredentialsType?.message}
|
||||
onChange={handleSelectionChange}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from "./azure-credentials-form";
|
||||
export * from "./github-credentials-form";
|
||||
export * from "./iac-credentials-form";
|
||||
export * from "./image-credentials-form";
|
||||
|
||||
@@ -55,8 +55,10 @@ export const useCredentialsForm = ({
|
||||
if (providerType === "gcp" && effectiveVia === "service-account") {
|
||||
return addCredentialsServiceAccountFormSchema(providerType);
|
||||
}
|
||||
// For GitHub, M365, and Cloudflare, we need to pass the via parameter to determine which fields are required
|
||||
// For Azure, GitHub, M365, and Cloudflare, we need to pass the via
|
||||
// parameter to determine which fields are required
|
||||
if (
|
||||
providerType === "azure" ||
|
||||
providerType === "github" ||
|
||||
providerType === "m365" ||
|
||||
providerType === "cloudflare"
|
||||
@@ -111,6 +113,19 @@ export const useCredentialsForm = ({
|
||||
[ProviderCredentialFields.AWS_SESSION_TOKEN]: "",
|
||||
};
|
||||
case "azure":
|
||||
// Azure now mirrors the M365 flow: the user picks the auth method
|
||||
// in the selector step, which sets `via`, and we only seed the
|
||||
// fields that method actually shows. Missing fields would still be
|
||||
// registered by the schema as optional, but seeding them here keeps
|
||||
// the react-hook-form store in sync with the visible form.
|
||||
if (effectiveVia === "app_certificate") {
|
||||
return {
|
||||
...baseDefaults,
|
||||
[ProviderCredentialFields.CLIENT_ID]: "",
|
||||
[ProviderCredentialFields.CERTIFICATE_CONTENT]: "",
|
||||
[ProviderCredentialFields.TENANT_ID]: "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
...baseDefaults,
|
||||
[ProviderCredentialFields.CLIENT_ID]: "",
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
buildGitHubPersonalAccessTokenOrgUrl,
|
||||
getAWSCredentialsTemplateLinks,
|
||||
getAWSOrgDeploymentQuickLink,
|
||||
getAzureDeploymentQuickLink,
|
||||
PRECONFIGURED_CREDENTIAL_URLS,
|
||||
getProviderHelpText,
|
||||
PROWLER_AZURE_BICEP_TEMPLATE_URL,
|
||||
PROWLER_CF_TEMPLATE_URL,
|
||||
} from "./external-urls";
|
||||
|
||||
@@ -285,6 +287,59 @@ describe("buildGitHubPersonalAccessTokenOrgUrl", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAzureDeploymentQuickLink", () => {
|
||||
it("pins the Bicep template to the public S3 bucket that hosts the Prowler templates", () => {
|
||||
// Snapshot check: fixes the exact template URL so a stray edit to the
|
||||
// bucket, path, or filename trips a failing test instead of silently
|
||||
// pointing the Deploy to Azure button at a 404. Kept in sync with the
|
||||
// Bicep file at `permissions/templates/azure/bicep/prowler-scan.json`
|
||||
// and its README, plus the actual object hosted in S3.
|
||||
expect(PROWLER_AZURE_BICEP_TEMPLATE_URL).toBe(
|
||||
"https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/azure/bicep/prowler-scan.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("routes through the Azure Portal custom-deployment URL fragment with the template URL percent-encoded exactly once", () => {
|
||||
// The Portal SPA re-parses the hash fragment and URL-decodes the
|
||||
// `uri/<value>` segment once, so double-encoding would land the user on
|
||||
// a broken page. Asserting on the raw substring guards against a future
|
||||
// refactor that swaps `encodeURIComponent` for `URLSearchParams`, which
|
||||
// would encode differently (e.g. spaces as `+`).
|
||||
const url = getAzureDeploymentQuickLink();
|
||||
|
||||
expect(url).toBe(
|
||||
`https://portal.azure.com/#create/Microsoft.Template/uri/${encodeURIComponent(
|
||||
PROWLER_AZURE_BICEP_TEMPLATE_URL,
|
||||
)}`,
|
||||
);
|
||||
expect(url).toContain(
|
||||
"https%3A%2F%2Fprowler-cloud-public.s3.eu-west-1.amazonaws.com%2Fpermissions%2Ftemplates%2Fazure%2Fbicep%2Fprowler-scan.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a custom template URL so operators can point the button at a private mirror without editing the constant", () => {
|
||||
// Self-hosted or air-gapped installs occasionally re-host the template
|
||||
// on their own bucket for compliance reasons. The helper accepts an
|
||||
// override so those callers don't have to reimplement the fragment
|
||||
// encoding themselves.
|
||||
const customUrl =
|
||||
"https://example.internal/templates/prowler-scan.json?v=2";
|
||||
const url = getAzureDeploymentQuickLink(customUrl);
|
||||
|
||||
expect(url).toBe(
|
||||
`https://portal.azure.com/#create/Microsoft.Template/uri/${encodeURIComponent(
|
||||
customUrl,
|
||||
)}`,
|
||||
);
|
||||
// The `?` inside the query-string of the custom URL must be encoded so
|
||||
// the Portal parser reads the entire value as the template URL instead
|
||||
// of truncating at the first `?`.
|
||||
expect(url).toContain(
|
||||
"uri/https%3A%2F%2Fexample.internal%2Ftemplates%2Fprowler-scan.json%3Fv%3D2",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProviderHelpText", () => {
|
||||
const AWS_SHORTLINK = "https://goto.prowler.com/provider-aws";
|
||||
const AWS_CREDENTIALS_STEP_DOCS =
|
||||
|
||||
@@ -40,6 +40,19 @@ export const getAttackPathHubUrl = (queryId: string): string =>
|
||||
export const PROWLER_CF_TEMPLATE_URL =
|
||||
"https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/aws/cloudformation/prowler-scan-role.yml";
|
||||
|
||||
// ARM JSON template URL for the Prowler quick-start Azure deployment (App
|
||||
// Registration + Service Principal + Reader + custom ProwlerRole + certificate
|
||||
// credential). The template is authored in Bicep at
|
||||
// `permissions/templates/azure/bicep/prowler-scan.bicep` and compiled to ARM
|
||||
// JSON at `prowler-scan.json` — the Azure Portal `#create/Microsoft.Template`
|
||||
// deep link only accepts ARM JSON, not raw Bicep source. Regenerate with
|
||||
// `make -C permissions/templates/azure/bicep build` after editing the .bicep.
|
||||
// URL-encoded and appended to the Portal deep link built by
|
||||
// `getAzureDeploymentQuickLink` below. Points at the same public S3 bucket
|
||||
// that hosts the AWS templates.
|
||||
export const PROWLER_AZURE_BICEP_TEMPLATE_URL =
|
||||
"https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/azure/bicep/prowler-scan.json";
|
||||
|
||||
// Prowler Cloud billing/subscription management page.
|
||||
export const BILLING_URL = "https://cloud.prowler.com/billing";
|
||||
|
||||
@@ -215,6 +228,14 @@ const PROVIDER_CREDENTIALS_METHOD_DOCS_URL: Record<
|
||||
credentials:
|
||||
"https://docs.prowler.com/user-guide/providers/aws/getting-started-aws#credentials-static-access-keys",
|
||||
},
|
||||
azure: {
|
||||
// The Deploy-to-Azure certificate flow is the recommended path;
|
||||
// client-secret authentication remains the manual fallback.
|
||||
app_certificate:
|
||||
"https://docs.prowler.com/user-guide/providers/azure/getting-started-azure#certificate-authentication-recommended",
|
||||
app_client_secret:
|
||||
"https://docs.prowler.com/user-guide/providers/azure/getting-started-azure#service-principal-with-client-secret",
|
||||
},
|
||||
m365: {
|
||||
app_certificate:
|
||||
"https://docs.prowler.com/user-guide/providers/microsoft365/getting-started-m365#application-certificate-authentication-recommended",
|
||||
@@ -393,3 +414,20 @@ export const getAWSOrgDeploymentQuickLink = ({
|
||||
|
||||
return buildCloudFormationQuickCreateLink(parameters);
|
||||
};
|
||||
|
||||
// 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
|
||||
// `encodeURIComponent`. This is the Azure analogue of the AWS CloudFormation
|
||||
// quick-create link built by `getAWSOrgDeploymentQuickLink` above.
|
||||
//
|
||||
// The resulting URL is safe to open in a new tab; the user is expected to be
|
||||
// signed into the Azure Portal already. If they are not, the Portal will
|
||||
// bounce them through the standard sign-in flow before landing on the
|
||||
// deployment form with the template still pre-loaded.
|
||||
export const getAzureDeploymentQuickLink = (
|
||||
templateUrl: string = PROWLER_AZURE_BICEP_TEMPLATE_URL,
|
||||
): string =>
|
||||
`https://portal.azure.com/#create/Microsoft.Template/uri/${encodeURIComponent(templateUrl)}`;
|
||||
@@ -87,6 +87,7 @@ export const getProviderFormType = (
|
||||
// Providers that need credential type selection
|
||||
const needsSelector = [
|
||||
"aws",
|
||||
"azure",
|
||||
"gcp",
|
||||
"github",
|
||||
"m365",
|
||||
@@ -119,6 +120,17 @@ export const getProviderFormType = (
|
||||
return "credentials";
|
||||
}
|
||||
|
||||
// Azure credential types — shares the M365 `app_client_secret` /
|
||||
// `app_certificate` via values because both providers authenticate against
|
||||
// an Entra ID App Registration; keeping the via strings identical lets the
|
||||
// per-method docs URLs and the wizard state machine reuse the same logic.
|
||||
if (
|
||||
providerType === "azure" &&
|
||||
["app_client_secret", "app_certificate"].includes(via || "")
|
||||
) {
|
||||
return "credentials";
|
||||
}
|
||||
|
||||
// M365 credential types
|
||||
if (
|
||||
providerType === "m365" &&
|
||||
|
||||
+16
-1
@@ -219,13 +219,28 @@ export type AWSCredentialsRole = {
|
||||
[ProviderCredentialFields.CREDENTIALS_TYPE]?: AWSCredentialsType;
|
||||
};
|
||||
|
||||
export type AzureCredentials = {
|
||||
export type AzureClientSecretCredentials = {
|
||||
[ProviderCredentialFields.CLIENT_ID]: string;
|
||||
[ProviderCredentialFields.CLIENT_SECRET]: string;
|
||||
[ProviderCredentialFields.TENANT_ID]: string;
|
||||
[ProviderCredentialFields.PROVIDER_ID]: string;
|
||||
};
|
||||
|
||||
export type AzureCertificateCredentials = {
|
||||
[ProviderCredentialFields.CLIENT_ID]: string;
|
||||
[ProviderCredentialFields.CERTIFICATE_CONTENT]: string;
|
||||
[ProviderCredentialFields.TENANT_ID]: string;
|
||||
[ProviderCredentialFields.PROVIDER_ID]: string;
|
||||
};
|
||||
|
||||
// `AzureCredentials` used to be the client-secret-only shape. It now spans
|
||||
// both service-principal auth methods so callers that render either form can
|
||||
// keep the same react-hook-form Control<>. The two forms share `client_id`
|
||||
// and `tenant_id`, so consumers touching only those fields need no changes.
|
||||
export type AzureCredentials =
|
||||
| AzureClientSecretCredentials
|
||||
| AzureCertificateCredentials;
|
||||
|
||||
export type M365ClientSecretCredentials = {
|
||||
[ProviderCredentialFields.CLIENT_ID]: string;
|
||||
[ProviderCredentialFields.CLIENT_SECRET]: string;
|
||||
|
||||
+39
-2
@@ -211,12 +211,19 @@ export const addCredentialsFormSchema = (
|
||||
}
|
||||
: providerType === "azure"
|
||||
? {
|
||||
// Client secret vs. certificate is a per-form choice driven by
|
||||
// `via`; the field-level presence check runs inside the
|
||||
// credential-type form (see azure-*-credentials-form.tsx). The
|
||||
// schema keeps both optional so switching methods without a
|
||||
// page reload does not trigger a stale "required" error on the
|
||||
// field that is not shown.
|
||||
[ProviderCredentialFields.CLIENT_ID]: z
|
||||
.string()
|
||||
.min(1, "Client ID is required"),
|
||||
[ProviderCredentialFields.CLIENT_SECRET]: z
|
||||
[ProviderCredentialFields.CLIENT_SECRET]: z.string().optional(),
|
||||
[ProviderCredentialFields.CERTIFICATE_CONTENT]: z
|
||||
.string()
|
||||
.min(1, "Client Secret is required"),
|
||||
.optional(),
|
||||
[ProviderCredentialFields.TENANT_ID]: z
|
||||
.string()
|
||||
.min(1, "Tenant ID is required"),
|
||||
@@ -442,6 +449,36 @@ export const addCredentialsFormSchema = (
|
||||
: {}),
|
||||
})
|
||||
.superRefine((data: Record<string, string | undefined>, ctx) => {
|
||||
if (providerType === "azure") {
|
||||
// Azure schema keeps both `client_secret` and `certificate_content`
|
||||
// optional at field level (the credential-type selector picks which
|
||||
// form is shown). The visible field for the chosen `via` is what
|
||||
// the user must fill — enforce it here so the client catches empty
|
||||
// submissions before hitting the API, mirroring M365. Error copy is
|
||||
// aligned with the visible field labels ("Certificate Private Key"
|
||||
// vs. the technical `certificate_content` field name).
|
||||
if (via === "app_client_secret") {
|
||||
const clientSecret = data[ProviderCredentialFields.CLIENT_SECRET];
|
||||
if (!clientSecret || clientSecret.trim() === "") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Client Secret is required",
|
||||
path: [ProviderCredentialFields.CLIENT_SECRET],
|
||||
});
|
||||
}
|
||||
} else if (via === "app_certificate") {
|
||||
const certificateContent =
|
||||
data[ProviderCredentialFields.CERTIFICATE_CONTENT];
|
||||
if (!certificateContent || certificateContent.trim() === "") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Certificate Private Key is required",
|
||||
path: [ProviderCredentialFields.CERTIFICATE_CONTENT],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (providerType === "m365") {
|
||||
// Validate based on the via parameter
|
||||
if (via === "app_client_secret") {
|
||||
|
||||
Reference in new issue
Block a user