mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 10:14:20 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4990ce012 | ||
|
|
788458be4e | ||
|
|
4f0a49eaf1 | ||
|
|
dda7c69c06 | ||
|
|
3762c9ba6b | ||
|
|
f67cf7c3e5 | ||
|
|
fcf06e148c | ||
|
|
a1e9d243be | ||
|
|
26fe612ea7 | ||
|
|
bd5afb6981 | ||
|
|
c593800e24 | ||
|
|
f1e331ad23 | ||
|
|
2f91cf430b | ||
|
|
f2d0e714c8 | ||
|
|
936d2c02a3 | ||
|
|
6a52bf432d | ||
|
|
5317c589b3 | ||
|
|
6a411881d6 | ||
|
|
6b4950f922 | ||
|
|
d6354068af | ||
|
|
4d368d7f1d | ||
|
|
1871efba93 |
@@ -0,0 +1,70 @@
|
||||
name: 'Docs: Azure ARM Template'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
paths:
|
||||
- '.github/workflows/docs-check-azure-arm-template.yml'
|
||||
- 'docs/assets/templates/azure/prowler-scan.json'
|
||||
- 'docs/snippets/azure-prowler-scan-template.mdx'
|
||||
- 'permissions/templates/azure/bicep/Makefile'
|
||||
- 'permissions/templates/azure/bicep/prowler-scan.bicep'
|
||||
- 'permissions/templates/azure/bicep/prowler-scan.json'
|
||||
- 'permissions/templates/azure/bicep/sync_docs_template.py'
|
||||
- 'permissions/templates/azure/bicep/sync_docs_template_test.py'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
paths:
|
||||
- '.github/workflows/docs-check-azure-arm-template.yml'
|
||||
- 'docs/assets/templates/azure/prowler-scan.json'
|
||||
- 'docs/snippets/azure-prowler-scan-template.mdx'
|
||||
- 'permissions/templates/azure/bicep/Makefile'
|
||||
- 'permissions/templates/azure/bicep/prowler-scan.bicep'
|
||||
- 'permissions/templates/azure/bicep/prowler-scan.json'
|
||||
- 'permissions/templates/azure/bicep/sync_docs_template.py'
|
||||
- 'permissions/templates/azure/bicep/sync_docs_template_test.py'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
azure-arm-template:
|
||||
if: github.repository == 'prowler-cloud/prowler'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
api.github.com:443
|
||||
downloads.bicep.azure.com:443
|
||||
github.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Bicep
|
||||
uses: anthony-c-martin/setup-bicep@faf8170f496c10a2ea75977d2e77024131331404 # v1
|
||||
with:
|
||||
version: 0.46.1
|
||||
|
||||
- name: Test Azure ARM documentation synchronization
|
||||
run: python3 -m unittest permissions/templates/azure/bicep/sync_docs_template_test.py
|
||||
|
||||
- name: Check Azure ARM template synchronization
|
||||
run: make --directory permissions/templates/azure/bicep check
|
||||
@@ -175,3 +175,4 @@ docker-compose.override.yml
|
||||
docker-compose-dev.override.yml
|
||||
# Local Pi runtime state
|
||||
.atl/
|
||||
.gga
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
`POST /api/v1/providers` OpenAPI schema documents Azure certificate authentication credentials
|
||||
@@ -0,0 +1 @@
|
||||
`certificate_content` support for Azure provider secrets, with mutual exclusion against `client_secret` and certificate/private-key bundle validation
|
||||
@@ -6091,16 +6091,6 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
- in: query
|
||||
name: filter[updated_at__gte]
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
- in: query
|
||||
name: filter[updated_at__lte]
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
- name: sort
|
||||
required: false
|
||||
in: query
|
||||
@@ -16312,7 +16302,7 @@ paths:
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserResponse'
|
||||
$ref: '#/components/schemas/UserMeResponse'
|
||||
description: ''
|
||||
components:
|
||||
schemas:
|
||||
@@ -16444,6 +16434,17 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AttackPathsQueryParameter'
|
||||
outcome:
|
||||
type: object
|
||||
nullable: true
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
partial:
|
||||
type: boolean
|
||||
readOnly: true
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
@@ -17680,7 +17681,11 @@ components:
|
||||
can be generated from your Atlassian account settings.
|
||||
domain:
|
||||
type: string
|
||||
description: The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').
|
||||
description: The Jira site name without the '.atlassian.net' suffix
|
||||
(e.g., 'your-domain').
|
||||
minLength: 1
|
||||
maxLength: 63
|
||||
pattern: ^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$
|
||||
required:
|
||||
- user_mail
|
||||
- api_token
|
||||
@@ -17865,7 +17870,11 @@ components:
|
||||
can be generated from your Atlassian account settings.
|
||||
domain:
|
||||
type: string
|
||||
description: The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').
|
||||
description: The Jira site name without the '.atlassian.net'
|
||||
suffix (e.g., 'your-domain').
|
||||
minLength: 1
|
||||
maxLength: 63
|
||||
pattern: ^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$
|
||||
required:
|
||||
- user_mail
|
||||
- api_token
|
||||
@@ -18127,7 +18136,11 @@ components:
|
||||
can be generated from your Atlassian account settings.
|
||||
domain:
|
||||
type: string
|
||||
description: The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').
|
||||
description: The Jira site name without the '.atlassian.net' suffix
|
||||
(e.g., 'your-domain').
|
||||
minLength: 1
|
||||
maxLength: 63
|
||||
pattern: ^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$
|
||||
required:
|
||||
- user_mail
|
||||
- api_token
|
||||
@@ -20554,7 +20567,11 @@ components:
|
||||
can be generated from your Atlassian account settings.
|
||||
domain:
|
||||
type: string
|
||||
description: The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').
|
||||
description: The Jira site name without the '.atlassian.net'
|
||||
suffix (e.g., 'your-domain').
|
||||
minLength: 1
|
||||
maxLength: 63
|
||||
pattern: ^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$
|
||||
required:
|
||||
- user_mail
|
||||
- api_token
|
||||
@@ -21272,7 +21289,7 @@ components:
|
||||
- role_arn
|
||||
- external_id
|
||||
- type: object
|
||||
title: Azure Static Credentials
|
||||
title: Azure Client Secret Credentials
|
||||
properties:
|
||||
client_id:
|
||||
type: string
|
||||
@@ -21290,6 +21307,26 @@ components:
|
||||
- client_id
|
||||
- client_secret
|
||||
- tenant_id
|
||||
- type: object
|
||||
title: Azure Certificate Credentials
|
||||
properties:
|
||||
client_id:
|
||||
type: string
|
||||
description: The Azure application (client) ID for authentication
|
||||
in Azure AD.
|
||||
certificate_content:
|
||||
type: string
|
||||
description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate
|
||||
and its matching private key for certificate-based authentication.
|
||||
maxLength: 68266
|
||||
tenant_id:
|
||||
type: string
|
||||
description: The Azure tenant ID, representing the directory
|
||||
where the application is registered.
|
||||
required:
|
||||
- client_id
|
||||
- certificate_content
|
||||
- tenant_id
|
||||
- type: object
|
||||
title: M365 Static Credentials
|
||||
properties:
|
||||
@@ -21332,8 +21369,9 @@ components:
|
||||
where the application is registered.
|
||||
certificate_content:
|
||||
type: string
|
||||
description: The certificate content in base64 format for
|
||||
certificate-based authentication.
|
||||
description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate
|
||||
and its matching private key for certificate-based authentication.
|
||||
maxLength: 68266
|
||||
required:
|
||||
- client_id
|
||||
- tenant_id
|
||||
@@ -21387,7 +21425,8 @@ components:
|
||||
kubeconfig_content:
|
||||
type: string
|
||||
description: The content of the Kubernetes kubeconfig file,
|
||||
encoded as a string.
|
||||
encoded as a string. Kubeconfig command-based authentication
|
||||
is not supported in Prowler Cloud for security reasons.
|
||||
required:
|
||||
- kubeconfig_content
|
||||
- type: object
|
||||
@@ -21450,18 +21489,23 @@ components:
|
||||
tenancy:
|
||||
type: string
|
||||
description: The OCID of the tenancy.
|
||||
region:
|
||||
type: string
|
||||
description: The OCI region identifier (e.g., us-ashburn-1,
|
||||
us-phoenix-1).
|
||||
pass_phrase:
|
||||
type: string
|
||||
description: The passphrase for the private key, if encrypted.
|
||||
region:
|
||||
type: string
|
||||
deprecated: true
|
||||
description: Legacy OCI region field accepted for backwards
|
||||
compatibility but ignored; OCI scans all regions.
|
||||
required:
|
||||
- user
|
||||
- fingerprint
|
||||
- tenancy
|
||||
- region
|
||||
anyOf:
|
||||
- required:
|
||||
- key_file
|
||||
- required:
|
||||
- key_content
|
||||
- type: object
|
||||
title: MongoDB Atlas API Key
|
||||
properties:
|
||||
@@ -23396,7 +23440,7 @@ components:
|
||||
- role_arn
|
||||
- external_id
|
||||
- type: object
|
||||
title: Azure Static Credentials
|
||||
title: Azure Client Secret Credentials
|
||||
properties:
|
||||
client_id:
|
||||
type: string
|
||||
@@ -23414,6 +23458,26 @@ components:
|
||||
- client_id
|
||||
- client_secret
|
||||
- tenant_id
|
||||
- type: object
|
||||
title: Azure Certificate Credentials
|
||||
properties:
|
||||
client_id:
|
||||
type: string
|
||||
description: The Azure application (client) ID for authentication
|
||||
in Azure AD.
|
||||
certificate_content:
|
||||
type: string
|
||||
description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate
|
||||
and its matching private key for certificate-based authentication.
|
||||
maxLength: 68266
|
||||
tenant_id:
|
||||
type: string
|
||||
description: The Azure tenant ID, representing the directory where
|
||||
the application is registered.
|
||||
required:
|
||||
- client_id
|
||||
- certificate_content
|
||||
- tenant_id
|
||||
- type: object
|
||||
title: M365 Static Credentials
|
||||
properties:
|
||||
@@ -23456,8 +23520,9 @@ components:
|
||||
the application is registered.
|
||||
certificate_content:
|
||||
type: string
|
||||
description: The certificate content in base64 format for certificate-based
|
||||
authentication.
|
||||
description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate
|
||||
and its matching private key for certificate-based authentication.
|
||||
maxLength: 68266
|
||||
required:
|
||||
- client_id
|
||||
- tenant_id
|
||||
@@ -23510,7 +23575,8 @@ components:
|
||||
kubeconfig_content:
|
||||
type: string
|
||||
description: The content of the Kubernetes kubeconfig file, encoded
|
||||
as a string.
|
||||
as a string. Kubeconfig command-based authentication is not
|
||||
supported in Prowler Cloud for security reasons.
|
||||
required:
|
||||
- kubeconfig_content
|
||||
- type: object
|
||||
@@ -23572,17 +23638,23 @@ components:
|
||||
tenancy:
|
||||
type: string
|
||||
description: The OCID of the tenancy.
|
||||
region:
|
||||
type: string
|
||||
description: The OCI region identifier (e.g., us-ashburn-1, us-phoenix-1).
|
||||
pass_phrase:
|
||||
type: string
|
||||
description: The passphrase for the private key, if encrypted.
|
||||
region:
|
||||
type: string
|
||||
deprecated: true
|
||||
description: Legacy OCI region field accepted for backwards compatibility
|
||||
but ignored; OCI scans all regions.
|
||||
required:
|
||||
- user
|
||||
- fingerprint
|
||||
- tenancy
|
||||
- region
|
||||
anyOf:
|
||||
- required:
|
||||
- key_file
|
||||
- required:
|
||||
- key_content
|
||||
- type: object
|
||||
title: MongoDB Atlas API Key
|
||||
properties:
|
||||
@@ -23835,7 +23907,7 @@ components:
|
||||
- role_arn
|
||||
- external_id
|
||||
- type: object
|
||||
title: Azure Static Credentials
|
||||
title: Azure Client Secret Credentials
|
||||
properties:
|
||||
client_id:
|
||||
type: string
|
||||
@@ -23853,6 +23925,26 @@ components:
|
||||
- client_id
|
||||
- client_secret
|
||||
- tenant_id
|
||||
- type: object
|
||||
title: Azure Certificate Credentials
|
||||
properties:
|
||||
client_id:
|
||||
type: string
|
||||
description: The Azure application (client) ID for authentication
|
||||
in Azure AD.
|
||||
certificate_content:
|
||||
type: string
|
||||
description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate
|
||||
and its matching private key for certificate-based authentication.
|
||||
maxLength: 68266
|
||||
tenant_id:
|
||||
type: string
|
||||
description: The Azure tenant ID, representing the directory
|
||||
where the application is registered.
|
||||
required:
|
||||
- client_id
|
||||
- certificate_content
|
||||
- tenant_id
|
||||
- type: object
|
||||
title: M365 Static Credentials
|
||||
properties:
|
||||
@@ -23895,8 +23987,9 @@ components:
|
||||
where the application is registered.
|
||||
certificate_content:
|
||||
type: string
|
||||
description: The certificate content in base64 format for
|
||||
certificate-based authentication.
|
||||
description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate
|
||||
and its matching private key for certificate-based authentication.
|
||||
maxLength: 68266
|
||||
required:
|
||||
- client_id
|
||||
- tenant_id
|
||||
@@ -23950,7 +24043,8 @@ components:
|
||||
kubeconfig_content:
|
||||
type: string
|
||||
description: The content of the Kubernetes kubeconfig file,
|
||||
encoded as a string.
|
||||
encoded as a string. Kubeconfig command-based authentication
|
||||
is not supported in Prowler Cloud for security reasons.
|
||||
required:
|
||||
- kubeconfig_content
|
||||
- type: object
|
||||
@@ -24013,18 +24107,23 @@ components:
|
||||
tenancy:
|
||||
type: string
|
||||
description: The OCID of the tenancy.
|
||||
region:
|
||||
type: string
|
||||
description: The OCI region identifier (e.g., us-ashburn-1,
|
||||
us-phoenix-1).
|
||||
pass_phrase:
|
||||
type: string
|
||||
description: The passphrase for the private key, if encrypted.
|
||||
region:
|
||||
type: string
|
||||
deprecated: true
|
||||
description: Legacy OCI region field accepted for backwards
|
||||
compatibility but ignored; OCI scans all regions.
|
||||
required:
|
||||
- user
|
||||
- fingerprint
|
||||
- tenancy
|
||||
- region
|
||||
anyOf:
|
||||
- required:
|
||||
- key_file
|
||||
- required:
|
||||
- key_content
|
||||
- type: object
|
||||
title: MongoDB Atlas API Key
|
||||
properties:
|
||||
@@ -24297,7 +24396,7 @@ components:
|
||||
- role_arn
|
||||
- external_id
|
||||
- type: object
|
||||
title: Azure Static Credentials
|
||||
title: Azure Client Secret Credentials
|
||||
properties:
|
||||
client_id:
|
||||
type: string
|
||||
@@ -24315,6 +24414,26 @@ components:
|
||||
- client_id
|
||||
- client_secret
|
||||
- tenant_id
|
||||
- type: object
|
||||
title: Azure Certificate Credentials
|
||||
properties:
|
||||
client_id:
|
||||
type: string
|
||||
description: The Azure application (client) ID for authentication
|
||||
in Azure AD.
|
||||
certificate_content:
|
||||
type: string
|
||||
description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate
|
||||
and its matching private key for certificate-based authentication.
|
||||
maxLength: 68266
|
||||
tenant_id:
|
||||
type: string
|
||||
description: The Azure tenant ID, representing the directory where
|
||||
the application is registered.
|
||||
required:
|
||||
- client_id
|
||||
- certificate_content
|
||||
- tenant_id
|
||||
- type: object
|
||||
title: M365 Static Credentials
|
||||
properties:
|
||||
@@ -24357,8 +24476,9 @@ components:
|
||||
the application is registered.
|
||||
certificate_content:
|
||||
type: string
|
||||
description: The certificate content in base64 format for certificate-based
|
||||
authentication.
|
||||
description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate
|
||||
and its matching private key for certificate-based authentication.
|
||||
maxLength: 68266
|
||||
required:
|
||||
- client_id
|
||||
- tenant_id
|
||||
@@ -24411,7 +24531,8 @@ components:
|
||||
kubeconfig_content:
|
||||
type: string
|
||||
description: The content of the Kubernetes kubeconfig file, encoded
|
||||
as a string.
|
||||
as a string. Kubeconfig command-based authentication is not
|
||||
supported in Prowler Cloud for security reasons.
|
||||
required:
|
||||
- kubeconfig_content
|
||||
- type: object
|
||||
@@ -24473,17 +24594,23 @@ components:
|
||||
tenancy:
|
||||
type: string
|
||||
description: The OCID of the tenancy.
|
||||
region:
|
||||
type: string
|
||||
description: The OCI region identifier (e.g., us-ashburn-1, us-phoenix-1).
|
||||
pass_phrase:
|
||||
type: string
|
||||
description: The passphrase for the private key, if encrypted.
|
||||
region:
|
||||
type: string
|
||||
deprecated: true
|
||||
description: Legacy OCI region field accepted for backwards compatibility
|
||||
but ignored; OCI scans all regions.
|
||||
required:
|
||||
- user
|
||||
- fingerprint
|
||||
- tenancy
|
||||
- region
|
||||
anyOf:
|
||||
- required:
|
||||
- key_file
|
||||
- required:
|
||||
- key_content
|
||||
- type: object
|
||||
title: MongoDB Atlas API Key
|
||||
properties:
|
||||
@@ -26809,6 +26936,103 @@ components:
|
||||
$ref: '#/components/schemas/UserCreate'
|
||||
required:
|
||||
- data
|
||||
UserMe:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- id
|
||||
additionalProperties: false
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
|
||||
member is used to describe resource objects that share common attributes
|
||||
and relationships.
|
||||
enum:
|
||||
- users
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
attributes:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
maxLength: 150
|
||||
minLength: 3
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
description: Case insensitive
|
||||
maxLength: 254
|
||||
company_name:
|
||||
type: string
|
||||
maxLength: 150
|
||||
date_joined:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
required:
|
||||
- name
|
||||
- email
|
||||
relationships:
|
||||
type: object
|
||||
properties:
|
||||
memberships:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- memberships
|
||||
title: Resource Type Name
|
||||
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
|
||||
member is used to describe resource objects that share common
|
||||
attributes and relationships.
|
||||
required:
|
||||
- id
|
||||
- type
|
||||
required:
|
||||
- data
|
||||
description: The identifier of the related object.
|
||||
title: Resource Identifier
|
||||
readOnly: true
|
||||
roles:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- roles
|
||||
title: Resource Type Name
|
||||
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
|
||||
member is used to describe resource objects that share common
|
||||
attributes and relationships.
|
||||
required:
|
||||
- id
|
||||
- type
|
||||
required:
|
||||
- data
|
||||
description: The identifier of the related object.
|
||||
title: Resource Identifier
|
||||
readOnly: true
|
||||
UserMeResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/UserMe'
|
||||
required:
|
||||
- data
|
||||
UserResponse:
|
||||
type: object
|
||||
properties:
|
||||
@@ -26849,7 +27073,6 @@ components:
|
||||
required:
|
||||
- type
|
||||
- id
|
||||
additionalProperties: false
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
|
||||
@@ -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,233 @@ 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",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def certificate_bundle():
|
||||
import base64
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Prowler")])
|
||||
certificate = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
.issuer_name(subject)
|
||||
.public_key(private_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(datetime.now(UTC))
|
||||
.not_valid_after(datetime.now(UTC) + timedelta(days=1))
|
||||
.sign(private_key, hashes.SHA256())
|
||||
)
|
||||
bundle = certificate.public_bytes(
|
||||
serialization.Encoding.PEM
|
||||
) + private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
return base64.b64encode(bundle).decode("ascii")
|
||||
|
||||
def test_accepts_client_secret_only(self):
|
||||
# Backwards-compatibility guard: rows saved by the previous serializer
|
||||
# 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):
|
||||
certificate_content = self.certificate_bundle()
|
||||
serializer = AzureProviderSecret(
|
||||
data={**self.BASE, "certificate_content": certificate_content}
|
||||
)
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert serializer.validated_data["certificate_content"] == certificate_content
|
||||
assert "client_secret" not in serializer.validated_data
|
||||
|
||||
def test_rejects_both_client_secret_and_certificate_content(self):
|
||||
# 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.certificate_bundle(),
|
||||
}
|
||||
)
|
||||
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_accepts_non_uuid_tenant_and_client_ids_for_backward_compatibility(self):
|
||||
serializer = AzureProviderSecret(
|
||||
data={
|
||||
"tenant_id": "not-a-uuid",
|
||||
"client_id": "also-not-a-uuid",
|
||||
"client_secret": "fake-client-secret",
|
||||
}
|
||||
)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
def test_rejects_key_only_certificate_content(self):
|
||||
import base64
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
key_only_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
serializer = AzureProviderSecret(
|
||||
data={
|
||||
**self.BASE,
|
||||
"certificate_content": base64.b64encode(key_only_pem).decode("ascii"),
|
||||
}
|
||||
)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "certificate_content" in serializer.errors
|
||||
|
||||
def test_rejects_empty_strings_for_both(self):
|
||||
# DRF's CharField rejects "" at field-level before `validate()` runs.
|
||||
# The errors surface per-field rather than as non_field_errors, but
|
||||
# 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
|
||||
|
||||
def test_rejects_encrypted_pem_certificate_content(self):
|
||||
# `load_pem_private_key(password=None)` raises TypeError for
|
||||
# encrypted keys — the narrowed `except (binascii.Error, TypeError,
|
||||
# ValueError)` in the serializer must catch it and surface the
|
||||
# typed `azure-certificate-content` code rather than a 500.
|
||||
import base64
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
certificate = self._self_signed_certificate()
|
||||
encrypted_key_pem = rsa.generate_private_key(
|
||||
public_exponent=65537, key_size=2048
|
||||
).private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.BestAvailableEncryption(b"prowler"),
|
||||
)
|
||||
bundle = (
|
||||
certificate.public_bytes(serialization.Encoding.PEM) + encrypted_key_pem
|
||||
)
|
||||
|
||||
serializer = AzureProviderSecret(
|
||||
data={
|
||||
**self.BASE,
|
||||
"certificate_content": base64.b64encode(bundle).decode("ascii"),
|
||||
}
|
||||
)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "certificate_content" in serializer.errors
|
||||
assert (
|
||||
serializer.errors["certificate_content"][0].code
|
||||
== "azure-certificate-content"
|
||||
)
|
||||
|
||||
def test_rejects_oversized_certificate_content(self):
|
||||
# Payloads larger than the base64 cap must be rejected at the DRF
|
||||
# field layer before base64 decoding or bundle parsing runs, so a
|
||||
# multi-MB blob cannot exhaust API-worker memory.
|
||||
from api.v1.serializers import _MAX_CERTIFICATE_CONTENT_LENGTH
|
||||
|
||||
serializer = AzureProviderSecret(
|
||||
data={
|
||||
**self.BASE,
|
||||
"certificate_content": "A" * (_MAX_CERTIFICATE_CONTENT_LENGTH + 1),
|
||||
}
|
||||
)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "certificate_content" in serializer.errors
|
||||
|
||||
def test_mutex_errors_carry_stable_codes(self):
|
||||
# JSON:API clients key on `code`; without it they cannot tell the
|
||||
# mutex ("both provided") apart from the required-material error
|
||||
# ("neither provided") without string-matching the message.
|
||||
both = AzureProviderSecret(
|
||||
data={
|
||||
**self.BASE,
|
||||
"client_secret": "fake-client-secret",
|
||||
"certificate_content": self.certificate_bundle(),
|
||||
}
|
||||
)
|
||||
assert not both.is_valid()
|
||||
assert both.errors["non_field_errors"][0].code == "azure-credential-mutex"
|
||||
|
||||
neither = AzureProviderSecret(data=self.BASE)
|
||||
assert not neither.is_valid()
|
||||
assert neither.errors["non_field_errors"][0].code == "azure-credential-required"
|
||||
|
||||
@staticmethod
|
||||
def _self_signed_certificate():
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Prowler")])
|
||||
return (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
.issuer_name(subject)
|
||||
.public_key(private_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(datetime.now(UTC))
|
||||
.not_valid_after(datetime.now(UTC) + timedelta(days=1))
|
||||
.sign(private_key, hashes.SHA256())
|
||||
)
|
||||
|
||||
|
||||
class TestOracleCloudProviderSecret:
|
||||
def valid_secret(self, **overrides):
|
||||
secret = {
|
||||
@@ -244,6 +472,39 @@ class TestOracleCloudProviderSecret:
|
||||
|
||||
|
||||
class TestProviderSecretFieldSchema:
|
||||
def test_azure_schema_exposes_exclusive_supported_credential_shapes(self):
|
||||
schema = ProviderSecretField._spectacular_annotation["field"]
|
||||
azure_schemas = {
|
||||
credential_schema["title"]: credential_schema
|
||||
for credential_schema in schema["oneOf"]
|
||||
if credential_schema["title"].startswith("Azure ")
|
||||
}
|
||||
|
||||
assert set(azure_schemas) == {
|
||||
"Azure Client Secret Credentials",
|
||||
"Azure Certificate Credentials",
|
||||
}
|
||||
assert azure_schemas["Azure Client Secret Credentials"]["required"] == [
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"tenant_id",
|
||||
]
|
||||
assert set(azure_schemas["Azure Client Secret Credentials"]["properties"]) == {
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"tenant_id",
|
||||
}
|
||||
assert azure_schemas["Azure Certificate Credentials"]["required"] == [
|
||||
"client_id",
|
||||
"certificate_content",
|
||||
"tenant_id",
|
||||
]
|
||||
assert set(azure_schemas["Azure Certificate Credentials"]["properties"]) == {
|
||||
"client_id",
|
||||
"certificate_content",
|
||||
"tenant_id",
|
||||
}
|
||||
|
||||
def test_oraclecloud_schema_includes_legacy_region_field(self):
|
||||
schema = ProviderSecretField._spectacular_annotation["field"]
|
||||
oraclecloud_schema = next(
|
||||
|
||||
@@ -1,14 +1,131 @@
|
||||
import socket
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from api.validators import (
|
||||
resolve_lighthouse_openai_compatible_host,
|
||||
validate_certificate_bundle,
|
||||
validate_lighthouse_openai_compatible_base_url,
|
||||
)
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||
from cryptography.x509.oid import NameOID
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import override_settings
|
||||
|
||||
|
||||
def _certificate_and_key():
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Prowler")])
|
||||
certificate = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
.issuer_name(subject)
|
||||
.public_key(private_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(datetime.now(UTC))
|
||||
.not_valid_after(datetime.now(UTC) + timedelta(days=1))
|
||||
.sign(private_key, hashes.SHA256())
|
||||
)
|
||||
return certificate, private_key
|
||||
|
||||
|
||||
def test_certificate_bundle_rejects_key_only_pkcs12():
|
||||
_, private_key = _certificate_and_key()
|
||||
key_only_pkcs12 = pkcs12.serialize_key_and_certificates(
|
||||
name=b"prowler",
|
||||
key=private_key,
|
||||
cert=None,
|
||||
cas=None,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="does not contain a certificate"):
|
||||
validate_certificate_bundle(key_only_pkcs12)
|
||||
|
||||
|
||||
def test_certificate_bundle_rejects_mismatched_pem_key():
|
||||
certificate, _ = _certificate_and_key()
|
||||
different_private_key = rsa.generate_private_key(
|
||||
public_exponent=65537, key_size=2048
|
||||
)
|
||||
mismatched_bundle = certificate.public_bytes(
|
||||
serialization.Encoding.PEM
|
||||
) + different_private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="does not match"):
|
||||
validate_certificate_bundle(mismatched_bundle)
|
||||
|
||||
|
||||
def test_certificate_bundle_rejects_encrypted_pem_key():
|
||||
# `cryptography.load_pem_private_key(..., password=None)` raises
|
||||
# TypeError for encrypted keys; the API relies on that specific type
|
||||
# to route to `azure-certificate-content`, not a generic 500.
|
||||
certificate, _ = _certificate_and_key()
|
||||
encrypted_key_pem = rsa.generate_private_key(
|
||||
public_exponent=65537, key_size=2048
|
||||
).private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.BestAvailableEncryption(b"prowler"),
|
||||
)
|
||||
encrypted_bundle = (
|
||||
certificate.public_bytes(serialization.Encoding.PEM) + encrypted_key_pem
|
||||
)
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
validate_certificate_bundle(encrypted_bundle)
|
||||
|
||||
|
||||
def test_certificate_bundle_normalizes_multi_key_bundle_when_second_key_matches():
|
||||
# A PEM bundle may legitimately carry more than one private key block
|
||||
# (e.g. legacy tools that export both RSA and PKCS#8 encodings).
|
||||
# The validator must find the key that actually pairs with the leaf
|
||||
# instead of stopping at the first `-----BEGIN PRIVATE KEY-----`.
|
||||
leaf_cert, leaf_key = _certificate_and_key()
|
||||
_, unrelated_key = _certificate_and_key()
|
||||
|
||||
leaf_cert_pem = leaf_cert.public_bytes(serialization.Encoding.PEM)
|
||||
unrelated_key_pem = unrelated_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
leaf_key_pem = leaf_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
bundle = leaf_cert_pem + unrelated_key_pem + leaf_key_pem
|
||||
|
||||
normalized = validate_certificate_bundle(bundle)
|
||||
|
||||
# The leaf still leads (azure-identity's thumbprint invariant) and the
|
||||
# matching key is the one paired in the normalized output.
|
||||
assert normalized.startswith(leaf_cert_pem)
|
||||
assert leaf_key_pem in normalized
|
||||
|
||||
|
||||
def test_certificate_bundle_rejects_oversized_payload():
|
||||
# Legitimate PEM/PFX bundles are well under 10 KiB. Reject anything
|
||||
# above the 50 KiB cap before base64 decoding + PKCS#12/PEM parsing
|
||||
# allocate the doubled memory a multi-MB payload would need.
|
||||
from prowler.providers.azure.lib.certificate import (
|
||||
_MAX_CERTIFICATE_BUNDLE_BYTES,
|
||||
)
|
||||
|
||||
oversized = b"\x00" * (_MAX_CERTIFICATE_BUNDLE_BYTES + 1)
|
||||
|
||||
with pytest.raises(ValueError, match="maximum bundle size"):
|
||||
validate_certificate_bundle(oversized)
|
||||
|
||||
|
||||
def test_lighthouse_base_url_rejects_http_scheme():
|
||||
with pytest.raises(ValidationError, match="HTTPS"):
|
||||
validate_lighthouse_openai_compatible_base_url(
|
||||
|
||||
@@ -78,7 +78,7 @@ from rest_framework_json_api import serializers
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "Azure Static Credentials",
|
||||
"title": "Azure Client Secret Credentials",
|
||||
"properties": {
|
||||
"client_id": {
|
||||
"type": "string",
|
||||
@@ -97,6 +97,26 @@ from rest_framework_json_api import serializers
|
||||
},
|
||||
"required": ["client_id", "client_secret", "tenant_id"],
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "Azure Certificate Credentials",
|
||||
"properties": {
|
||||
"client_id": {
|
||||
"type": "string",
|
||||
"description": "The Azure application (client) ID for authentication in Azure AD.",
|
||||
},
|
||||
"certificate_content": {
|
||||
"type": "string",
|
||||
"description": "Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate and its matching private key for certificate-based authentication.",
|
||||
},
|
||||
"tenant_id": {
|
||||
"type": "string",
|
||||
"description": "The Azure tenant ID, representing the directory where the application is "
|
||||
"registered.",
|
||||
},
|
||||
},
|
||||
"required": ["client_id", "certificate_content", "tenant_id"],
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "M365 Static Credentials",
|
||||
@@ -149,7 +169,7 @@ from rest_framework_json_api import serializers
|
||||
},
|
||||
"certificate_content": {
|
||||
"type": "string",
|
||||
"description": "The certificate content in base64 format for certificate-based authentication.",
|
||||
"description": "Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate and its matching private key for certificate-based authentication.",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
@@ -60,7 +61,10 @@ from api.v1.serializer_utils.lighthouse import (
|
||||
)
|
||||
from api.v1.serializer_utils.processors import ProcessorConfigField
|
||||
from api.v1.serializer_utils.providers import ProviderSecretField
|
||||
from api.validators import validate_lighthouse_openai_compatible_base_url
|
||||
from api.validators import (
|
||||
validate_certificate_bundle,
|
||||
validate_lighthouse_openai_compatible_base_url,
|
||||
)
|
||||
from config.custom_logging import BackendLogger
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import authenticate
|
||||
@@ -1785,10 +1789,59 @@ class AwsProviderSecret(serializers.Serializer):
|
||||
resource_name = "provider-secrets"
|
||||
|
||||
|
||||
# Base64 cap that matches the SDK's 50 KiB `_MAX_CERTIFICATE_BUNDLE_BYTES`
|
||||
# limit on the decoded bundle. Rejects oversized payloads at the request
|
||||
# layer so DRF never allocates the doubled memory that base64 decoding plus
|
||||
# PKCS#12/PEM parsing would need for a multi-MB blob.
|
||||
_MAX_CERTIFICATE_CONTENT_LENGTH = 68266
|
||||
|
||||
|
||||
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, max_length=_MAX_CERTIFICATE_CONTENT_LENGTH
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
if attrs.get("client_secret") and attrs.get("certificate_content"):
|
||||
raise serializers.ValidationError(
|
||||
"You cannot provide both client_secret and certificate_content.",
|
||||
code="azure-credential-mutex",
|
||||
)
|
||||
if not attrs.get("client_secret") and not attrs.get("certificate_content"):
|
||||
raise serializers.ValidationError(
|
||||
"You must provide either client_secret or certificate_content.",
|
||||
code="azure-credential-required",
|
||||
)
|
||||
return super().validate(attrs)
|
||||
|
||||
def validate_certificate_content(self, certificate_content):
|
||||
"""Validate the Azure certificate and matching private-key bundle."""
|
||||
if certificate_content:
|
||||
try:
|
||||
certificate_data = base64.b64decode(certificate_content, validate=True)
|
||||
validate_certificate_bundle(certificate_data)
|
||||
# `binascii.Error` (bad base64), `TypeError` (encrypted PEM key)
|
||||
# and `ValueError` (mismatched cert/key, oversized bundle,
|
||||
# malformed bytes) are the failure modes `validate_certificate_bundle`
|
||||
# and `base64.b64decode` surface. Anything else is a real bug
|
||||
# and should propagate.
|
||||
except (binascii.Error, TypeError, ValueError) as e:
|
||||
logger.error(
|
||||
f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}]: {e}"
|
||||
)
|
||||
# Field validators are invoked per-field; DRF already knows
|
||||
# this error belongs to `certificate_content` and will nest
|
||||
# the message under that key. Raising a dict here would
|
||||
# double-nest the JSON:API pointer as
|
||||
# `/certificate_content/certificate_content`.
|
||||
raise serializers.ValidationError(
|
||||
"Certificate content must be valid base64 containing an X.509 certificate and its matching private key.",
|
||||
code="azure-certificate-content",
|
||||
) from e
|
||||
return certificate_content
|
||||
|
||||
class Meta:
|
||||
resource_name = "provider-secrets"
|
||||
@@ -1800,16 +1853,20 @@ class M365ProviderSecret(serializers.Serializer):
|
||||
tenant_id = serializers.CharField()
|
||||
user = serializers.EmailField(required=False)
|
||||
password = serializers.CharField(required=False)
|
||||
certificate_content = serializers.CharField(required=False)
|
||||
certificate_content = serializers.CharField(
|
||||
required=False, max_length=_MAX_CERTIFICATE_CONTENT_LENGTH
|
||||
)
|
||||
|
||||
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."
|
||||
"You cannot provide both client_secret and certificate_content.",
|
||||
code="m365-credential-mutex",
|
||||
)
|
||||
if not attrs.get("client_secret") and not attrs.get("certificate_content"):
|
||||
raise serializers.ValidationError(
|
||||
"You must provide either client_secret or certificate_content."
|
||||
"You must provide either client_secret or certificate_content.",
|
||||
code="m365-credential-required",
|
||||
)
|
||||
return super().validate(attrs)
|
||||
|
||||
@@ -1818,13 +1875,13 @@ class M365ProviderSecret(serializers.Serializer):
|
||||
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)}"
|
||||
]
|
||||
},
|
||||
# `base64.b64decode(validate=True)` raises `binascii.Error`; the
|
||||
# legacy alias `ValueError` is preserved for older builds.
|
||||
except (binascii.Error, ValueError) as e:
|
||||
# DRF field validators are already keyed to `certificate_content`,
|
||||
# so raising a dict here would double-nest the JSON:API pointer.
|
||||
raise serializers.ValidationError(
|
||||
f"The provided certificate content is not valid base64 encoded data: {str(e)}",
|
||||
code="m365-certificate-content",
|
||||
)
|
||||
return certificate_content
|
||||
|
||||
@@ -7,6 +7,14 @@ from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
# Re-exported so the SDK stays the single source of truth for bundle parsing:
|
||||
# it covers PKCS#12 UnsupportedAlgorithm, encrypted PEM keys (TypeError), the
|
||||
# full private-key PEM label set, and leaf-first normalization for
|
||||
# azure-identity's thumbprint. A local copy silently drifted before.
|
||||
from prowler.providers.azure.lib.certificate import ( # noqa: F401
|
||||
validate_certificate_bundle,
|
||||
)
|
||||
|
||||
LIGHTHOUSE_OPENAI_COMPATIBLE_ALLOWED_SCHEMES = frozenset({"https"})
|
||||
LIGHTHOUSE_NAT64_WELL_KNOWN_PREFIX = ipaddress.IPv6Network("64:ff9b::/96")
|
||||
LIGHTHOUSE_BLOCKED_METADATA_HOSTS = frozenset(
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#",
|
||||
"contentVersion": "1.0.0.0",
|
||||
"metadata": {
|
||||
"_generator": {
|
||||
"name": "bicep",
|
||||
"version": "0.46.1.21595",
|
||||
"templateHash": "16146816613430830317"
|
||||
}
|
||||
},
|
||||
"parameters": {
|
||||
"servicePrincipalObjectId": {
|
||||
"type": "string",
|
||||
"metadata": {
|
||||
"description": "Object ID of the Service Principal for the App Registration Prowler will use. Find it in Azure Portal → Microsoft Entra ID → Enterprise applications → your app → Overview → Object ID. This is NOT the same as the App Registration's Object ID; the Service Principal has its own separate Object ID."
|
||||
}
|
||||
},
|
||||
"deploymentLabel": {
|
||||
"type": "string",
|
||||
"defaultValue": "Prowler",
|
||||
"metadata": {
|
||||
"description": "Cosmetic label included in the custom role description. Free text; keep the default unless you need to distinguish multiple Prowler deployments."
|
||||
}
|
||||
},
|
||||
"customRoleName": {
|
||||
"type": "string",
|
||||
"defaultValue": "ProwlerRole",
|
||||
"metadata": {
|
||||
"description": "Name of the extra role Prowler creates. Keep the default unless your org already uses this name."
|
||||
}
|
||||
}
|
||||
},
|
||||
"variables": {
|
||||
"customRoleDefinitionName": "[guid(subscription().id, parameters('customRoleName'))]",
|
||||
"readerRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'acdd72a7-3385-48ef-bd42-f606fba81ae7')]"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"type": "Microsoft.Authorization/roleDefinitions",
|
||||
"apiVersion": "2022-05-01-preview",
|
||||
"name": "[variables('customRoleDefinitionName')]",
|
||||
"properties": {
|
||||
"roleName": "[parameters('customRoleName')]",
|
||||
"description": "[format('Role used by {0} for Prowler checks that require Azure actions beyond the built-in Reader role.', parameters('deploymentLabel'))]",
|
||||
"type": "CustomRole",
|
||||
"assignableScopes": [
|
||||
"[subscription().id]"
|
||||
],
|
||||
"permissions": [
|
||||
{
|
||||
"actions": [
|
||||
"Microsoft.Web/sites/host/listkeys/action",
|
||||
"Microsoft.Web/sites/config/list/Action"
|
||||
],
|
||||
"notActions": [],
|
||||
"dataActions": [],
|
||||
"notDataActions": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Microsoft.Authorization/roleAssignments",
|
||||
"apiVersion": "2022-04-01",
|
||||
"name": "[guid(subscription().id, parameters('servicePrincipalObjectId'), variables('readerRoleDefinitionId'))]",
|
||||
"properties": {
|
||||
"principalId": "[parameters('servicePrincipalObjectId')]",
|
||||
"principalType": "ServicePrincipal",
|
||||
"roleDefinitionId": "[variables('readerRoleDefinitionId')]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Microsoft.Authorization/roleAssignments",
|
||||
"apiVersion": "2022-04-01",
|
||||
"name": "[guid(subscription().id, parameters('servicePrincipalObjectId'), subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('customRoleDefinitionName')))]",
|
||||
"properties": {
|
||||
"principalId": "[parameters('servicePrincipalObjectId')]",
|
||||
"principalType": "ServicePrincipal",
|
||||
"roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('customRoleDefinitionName'))]"
|
||||
},
|
||||
"dependsOn": [
|
||||
"[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('customRoleDefinitionName'))]"
|
||||
]
|
||||
}
|
||||
],
|
||||
"outputs": {
|
||||
"tenantId": {
|
||||
"type": "string",
|
||||
"value": "[subscription().tenantId]"
|
||||
},
|
||||
"subscriptionId": {
|
||||
"type": "string",
|
||||
"value": "[subscription().subscriptionId]"
|
||||
},
|
||||
"prowlerRoleDefinitionId": {
|
||||
"type": "string",
|
||||
"value": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('customRoleDefinitionName'))]"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
{/* AUTO-GENERATED from permissions/templates/azure/bicep/prowler-scan.json. Do not edit manually. */}
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#",
|
||||
"contentVersion": "1.0.0.0",
|
||||
"metadata": {
|
||||
"_generator": {
|
||||
"name": "bicep",
|
||||
"version": "0.46.1.21595",
|
||||
"templateHash": "16146816613430830317"
|
||||
}
|
||||
},
|
||||
"parameters": {
|
||||
"servicePrincipalObjectId": {
|
||||
"type": "string",
|
||||
"metadata": {
|
||||
"description": "Object ID of the Service Principal for the App Registration Prowler will use. Find it in Azure Portal → Microsoft Entra ID → Enterprise applications → your app → Overview → Object ID. This is NOT the same as the App Registration's Object ID; the Service Principal has its own separate Object ID."
|
||||
}
|
||||
},
|
||||
"deploymentLabel": {
|
||||
"type": "string",
|
||||
"defaultValue": "Prowler",
|
||||
"metadata": {
|
||||
"description": "Cosmetic label included in the custom role description. Free text; keep the default unless you need to distinguish multiple Prowler deployments."
|
||||
}
|
||||
},
|
||||
"customRoleName": {
|
||||
"type": "string",
|
||||
"defaultValue": "ProwlerRole",
|
||||
"metadata": {
|
||||
"description": "Name of the extra role Prowler creates. Keep the default unless your org already uses this name."
|
||||
}
|
||||
}
|
||||
},
|
||||
"variables": {
|
||||
"customRoleDefinitionName": "[guid(subscription().id, parameters('customRoleName'))]",
|
||||
"readerRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'acdd72a7-3385-48ef-bd42-f606fba81ae7')]"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"type": "Microsoft.Authorization/roleDefinitions",
|
||||
"apiVersion": "2022-05-01-preview",
|
||||
"name": "[variables('customRoleDefinitionName')]",
|
||||
"properties": {
|
||||
"roleName": "[parameters('customRoleName')]",
|
||||
"description": "[format('Role used by {0} for Prowler checks that require Azure actions beyond the built-in Reader role.', parameters('deploymentLabel'))]",
|
||||
"type": "CustomRole",
|
||||
"assignableScopes": [
|
||||
"[subscription().id]"
|
||||
],
|
||||
"permissions": [
|
||||
{
|
||||
"actions": [
|
||||
"Microsoft.Web/sites/host/listkeys/action",
|
||||
"Microsoft.Web/sites/config/list/Action"
|
||||
],
|
||||
"notActions": [],
|
||||
"dataActions": [],
|
||||
"notDataActions": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Microsoft.Authorization/roleAssignments",
|
||||
"apiVersion": "2022-04-01",
|
||||
"name": "[guid(subscription().id, parameters('servicePrincipalObjectId'), variables('readerRoleDefinitionId'))]",
|
||||
"properties": {
|
||||
"principalId": "[parameters('servicePrincipalObjectId')]",
|
||||
"principalType": "ServicePrincipal",
|
||||
"roleDefinitionId": "[variables('readerRoleDefinitionId')]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Microsoft.Authorization/roleAssignments",
|
||||
"apiVersion": "2022-04-01",
|
||||
"name": "[guid(subscription().id, parameters('servicePrincipalObjectId'), subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('customRoleDefinitionName')))]",
|
||||
"properties": {
|
||||
"principalId": "[parameters('servicePrincipalObjectId')]",
|
||||
"principalType": "ServicePrincipal",
|
||||
"roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('customRoleDefinitionName'))]"
|
||||
},
|
||||
"dependsOn": [
|
||||
"[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('customRoleDefinitionName'))]"
|
||||
]
|
||||
}
|
||||
],
|
||||
"outputs": {
|
||||
"tenantId": {
|
||||
"type": "string",
|
||||
"value": "[subscription().tenantId]"
|
||||
},
|
||||
"subscriptionId": {
|
||||
"type": "string",
|
||||
"value": "[subscription().subscriptionId]"
|
||||
},
|
||||
"prowlerRoleDefinitionId": {
|
||||
"type": "string",
|
||||
"value": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('customRoleDefinitionName'))]"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,16 +1,20 @@
|
||||
---
|
||||
title: 'Azure Authentication in Prowler'
|
||||
title: "Azure Authentication in Prowler"
|
||||
---
|
||||
|
||||
import AzureProwlerScanTemplate from "/snippets/azure-prowler-scan-template.mdx";
|
||||
|
||||
Prowler for Azure supports multiple authentication types. Authentication methods vary between Prowler Cloud and Prowler CLI:
|
||||
|
||||
**Prowler Cloud:**
|
||||
|
||||
- [**Service Principal Application**](#service-principal-application-authentication-recommended)
|
||||
- [**Certificate Authentication**](#certificate-authentication) (**Recommended**)
|
||||
- [**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)
|
||||
@@ -58,6 +62,7 @@ Replace `Directory.Read.All` with `Domain.Read.All` for more restrictive permiss
|
||||
|
||||

|
||||

|
||||
|
||||
</Tab>
|
||||
<Tab title="Azure CLI">
|
||||
1. To grant permissions to a Service Principal, execute the following command in a terminal:
|
||||
@@ -65,6 +70,7 @@ Replace `Directory.Read.All` with `Domain.Read.All` for more restrictive permiss
|
||||
```console
|
||||
az ad app permission add --id {appId} --api 00000003-0000-0000-c000-000000000000 --api-permissions 7ab1d382-f21e-4acd-a863-ba3e13f7da61=Role 246dd0d5-5bd0-4def-940b-0421030a5b68=Role b0afded3-3588-46d8-8b3d-9842eff778da=Role
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
### Subscription Scope Permissions
|
||||
@@ -74,8 +80,8 @@ These permissions are required to perform security checks against Azure resource
|
||||
- `Reader` – Grants read-only access to Azure resources.
|
||||
- `ProwlerRole` – A custom role with minimal permissions needed for some specific checks, defined in the [prowler-azure-custom-role](https://github.com/prowler-cloud/prowler/blob/master/permissions/prowler-azure-custom-role.json).
|
||||
|
||||
|
||||
#### Assigning "Reader" Role at the Subscription Level
|
||||
|
||||
By default, Prowler scans all accessible subscriptions. If you need to audit specific subscriptions, you must assign the necessary role `Reader` for each one. For streamlined and less repetitive role assignments in multi-subscription environments, refer to the [following section](/user-guide/providers/azure/subscriptions#recommendation-for-managing-multiple-subscriptions).
|
||||
|
||||
<Tabs>
|
||||
@@ -94,6 +100,7 @@ By default, Prowler scans all accessible subscriptions. If you need to audit spe
|
||||
6. Click "Review + assign" to finalize and apply the role assignment.
|
||||
|
||||

|
||||
|
||||
</Tab>
|
||||
<Tab title="Azure CLI">
|
||||
1. Open a terminal and execute the following command to assign the `Reader` role to the identity that is going to be assumed by Prowler:
|
||||
@@ -101,6 +108,7 @@ By default, Prowler scans all accessible subscriptions. If you need to audit spe
|
||||
```console
|
||||
az role assignment create --role "Reader" --assignee <user, group, or service principal> --scope /subscriptions/<subscription-id>
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
#### Assigning "ProwlerRole" Permissions at the Subscription Level
|
||||
@@ -140,6 +148,7 @@ The checks requiring this `ProwlerRole` can be found in this [section](/user-gui
|
||||
The `assignableScopes` field in the JSON custom role file must be updated to reflect the correct subscription or management group. Use one of the following formats: `/subscriptions/<subscription-id>` or `/providers/Microsoft.Management/managementGroups/<management-group-id>`.
|
||||
|
||||
</Note>
|
||||
|
||||
</Tab>
|
||||
<Tab title="Azure CLI">
|
||||
1. To create a new custom role, open a terminal and execute the following command:
|
||||
@@ -159,38 +168,39 @@ The checks requiring this `ProwlerRole` can be found in this [section](/user-gui
|
||||
}'
|
||||
```
|
||||
|
||||
2. If the command is executed successfully, the output is going to be similar to the following:
|
||||
2. If the command is executed successfully, the output is going to be similar to the following:
|
||||
|
||||
```json
|
||||
{
|
||||
"assignableScopes": [
|
||||
"/subscriptions/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
|
||||
],
|
||||
"createdBy": null,
|
||||
"createdOn": "YYYY-MM-DDTHH:MM:SS.SSSSSS+00:00",
|
||||
"description": "Role used for checks that require read-only access to Azure resources and are not covered by the Reader role.",
|
||||
"id": "/subscriptions/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX/providers/Microsoft.Authorization/roleDefinitions/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
|
||||
"name": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
|
||||
"permissions": [
|
||||
```json
|
||||
{
|
||||
"actions": [
|
||||
"Microsoft.Web/sites/host/listkeys/action",
|
||||
"Microsoft.Web/sites/config/list/Action"
|
||||
"assignableScopes": [
|
||||
"/subscriptions/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
|
||||
],
|
||||
"condition": null,
|
||||
"conditionVersion": null,
|
||||
"dataActions": [],
|
||||
"notActions": [],
|
||||
"notDataActions": []
|
||||
"createdBy": null,
|
||||
"createdOn": "YYYY-MM-DDTHH:MM:SS.SSSSSS+00:00",
|
||||
"description": "Role used for checks that require read-only access to Azure resources and are not covered by the Reader role.",
|
||||
"id": "/subscriptions/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX/providers/Microsoft.Authorization/roleDefinitions/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
|
||||
"name": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
|
||||
"permissions": [
|
||||
{
|
||||
"actions": [
|
||||
"Microsoft.Web/sites/host/listkeys/action",
|
||||
"Microsoft.Web/sites/config/list/Action"
|
||||
],
|
||||
"condition": null,
|
||||
"conditionVersion": null,
|
||||
"dataActions": [],
|
||||
"notActions": [],
|
||||
"notDataActions": []
|
||||
}
|
||||
],
|
||||
"roleName": "ProwlerRole",
|
||||
"roleType": "CustomRole",
|
||||
"type": "Microsoft.Authorization/roleDefinitions",
|
||||
"updatedBy": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
|
||||
"updatedOn": "YYYY-MM-DDTHH:MM:SS.SSSSSS+00:00"
|
||||
}
|
||||
],
|
||||
"roleName": "ProwlerRole",
|
||||
"roleType": "CustomRole",
|
||||
"type": "Microsoft.Authorization/roleDefinitions",
|
||||
"updatedBy": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
|
||||
"updatedOn": "YYYY-MM-DDTHH:MM:SS.SSSSSS+00:00"
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
@@ -214,13 +224,149 @@ The following security checks require the `ProwlerRole` permissions for executio
|
||||
- `app_function_ftps_deployment_disabled`
|
||||
- `app_function_latest_runtime_version`
|
||||
|
||||
---
|
||||
## Certificate Authentication
|
||||
|
||||
## Service Principal Application Authentication (Recommended)
|
||||
Certificate authentication is the recommended way to run Prowler against Azure because Microsoft Entra ID stores only the public certificate, so no shared client secret needs to be rotated. Upload the public certificate to the App Registration and provide Prowler with the matching private bundle.
|
||||
|
||||
This method is required for Prowler Cloud and recommended for Prowler CLI.
|
||||
### Prerequisites
|
||||
|
||||
Certificate authentication touches two separate permission systems: **Microsoft Entra ID** (identity) and **Azure RBAC** (subscription). Both are required.
|
||||
|
||||
#### Microsoft Entra ID roles per setup step
|
||||
|
||||
| Setup step | Minimum role required |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **1. Register a new App Registration** | Any member user, _by default_. Member users can register applications through the built-in default user permissions. If the tenant disabled this by setting _Users can register applications_ to **No** (under **Microsoft Entra ID** > **Users** > **User settings**), the user needs one of: `Application Administrator`, `Cloud Application Administrator`, or `Global Administrator`. |
|
||||
| **2. Upload the certificate to the App Registration** | The App Registration's owner (the user who created it, added automatically) — or `Application Administrator`, `Cloud Application Administrator`, or `Global Administrator`. |
|
||||
| **3. Add Microsoft Graph _application_ permissions (request)** — `AuditLog.Read.All`, `Directory.Read.All`, `Policy.Read.All` | The App Registration's owner — or `Application Administrator`, `Cloud Application Administrator`, or `Global Administrator`. Requesting a permission and granting consent are separate operations; see step 4 for the consent role. |
|
||||
| **4. Grant tenant-wide admin consent for those permissions** ⚠️ | **`Global Administrator`** or **`Privileged Role Administrator`** _only_. `Application Administrator`, `Cloud Application Administrator`, and `AI Administrator` **cannot** consent to _Microsoft Graph_ application permissions — they can consent to application permissions for other APIs, but not Microsoft Graph app roles, per [Microsoft's admin consent documentation](https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/grant-admin-consent#prerequisites) and [Microsoft Graph permissions overview](https://learn.microsoft.com/en-us/graph/permissions-overview). |
|
||||
| **5. Read the Service Principal Object ID from Enterprise applications** | Any signed-in user (read-only, granted by default user permissions). |
|
||||
|
||||
The Service Principal Object ID lives on **Microsoft Entra ID** > **Enterprise applications** > _the application_ > **Overview**. This value is different from the Object ID that appears under **App registrations** for the same application; both objects share the same Application (client) ID but each has its own Object ID.
|
||||
|
||||
#### Azure RBAC role for the subscription template
|
||||
|
||||
The Deploy to Azure template creates one custom role definition and two role assignments (`Reader` and the custom `ProwlerRole`) at subscription scope. The account that runs the deployment needs one of:
|
||||
|
||||
- **`Owner`** on the target subscription. Simplest and most common. `Owner` grants both `Microsoft.Authorization/roleDefinitions/write` and `Microsoft.Authorization/roleAssignments/write`.
|
||||
- Alternatively, **`Contributor` combined with `User Access Administrator`** on the target subscription. `Contributor` alone is not enough because it explicitly cannot create role assignments (see [Azure built-in roles](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles)); `User Access Administrator` fills the gap for role definitions and role assignments.
|
||||
|
||||
<Note>
|
||||
**Global Administrator does not automatically have Azure RBAC.** Per
|
||||
[Microsoft's Elevate access
|
||||
documentation](https://learn.microsoft.com/en-us/azure/role-based-access-control/elevate-access-global-admin):
|
||||
"As a Global Administrator in Microsoft Entra ID, you might not have access to
|
||||
all subscriptions and management groups in your tenant." To grant a Global
|
||||
Administrator temporary access, open **Microsoft Entra ID** > **Properties**
|
||||
and set **Access management for Azure resources** to **Yes**. This assigns
|
||||
`User Access Administrator` at root scope (`/`), which covers step 6. Disable
|
||||
the toggle after the setup is done — the assignment persists per-user until
|
||||
revoked.
|
||||
</Note>
|
||||
|
||||
#### Shortest single-user path
|
||||
|
||||
The simplest way for one user to complete the whole flow is **`Global Administrator`** in Microsoft Entra ID (covers steps 1-4 by inclusion) combined with the Elevate access toggle above (covers step 6). Deploying with a least-privilege split (for example, an `Application Administrator` for step 1-3, a `Privileged Role Administrator` for step 4, and an `Owner` for step 6) is also supported.
|
||||
|
||||
### Generating the Certificate
|
||||
|
||||
Generate the key pair locally. Upload only the public certificate to Microsoft Entra ID and keep the private key secure.
|
||||
|
||||
**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, if a base64 representation is needed
|
||||
openssl x509 -in prowler.crt -outform DER | base64 | tr -d '\n'
|
||||
|
||||
# Base64 of a PEM bundle containing the certificate AND the private key —
|
||||
# paste into Prowler as "Certificate Content". A key-only file fails.
|
||||
cat prowler.crt prowler.key > prowler-bundle.pem
|
||||
base64 < prowler-bundle.pem | tr -d '\n'
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
|
||||
```powershell
|
||||
$cert = New-SelfSignedCertificate -Subject "CN=Prowler" `
|
||||
-CertStoreLocation "Cert:\CurrentUser\My" `
|
||||
-KeyExportPolicy Exportable -KeySpec Signature `
|
||||
-KeyLength 4096 -HashAlgorithm SHA256
|
||||
|
||||
Export-Certificate -Cert $cert -FilePath prowler.cer
|
||||
|
||||
# Export an unencrypted PKCS#12/PFX bundle for Prowler. Keep this value secret.
|
||||
$pfxBytes = $cert.Export('Pfx', '')
|
||||
[Convert]::ToBase64String($pfxBytes)
|
||||
```
|
||||
|
||||
### Deploying Subscription Permissions
|
||||
|
||||
The Prowler Azure Resource Manager (ARM) template creates the subscription permissions required by Prowler for an existing Service Principal. The template does not create an App Registration, create a Service Principal, upload a certificate, or grant Microsoft Graph permissions.
|
||||
|
||||
To deploy the template:
|
||||
|
||||
1. Copy the Service Principal Object ID from **Enterprise applications**.
|
||||
2. Click [**Deploy to Azure**](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fdocs.prowler.com%2Fassets%2Ftemplates%2Fazure%2Fprowler-scan.json).
|
||||
3. Select the target subscription, enter the Service Principal Object ID, and review the optional deployment label and custom role name.
|
||||
4. Create the deployment.
|
||||
|
||||
The deployment creates:
|
||||
|
||||
- The subscription-scoped `ProwlerRole` custom role definition.
|
||||
- A `Reader` role assignment for the existing Service Principal.
|
||||
- A `ProwlerRole` role assignment for the existing Service Principal.
|
||||
|
||||
The deployment outputs the tenant ID, subscription ID, and `ProwlerRole` definition ID. Return to Prowler with the App Registration's Application (client) ID and the certificate bundle after the deployment succeeds.
|
||||
|
||||
<Note>
|
||||
Azure Portal loads the public ARM JSON from
|
||||
`https://docs.prowler.com/assets/templates/azure/prowler-scan.json`. For a
|
||||
sovereign cloud or a deployment that cannot retrieve this public URL, download
|
||||
the [ARM JSON template](/assets/templates/azure/prowler-scan.json) and deploy
|
||||
it with the Azure CLI or Azure PowerShell.
|
||||
</Note>
|
||||
|
||||
### ARM JSON Template
|
||||
|
||||
The following JSON is generated from the same canonical template that Azure Portal retrieves from Prowler documentation:
|
||||
|
||||
<AzureProwlerScanTemplate />
|
||||
|
||||
### Prowler Cloud
|
||||
|
||||
Paste the following into the Certificate Authentication form of the Azure add-provider wizard:
|
||||
|
||||
- **Tenant ID** — copy the Directory (tenant) ID from the App Registration overview.
|
||||
- **Client ID** — copy the Application (client) ID from the App Registration overview.
|
||||
- **Certificate Content** — provide the base64-encoded **PEM bundle** (certificate + private key) or an unencrypted PKCS#12/PFX export. Prowler needs both parts to sign the token request and does not accept password-protected PKCS#12/PFX input.
|
||||
|
||||
### Prowler CLI
|
||||
|
||||
Set the certificate environment variables and run 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 certificate and private key bundle>"
|
||||
|
||||
prowler azure --certificate-auth
|
||||
```
|
||||
|
||||
Alternatively, provide a PEM file or an unencrypted PKCS#12/PFX file that contains both the certificate and matching private key. Password-protected PKCS#12/PFX input is not supported:
|
||||
|
||||
```console
|
||||
prowler azure --certificate-auth --certificate-path /path/to/prowler-bundle.pem
|
||||
```
|
||||
|
||||
## Service Principal Application Authentication
|
||||
|
||||
This client-secret flow is the supported fallback when [Certificate Authentication](#certificate-authentication) is not an option or when an organization policy forbids certificate-based Service Principals. New Prowler Cloud onboardings should prefer certificate authentication.
|
||||
|
||||
### Creating the Service Principal
|
||||
|
||||
For more information, see [Creating Prowler Service Principal](/user-guide/providers/azure/create-prowler-service-principal).
|
||||
|
||||
### Environment Variables (CLI)
|
||||
@@ -237,7 +383,7 @@ Execution with the `--sp-env-auth` flag fails if these variables are not set or
|
||||
|
||||
## AZ CLI Authentication
|
||||
|
||||
*Available only for Prowler CLI*
|
||||
_Available only for Prowler CLI_
|
||||
|
||||
Use stored Azure CLI credentials:
|
||||
|
||||
@@ -247,7 +393,7 @@ prowler azure --az-cli-auth
|
||||
|
||||
## Managed Identity Authentication
|
||||
|
||||
*Available only for Prowler CLI*
|
||||
_Available only for Prowler CLI_
|
||||
|
||||
Authenticate via Azure Managed Identity when running Prowler on Azure resources (VMs, Container Instances, Azure Functions, etc.):
|
||||
|
||||
@@ -263,7 +409,13 @@ Before using Managed Identity authentication, the following steps are required:
|
||||
2. **Assign the required permissions** to the Managed Identity on the target subscription(s) to scan
|
||||
|
||||
<Warning>
|
||||
A common misconception is that enabling a Managed Identity on a resource automatically grants it permissions. **This is not the case.** Without explicit role assignments, Prowler will be unable to scan subscriptions and will return authorization errors, resulting in incomplete security assessments. The Managed Identity itself is a service principal that must be explicitly granted Reader and ProwlerRole permissions on each subscription to scan.
|
||||
A common misconception is that enabling a Managed Identity on a resource
|
||||
automatically grants it permissions. **This is not the case.** Without
|
||||
explicit role assignments, Prowler will be unable to scan subscriptions and
|
||||
will return authorization errors, resulting in incomplete security
|
||||
assessments. The Managed Identity itself is a service principal that must be
|
||||
explicitly granted Reader and ProwlerRole permissions on each subscription to
|
||||
scan.
|
||||
</Warning>
|
||||
|
||||
### Step-by-Step Setup Guide
|
||||
@@ -287,6 +439,7 @@ A common misconception is that enabling a Managed Identity on a resource automat
|
||||
# Get the principal ID
|
||||
az vm identity show --name <vm-name> --resource-group <resource-group> --query principalId -o tsv
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="Azure Container Instance">
|
||||
**Via Azure CLI:**
|
||||
@@ -301,6 +454,7 @@ A common misconception is that enabling a Managed Identity on a resource automat
|
||||
# Get the principal ID
|
||||
az container show --resource-group <resource-group> --name <container-name> --query identity.principalId -o tsv
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
@@ -322,6 +476,7 @@ The Managed Identity needs the **Reader** role on each subscription to scan. Thi
|
||||
<Note>
|
||||
When scanning a subscription different from where the VM is located, ensure the role is assigned on the **target subscription**, not the VM's subscription.
|
||||
</Note>
|
||||
|
||||
</Tab>
|
||||
<Tab title="Azure CLI">
|
||||
```console
|
||||
@@ -335,6 +490,7 @@ The Managed Identity needs the **Reader** role on each subscription to scan. Thi
|
||||
--assignee-principal-type ServicePrincipal \
|
||||
--scope /subscriptions/<target-subscription-id>
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
@@ -370,6 +526,7 @@ The ProwlerRole is a custom role required for specific security checks. First, c
|
||||
--assignee-principal-type ServicePrincipal \
|
||||
--scope /subscriptions/<target-subscription-id>
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="Azure Portal">
|
||||
Follow the same process as creating the ProwlerRole in the [Assigning ProwlerRole Permissions](/user-guide/providers/azure/authentication#assigning-prowlerrole-permissions-at-the-subscription-level) section, then assign it to the Managed Identity using the same steps as the Reader role assignment.
|
||||
@@ -381,7 +538,9 @@ The ProwlerRole is a custom role required for specific security checks. First, c
|
||||
For Entra ID (Azure AD) checks, the Managed Identity needs Microsoft Graph API permissions: `Directory.Read.All`, `Policy.Read.All`, and `AuditLog.Read.All`.
|
||||
|
||||
<Note>
|
||||
Assigning Microsoft Graph API permissions to a Managed Identity requires Azure CLI or PowerShell - it cannot be done through the Azure Portal's standard role assignment interface.
|
||||
Assigning Microsoft Graph API permissions to a Managed Identity requires Azure
|
||||
CLI or PowerShell - it cannot be done through the Azure Portal's standard role
|
||||
assignment interface.
|
||||
</Note>
|
||||
|
||||
```console
|
||||
@@ -417,7 +576,8 @@ prowler azure --managed-identity-auth --subscription-ids <subscription-id>
|
||||
```
|
||||
|
||||
<Note>
|
||||
Wait a few minutes after assigning roles for Azure to propagate permissions. Role assignments are not always immediately effective.
|
||||
Wait a few minutes after assigning roles for Azure to propagate permissions.
|
||||
Role assignments are not always immediately effective.
|
||||
</Note>
|
||||
|
||||
### Troubleshooting
|
||||
@@ -427,6 +587,7 @@ Wait a few minutes after assigning roles for Azure to propagate permissions. Rol
|
||||
**Cause:** The Managed Identity does not have the Reader role assigned on any subscription.
|
||||
|
||||
**Solution:**
|
||||
|
||||
- Verify the Managed Identity has the Reader role assigned on at least one subscription.
|
||||
- Wait a few minutes after role assignment for Azure to propagate permissions.
|
||||
- Verify role assignments:
|
||||
@@ -439,6 +600,7 @@ Wait a few minutes after assigning roles for Azure to propagate permissions. Rol
|
||||
**Cause:** The Managed Identity lacks the Reader role on the target subscription.
|
||||
|
||||
**Solution:**
|
||||
|
||||
- Ensure the Reader role is assigned to the **Managed Identity's principal ID**, not the VM resource.
|
||||
- Verify the role is assigned on the **target subscription** to scan, not just the VM's resource group.
|
||||
- Check role assignments:
|
||||
@@ -451,6 +613,7 @@ Wait a few minutes after assigning roles for Azure to propagate permissions. Rol
|
||||
**Cause:** Managed Identity is not enabled on the resource, or Prowler is running outside of Azure.
|
||||
|
||||
**Solution:**
|
||||
|
||||
- Verify Managed Identity is enabled on the Azure resource.
|
||||
- Ensure Prowler is running from within the Azure resource (not a local machine).
|
||||
- Check Managed Identity status:
|
||||
@@ -463,12 +626,13 @@ Wait a few minutes after assigning roles for Azure to propagate permissions. Rol
|
||||
**Cause:** The Managed Identity lacks Microsoft Graph API permissions.
|
||||
|
||||
**Solution:**
|
||||
|
||||
- Assign the required Graph API permissions as shown in Step 4.
|
||||
- These permissions are optional for basic resource scanning but required for Entra ID security checks.
|
||||
|
||||
## Browser Authentication
|
||||
|
||||
*Available only for Prowler CLI*
|
||||
_Available only for Prowler CLI_
|
||||
|
||||
Authenticate using the default browser:
|
||||
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
---
|
||||
title: 'Getting Started With Azure on Prowler'
|
||||
title: "Getting Started With Azure on Prowler"
|
||||
---
|
||||
|
||||
## Prowler Cloud
|
||||
|
||||
<iframe width="560" height="380" src="https://www.youtube-nocookie.com/embed/v1as8vTFlMg" title="Prowler Cloud Onboarding Azure" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen="1"></iframe>
|
||||
<iframe
|
||||
width="560"
|
||||
height="380"
|
||||
src="https://www.youtube-nocookie.com/embed/v1as8vTFlMg"
|
||||
title="Prowler Cloud Onboarding Azure"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen="1"
|
||||
></iframe>
|
||||
> Walkthrough video onboarding an Azure Subscription using Service Principal.
|
||||
|
||||
|
||||
<Note>
|
||||
**Government Cloud Support**
|
||||
|
||||
@@ -27,8 +34,8 @@ For detailed instructions on how to create the Service Principal and configure p
|
||||
1. Go to the [Azure Portal](https://portal.azure.com/#home) and search for `Subscriptions`
|
||||
2. Locate and copy your Subscription ID
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
@@ -37,44 +44,46 @@ For detailed instructions on how to create the Service Principal and configure p
|
||||
1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app)
|
||||
2. Navigate to `Configuration` > `Providers`
|
||||
|
||||

|
||||

|
||||
|
||||
3. Click `Add Provider`
|
||||
|
||||

|
||||

|
||||
|
||||
4. Select `Microsoft Azure`
|
||||
|
||||

|
||||

|
||||
|
||||
5. Add the Subscription ID and an optional alias, then click `Next`
|
||||
|
||||

|
||||

|
||||
|
||||
### 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`
|
||||
Certificate authentication uses a Microsoft Entra ID App Registration with an X.509 certificate. Complete the App Registration, certificate upload, Microsoft Graph permissions, and subscription permissions by following [Azure Certificate Authentication](/user-guide/providers/azure/authentication#certificate-authentication).
|
||||
|
||||

|
||||
1. In the Azure wizard, select **Certificate Authentication (Recommended)**.
|
||||
2. Paste the Directory (tenant) ID and Application (client) ID from the App Registration.
|
||||
3. Paste the base64-encoded certificate and private key bundle. To create a new key pair, click **Generate certificate**, upload the downloaded `prowler-cert.cer` file to the App Registration, and keep the generated bundle in the form.
|
||||
4. 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)
|
||||
Client-secret authentication remains available when certificate authentication is not suitable.
|
||||
|
||||

|
||||
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 `Next`, then **Launch Scan**.
|
||||
|
||||
4. Click "Launch Scan"
|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
@@ -86,7 +95,7 @@ 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)**
|
||||
**Service Principal with Client Secret**
|
||||
|
||||
Set up environment variables:
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# 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.
|
||||
#
|
||||
# Defaults to the standalone Bicep CLI. Set `BICEP=az bicep` to use the
|
||||
# Azure CLI wrapper installed by `az bicep install`.
|
||||
|
||||
BICEP ?= bicep
|
||||
BICEP_BUILD := $(BICEP) build
|
||||
|
||||
ifeq ($(strip $(BICEP)),az bicep)
|
||||
BICEP_BUILD += --file
|
||||
endif
|
||||
|
||||
.PHONY: build check clean sync-docs
|
||||
|
||||
build: prowler-scan.json sync-docs
|
||||
|
||||
prowler-scan.json: prowler-scan.bicep
|
||||
$(BICEP_BUILD) $<
|
||||
|
||||
sync-docs: prowler-scan.json
|
||||
python3 sync_docs_template.py --sync
|
||||
|
||||
# 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
|
||||
@set -eu; \
|
||||
tmp=$$(mktemp); \
|
||||
trap 'rm -f "$$tmp"' EXIT; \
|
||||
$(BICEP_BUILD) prowler-scan.bicep --outfile "$$tmp"; \
|
||||
python3 -c 'from pathlib import Path; import sys; path = Path(sys.argv[1]); path.write_bytes(path.read_bytes().rstrip(b"\r\n") + b"\n")' "$$tmp"; \
|
||||
diff -q prowler-scan.json "$$tmp"; \
|
||||
python3 sync_docs_template.py --check
|
||||
|
||||
clean:
|
||||
rm -f prowler-scan.json
|
||||
@@ -0,0 +1,201 @@
|
||||
# Prowler Azure Bicep Template
|
||||
|
||||
This directory contains the Bicep source and compiled Azure Resource Manager
|
||||
(ARM) JSON template documented in the [Azure authentication
|
||||
guide](../../../../docs/user-guide/providers/azure/authentication.mdx).
|
||||
|
||||
Deploying `prowler-scan.bicep` at subscription scope grants a **pre-existing**
|
||||
App Registration the subscription permissions Prowler needs:
|
||||
|
||||
1. A subscription-scoped assignment of the built-in `Reader` role.
|
||||
2. 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 the deployment
|
||||
label, so redeploying updates the existing resources instead of creating
|
||||
duplicates.
|
||||
|
||||
## Why the template doesn't create the App Registration itself
|
||||
|
||||
Microsoft.Graph Bicep resources (`Microsoft.Graph/applications`,
|
||||
`Microsoft.Graph/servicePrincipals`, etc.) are **not supported by the
|
||||
Azure Portal "Deploy to Azure" URL flow**. This is a documented Microsoft
|
||||
limitation, not a permission or configuration problem — see
|
||||
[microsoftgraph/msgraph-bicep-types#294](https://github.com/microsoftgraph/msgraph-bicep-types/issues/294)
|
||||
(closed as documented limitation) and Microsoft's own
|
||||
[permissions and privileges docs](https://learn.microsoft.com/en-us/graph/templates/bicep/concept-permissions-and-privileges),
|
||||
which list only Azure CLI and Azure PowerShell as supported deployment paths
|
||||
for templates containing `Microsoft.Graph/*` resources.
|
||||
|
||||
A template that includes those resources fails at Portal deploy time with
|
||||
`Authorization_RequestDenied: Insufficient privileges to complete the
|
||||
operation` from Microsoft Graph, regardless of the deploying user's Entra
|
||||
ID role — a **Global Administrator** hits the same wall. Create the App
|
||||
Registration and upload the certificate separately, then use this template to
|
||||
grant the existing Service Principal the RBAC roles Prowler needs.
|
||||
|
||||
## Required permissions to run the deployment
|
||||
|
||||
The account that deploys the template needs `Owner` on the target subscription.
|
||||
That is enough
|
||||
to create the custom role definition and assign both roles. `Contributor`
|
||||
is not sufficient because it cannot create role assignments.
|
||||
|
||||
Creating and managing the App Registration requires the relevant Microsoft
|
||||
Entra ID permission. App Registration creation is available without an
|
||||
administrator when the tenant setting _Users can register applications_ is
|
||||
enabled. An administrator authorized to grant tenant-wide consent must approve
|
||||
the Microsoft Graph application permissions.
|
||||
|
||||
## Files
|
||||
|
||||
- `prowler-scan.bicep` — Bicep source (source of truth). Vanilla ARM only —
|
||||
no `Microsoft.Graph` extension, so it deploys cleanly through the Portal.
|
||||
- `prowler-scan.json` — compiled ARM JSON. 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`.
|
||||
- `sync_docs_template.py` — copies the canonical JSON bytes to the public docs
|
||||
asset and generates the MDX code snippet shown in the authentication guide.
|
||||
- `Makefile` — `make build` regenerates the JSON and synchronizes both docs
|
||||
outputs; `make check` fails when the Bicep build or either docs output drifts.
|
||||
|
||||
Prowler documentation serves the compiled JSON at:
|
||||
|
||||
```text
|
||||
https://docs.prowler.com/assets/templates/azure/prowler-scan.json
|
||||
```
|
||||
|
||||
The **Deploy to Azure** link in the authentication guide opens
|
||||
`https://portal.azure.com/#create/Microsoft.Template/uri/<encoded-json-url>`,
|
||||
which loads the documentation-hosted ARM JSON into Azure Portal. The Bicep
|
||||
source remains in this directory and is not served as a public asset.
|
||||
|
||||
## Regenerating the ARM JSON
|
||||
|
||||
The Makefile uses the standalone Bicep CLI by default. After editing
|
||||
`prowler-scan.bicep`, run:
|
||||
|
||||
```bash
|
||||
make build
|
||||
make check
|
||||
```
|
||||
|
||||
Download the standalone binary from
|
||||
<https://github.com/Azure/bicep/releases>. To use the Azure CLI wrapper
|
||||
instead, install it and pass the wrapper command explicitly:
|
||||
|
||||
```bash
|
||||
az bicep install
|
||||
make build BICEP='az bicep'
|
||||
make check BICEP='az bicep'
|
||||
```
|
||||
|
||||
The Makefile adds Azure CLI's required `--file` option when
|
||||
`BICEP='az bicep'` is set.
|
||||
|
||||
## Manual App Registration and Certificate Steps
|
||||
|
||||
1. **Create the App Registration** in Portal → **Microsoft Entra ID** →
|
||||
**App registrations** → **New registration**. Give it any name, keep
|
||||
the default _single tenant_ audience, no redirect URI.
|
||||
2. **Upload the certificate** on the same App Registration → **Certificates
|
||||
and secrets** → **Certificates** tab → **Upload certificate**. Upload
|
||||
the public `.cer` file. Prowler's **Generate certificate** button downloads
|
||||
`prowler-cert.cer` directly and fills the private bundle field.
|
||||
3. **Grant Microsoft Graph permissions** on the App Registration. Add the
|
||||
`AuditLog.Read.All`, `Directory.Read.All` (or `Domain.Read.All`), and
|
||||
`Policy.Read.All` application permissions, then grant admin consent.
|
||||
4. **Copy the Service Principal Object ID**: Portal → **Microsoft Entra
|
||||
ID** → **Enterprise applications** → search for the app you just
|
||||
created → click it → **Object ID** on the Overview page. That is the
|
||||
value the Bicep template asks for as `servicePrincipalObjectId`. It is
|
||||
NOT the same as the App Registration's Object ID (Enterprise
|
||||
applications and App registrations are two separate objects with
|
||||
separate Object IDs — same App ID / Client ID, different Object IDs).
|
||||
5. **Copy the Application (client) ID** from the App Registration overview
|
||||
— provide this value in Prowler's _Client ID_ field.
|
||||
|
||||
### Certificate generation cheatsheet
|
||||
|
||||
**Generate certificate** in Prowler is the easiest option — it
|
||||
generates a keypair in your browser, auto-fills the base64-encoded
|
||||
certificate and private key bundle into the wizard, and downloads the raw
|
||||
DER public certificate as `prowler-cert.cer` for upload to the App
|
||||
Registration.
|
||||
|
||||
If you prefer the command line:
|
||||
|
||||
#### macOS / Linux (OpenSSL)
|
||||
|
||||
```bash
|
||||
# 1. Generate a 4096-bit RSA private key and matching self-signed cert
|
||||
openssl req -x509 -newkey rsa:4096 -keyout prowler.key -out prowler.crt \
|
||||
-days 365 -nodes -subj "/CN=Prowler"
|
||||
|
||||
# 2. Upload prowler.crt to the App Registration (Portal, step 2 above).
|
||||
|
||||
# 3. Bundle certificate + private key into a single PEM and base64-encode
|
||||
# it. `azure.identity.CertificateCredential` needs BOTH parts — a
|
||||
# key-only file fails with "No certificate found". Keep this value
|
||||
# secret; it is what Prowler uses to authenticate.
|
||||
cat prowler.crt prowler.key > prowler-bundle.pem
|
||||
CERT_BUNDLE_BASE64=$(base64 < prowler-bundle.pem | tr -d '\n')
|
||||
|
||||
echo "Certificate and Private Key Bundle for Prowler: $CERT_BUNDLE_BASE64"
|
||||
```
|
||||
|
||||
#### Windows (PowerShell)
|
||||
|
||||
```powershell
|
||||
$cert = New-SelfSignedCertificate -Subject "CN=Prowler" `
|
||||
-CertStoreLocation "Cert:\CurrentUser\My" `
|
||||
-KeyExportPolicy Exportable -KeySpec Signature `
|
||||
-KeyLength 4096 -HashAlgorithm SHA256
|
||||
|
||||
# Save the .cer to upload in the Portal
|
||||
Export-Certificate -Cert $cert -FilePath prowler.cer
|
||||
|
||||
# Unencrypted certificate and private key bundle (PKCS#12/PFX), base64-encoded
|
||||
# for Prowler. Password-protected PKCS#12/PFX input is not supported. Keep secret.
|
||||
$pfxBytes = $cert.Export('Pfx', '')
|
||||
$keyBase64 = [Convert]::ToBase64String($pfxBytes)
|
||||
|
||||
Write-Host "Certificate and Private Key Bundle for Prowler: $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 servicePrincipalObjectId=<sp-object-id>
|
||||
```
|
||||
|
||||
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 servicePrincipalObjectId=<sp-object-id>
|
||||
```
|
||||
|
||||
## After the Deployment
|
||||
|
||||
Paste the following into Prowler's Certificate Authentication form:
|
||||
|
||||
- **Tenant ID** — the `tenantId` output (also visible in Portal → Entra ID
|
||||
→ Overview).
|
||||
- **Client ID** — the Application (client) ID of the App Registration you
|
||||
created manually in step 1 of the manual flow above.
|
||||
- **Certificate and Private Key Bundle** — the base64-encoded PEM bundle
|
||||
(certificate + private key) or PKCS#12 export from the generation step.
|
||||
This bundle is submitted to Prowler and never touches Azure.
|
||||
|
||||
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,102 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// Prowler quick-start deployment (RBAC only)
|
||||
//
|
||||
// Subscription-scoped template that grants a pre-existing App Registration
|
||||
// / Service Principal the permissions Prowler needs to scan an
|
||||
// Azure subscription:
|
||||
//
|
||||
// 1. Assignment of the built-in `Reader` role at subscription scope so
|
||||
// Prowler can inventory resources.
|
||||
// 2. 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.
|
||||
//
|
||||
// The template does NOT create the App Registration itself. Microsoft.Graph
|
||||
// Bicep resources are not supported by the Azure Portal "Deploy to Azure"
|
||||
// flow (Microsoft docs: `msgraph-bicep-types` issue #294, closed as a
|
||||
// documented limitation), so any template that includes them fails at
|
||||
// deploy time with `Authorization_RequestDenied` regardless of the user's
|
||||
// Entra ID role. The wizard therefore guides the user to create the App
|
||||
// Registration and upload the certificate manually in the Portal first,
|
||||
// then deploys this template with the resulting service principal's
|
||||
// Object ID.
|
||||
//
|
||||
// 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, subscriptionId)
|
||||
// into Prowler along with the App Registration's Client ID and the private
|
||||
// key that pairs with the certificate uploaded to Entra ID manually.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
targetScope = 'subscription'
|
||||
|
||||
@description('Object ID of the Service Principal for the App Registration Prowler will use. Find it in Azure Portal → Microsoft Entra ID → Enterprise applications → your app → Overview → Object ID. This is NOT the same as the App Registration\'s Object ID; the Service Principal has its own separate Object ID.')
|
||||
param servicePrincipalObjectId string
|
||||
|
||||
@description('Cosmetic label included in the custom role description. Free text; keep the default unless you need to distinguish multiple Prowler deployments.')
|
||||
param deploymentLabel string = 'Prowler'
|
||||
|
||||
@description('Name of the extra role Prowler creates. Keep the default unless your org already uses this name.')
|
||||
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.
|
||||
var customRoleDefinitionName = guid(subscription().id, customRoleName)
|
||||
var readerRoleDefinitionId = subscriptionResourceId(
|
||||
'Microsoft.Authorization/roleDefinitions',
|
||||
'acdd72a7-3385-48ef-bd42-f606fba81ae7' // built-in Reader
|
||||
)
|
||||
|
||||
// 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 ${deploymentLabel} for Prowler checks that require Azure actions 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, servicePrincipalObjectId, readerRoleDefinitionId)
|
||||
properties: {
|
||||
principalId: servicePrincipalObjectId
|
||||
principalType: 'ServicePrincipal'
|
||||
roleDefinitionId: readerRoleDefinitionId
|
||||
}
|
||||
}
|
||||
|
||||
resource prowlerRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
|
||||
name: guid(subscription().id, servicePrincipalObjectId, prowlerRole.id)
|
||||
properties: {
|
||||
principalId: servicePrincipalObjectId
|
||||
principalType: 'ServicePrincipal'
|
||||
roleDefinitionId: prowlerRole.id
|
||||
}
|
||||
}
|
||||
|
||||
output tenantId string = subscription().tenantId
|
||||
output subscriptionId string = subscription().subscriptionId
|
||||
output prowlerRoleDefinitionId string = prowlerRole.id
|
||||
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#",
|
||||
"contentVersion": "1.0.0.0",
|
||||
"metadata": {
|
||||
"_generator": {
|
||||
"name": "bicep",
|
||||
"version": "0.46.1.21595",
|
||||
"templateHash": "16146816613430830317"
|
||||
}
|
||||
},
|
||||
"parameters": {
|
||||
"servicePrincipalObjectId": {
|
||||
"type": "string",
|
||||
"metadata": {
|
||||
"description": "Object ID of the Service Principal for the App Registration Prowler will use. Find it in Azure Portal → Microsoft Entra ID → Enterprise applications → your app → Overview → Object ID. This is NOT the same as the App Registration's Object ID; the Service Principal has its own separate Object ID."
|
||||
}
|
||||
},
|
||||
"deploymentLabel": {
|
||||
"type": "string",
|
||||
"defaultValue": "Prowler",
|
||||
"metadata": {
|
||||
"description": "Cosmetic label included in the custom role description. Free text; keep the default unless you need to distinguish multiple Prowler deployments."
|
||||
}
|
||||
},
|
||||
"customRoleName": {
|
||||
"type": "string",
|
||||
"defaultValue": "ProwlerRole",
|
||||
"metadata": {
|
||||
"description": "Name of the extra role Prowler creates. Keep the default unless your org already uses this name."
|
||||
}
|
||||
}
|
||||
},
|
||||
"variables": {
|
||||
"customRoleDefinitionName": "[guid(subscription().id, parameters('customRoleName'))]",
|
||||
"readerRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'acdd72a7-3385-48ef-bd42-f606fba81ae7')]"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"type": "Microsoft.Authorization/roleDefinitions",
|
||||
"apiVersion": "2022-05-01-preview",
|
||||
"name": "[variables('customRoleDefinitionName')]",
|
||||
"properties": {
|
||||
"roleName": "[parameters('customRoleName')]",
|
||||
"description": "[format('Role used by {0} for Prowler checks that require Azure actions beyond the built-in Reader role.', parameters('deploymentLabel'))]",
|
||||
"type": "CustomRole",
|
||||
"assignableScopes": [
|
||||
"[subscription().id]"
|
||||
],
|
||||
"permissions": [
|
||||
{
|
||||
"actions": [
|
||||
"Microsoft.Web/sites/host/listkeys/action",
|
||||
"Microsoft.Web/sites/config/list/Action"
|
||||
],
|
||||
"notActions": [],
|
||||
"dataActions": [],
|
||||
"notDataActions": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Microsoft.Authorization/roleAssignments",
|
||||
"apiVersion": "2022-04-01",
|
||||
"name": "[guid(subscription().id, parameters('servicePrincipalObjectId'), variables('readerRoleDefinitionId'))]",
|
||||
"properties": {
|
||||
"principalId": "[parameters('servicePrincipalObjectId')]",
|
||||
"principalType": "ServicePrincipal",
|
||||
"roleDefinitionId": "[variables('readerRoleDefinitionId')]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Microsoft.Authorization/roleAssignments",
|
||||
"apiVersion": "2022-04-01",
|
||||
"name": "[guid(subscription().id, parameters('servicePrincipalObjectId'), subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('customRoleDefinitionName')))]",
|
||||
"properties": {
|
||||
"principalId": "[parameters('servicePrincipalObjectId')]",
|
||||
"principalType": "ServicePrincipal",
|
||||
"roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('customRoleDefinitionName'))]"
|
||||
},
|
||||
"dependsOn": [
|
||||
"[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('customRoleDefinitionName'))]"
|
||||
]
|
||||
}
|
||||
],
|
||||
"outputs": {
|
||||
"tenantId": {
|
||||
"type": "string",
|
||||
"value": "[subscription().tenantId]"
|
||||
},
|
||||
"subscriptionId": {
|
||||
"type": "string",
|
||||
"value": "[subscription().subscriptionId]"
|
||||
},
|
||||
"prowlerRoleDefinitionId": {
|
||||
"type": "string",
|
||||
"value": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('customRoleDefinitionName'))]"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[4]
|
||||
DEFAULT_SOURCE = Path(__file__).with_name("prowler-scan.json")
|
||||
DEFAULT_ASSET = REPOSITORY_ROOT / "docs/assets/templates/azure/prowler-scan.json"
|
||||
DEFAULT_SNIPPET = REPOSITORY_ROOT / "docs/snippets/azure-prowler-scan-template.mdx"
|
||||
SNIPPET_PREFIX = (
|
||||
b"{/* AUTO-GENERATED from permissions/templates/azure/bicep/"
|
||||
b"prowler-scan.json. Do not edit manually. */}\n\n```json\n"
|
||||
)
|
||||
SNIPPET_SUFFIX = b"```\n"
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Synchronize the Azure ARM template with Prowler documentation."
|
||||
)
|
||||
mode = parser.add_mutually_exclusive_group(required=True)
|
||||
mode.add_argument("--sync", action="store_true")
|
||||
mode.add_argument("--check", action="store_true")
|
||||
parser.add_argument("--source", type=Path, default=DEFAULT_SOURCE)
|
||||
parser.add_argument("--asset", type=Path, default=DEFAULT_ASSET)
|
||||
parser.add_argument("--snippet", type=Path, default=DEFAULT_SNIPPET)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def expected_snippet(source):
|
||||
return SNIPPET_PREFIX + source + SNIPPET_SUFFIX
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
source_bytes = args.source.read_bytes()
|
||||
source = source_bytes.rstrip(b"\r\n") + b"\n"
|
||||
json.loads(source)
|
||||
snippet = expected_snippet(source)
|
||||
|
||||
if args.sync:
|
||||
args.asset.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.snippet.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.source.write_bytes(source)
|
||||
args.asset.write_bytes(source)
|
||||
args.snippet.write_bytes(snippet)
|
||||
return 0
|
||||
|
||||
drifted = []
|
||||
if source_bytes != source:
|
||||
drifted.append(args.source)
|
||||
if not args.asset.exists() or args.asset.read_bytes() != source:
|
||||
drifted.append(args.asset)
|
||||
if not args.snippet.exists() or args.snippet.read_bytes() != snippet:
|
||||
drifted.append(args.snippet)
|
||||
|
||||
if drifted:
|
||||
paths = ", ".join(str(path) for path in drifted)
|
||||
print(
|
||||
f"Azure documentation template drift detected: {paths}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,107 @@
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).with_name("sync_docs_template.py")
|
||||
|
||||
|
||||
class SyncDocsTemplateTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
root = Path(self.temp_dir.name)
|
||||
self.source = root / "prowler-scan.json"
|
||||
self.asset = root / "docs/assets/prowler-scan.json"
|
||||
self.snippet = root / "docs/snippets/prowler-scan.mdx"
|
||||
self.canonical = b'{"contentVersion":"1.0.0.0"}'
|
||||
self.source.write_bytes(self.canonical)
|
||||
|
||||
def tearDown(self):
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def run_script(self, mode):
|
||||
return subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
SCRIPT,
|
||||
mode,
|
||||
"--source",
|
||||
self.source,
|
||||
"--asset",
|
||||
self.asset,
|
||||
"--snippet",
|
||||
self.snippet,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
def test_sync_normalizes_canonical_and_asset_to_one_trailing_lf(self):
|
||||
for terminal_newlines in (b"", b"\n", b"\n\n", b"\r\n\r\n"):
|
||||
with self.subTest(terminal_newlines=terminal_newlines):
|
||||
self.source.write_bytes(self.canonical + terminal_newlines)
|
||||
|
||||
result = self.run_script("--sync")
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
expected = self.canonical + b"\n"
|
||||
self.assertEqual(self.source.read_bytes(), expected)
|
||||
self.assertEqual(self.asset.read_bytes(), expected)
|
||||
|
||||
def test_sync_normalizes_snippet_fence_and_final_lf(self):
|
||||
for terminal_newlines in (b"", b"\n", b"\n\n", b"\r\n\r\n"):
|
||||
with self.subTest(terminal_newlines=terminal_newlines):
|
||||
self.source.write_bytes(self.canonical + terminal_newlines)
|
||||
|
||||
result = self.run_script("--sync")
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertTrue(
|
||||
self.snippet.read_bytes().endswith(self.canonical + b"\n```\n")
|
||||
)
|
||||
|
||||
def test_check_fails_when_generated_output_drifts(self):
|
||||
self.assertEqual(self.run_script("--sync").returncode, 0)
|
||||
self.asset.write_text('{"contentVersion":"stale"}')
|
||||
|
||||
result = self.run_script("--check")
|
||||
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("Azure documentation template drift detected", result.stderr)
|
||||
|
||||
def test_check_fails_when_displayed_snippet_drifts(self):
|
||||
self.assertEqual(self.run_script("--sync").returncode, 0)
|
||||
self.snippet.write_text("```json\n{}\n```\n")
|
||||
|
||||
result = self.run_script("--check")
|
||||
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("Azure documentation template drift detected", result.stderr)
|
||||
|
||||
def test_check_fails_when_canonical_template_changes(self):
|
||||
self.assertEqual(self.run_script("--sync").returncode, 0)
|
||||
self.source.write_text('{"contentVersion":"2.0.0.0"}')
|
||||
|
||||
result = self.run_script("--check")
|
||||
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("Azure documentation template drift detected", result.stderr)
|
||||
|
||||
def test_role_assignment_ids_ignore_cosmetic_deployment_label(self):
|
||||
template = json.loads(Path(__file__).with_name("prowler-scan.json").read_text())
|
||||
role_assignments = [
|
||||
resource
|
||||
for resource in template["resources"]
|
||||
if resource["type"] == "Microsoft.Authorization/roleAssignments"
|
||||
]
|
||||
|
||||
self.assertEqual(len(role_assignments), 2)
|
||||
for assignment in role_assignments:
|
||||
self.assertNotIn("deploymentLabel", assignment["name"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1 @@
|
||||
Certificate-based service principal authentication for the Azure provider, alongside the existing client-secret flow
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
)
|
||||
|
||||
@@ -29,6 +29,21 @@ def init_parser(self):
|
||||
action="store_true",
|
||||
help="Use managed identity authentication to log in against Azure ",
|
||||
)
|
||||
azure_auth_modes_group.add_argument(
|
||||
"--certificate-auth",
|
||||
action="store_true",
|
||||
help="Use certificate authentication to log in against Azure",
|
||||
)
|
||||
# Modifier of --certificate-auth, not a separate mode. The pairing is
|
||||
# enforced in `validate_arguments` so a stray combination like
|
||||
# `--browser-auth --certificate-path X` fails fast instead of dropping
|
||||
# the certificate silently.
|
||||
azure_parser.add_argument(
|
||||
"--certificate-path",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="Path to the certificate file to be used with --certificate-auth option",
|
||||
)
|
||||
# Subscriptions
|
||||
azure_subscriptions_subparser = azure_parser.add_argument_group("Subscriptions")
|
||||
azure_subscriptions_subparser.add_argument(
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.exceptions import UnsupportedAlgorithm
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||
|
||||
# Reject bundles larger than this before parsing: legitimate PEM and PFX
|
||||
# bundles are under ~10 KiB, and a multi-MB payload would waste memory on
|
||||
# the doubling that base64 decoding plus PKCS#12/PEM parsing perform.
|
||||
_MAX_CERTIFICATE_BUNDLE_BYTES = 50 * 1024
|
||||
|
||||
_CERTIFICATE_BLOCK_RE = re.compile(
|
||||
rb"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
# Covers PKCS#8 (encrypted or not), legacy RSA/EC/DSA and OpenSSH PEM labels.
|
||||
# The actual decoding is delegated to `load_pem_private_key` below.
|
||||
_PRIVATE_KEY_BLOCK_RE = re.compile(
|
||||
rb"-----BEGIN (?:ENCRYPTED |OPENSSH |RSA |EC |DSA )?PRIVATE KEY-----"
|
||||
rb".*?"
|
||||
rb"-----END (?:ENCRYPTED |OPENSSH |RSA |EC |DSA )?PRIVATE KEY-----",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def validate_certificate_bundle(certificate_data: bytes) -> bytes:
|
||||
"""Validate the bundle and return a normalized copy safe for azure-identity.
|
||||
|
||||
Accepts either a PKCS#12/PFX blob (encrypted or unencrypted with a null
|
||||
password) or a concatenated PEM bundle. Raises ``ValueError`` when the
|
||||
payload exceeds the maximum bundle size, is missing a certificate,
|
||||
missing a private key, or contains a pair whose public keys do not
|
||||
match. Raises ``TypeError`` for password-protected PEM private keys,
|
||||
which cryptography surfaces from
|
||||
``load_pem_private_key(..., password=None)``.
|
||||
|
||||
The normalized bytes always place the leaf certificate before the private
|
||||
key so ``azure.identity.CertificateCredential`` — which uses the first
|
||||
``BEGIN CERTIFICATE`` block to compute the credential thumbprint — never
|
||||
picks an intermediate CA over the matching leaf. PKCS#12 blobs are
|
||||
returned as-is.
|
||||
"""
|
||||
if len(certificate_data) > _MAX_CERTIFICATE_BUNDLE_BYTES:
|
||||
raise ValueError(
|
||||
f"the payload exceeds the maximum bundle size of "
|
||||
f"{_MAX_CERTIFICATE_BUNDLE_BYTES} bytes"
|
||||
)
|
||||
try:
|
||||
private_key, certificate, additional_certs = pkcs12.load_key_and_certificates(
|
||||
certificate_data, None, default_backend()
|
||||
)
|
||||
except (ValueError, UnsupportedAlgorithm) as error:
|
||||
# `load_key_and_certificates` also raises `ValueError` when the
|
||||
# PKCS#12 archive is password-protected (message text: "Invalid
|
||||
# password or PKCS12 data"). Fall through to the PEM parser only
|
||||
# when the payload smells like PEM; otherwise raise a specific
|
||||
# error so the caller does not see the misleading "missing
|
||||
# certificate or key" message from `_normalize_pem_bundle`.
|
||||
if b"-----BEGIN" not in certificate_data:
|
||||
raise ValueError(
|
||||
"the payload is not a valid PEM bundle nor an unencrypted "
|
||||
"PKCS#12 archive; password-protected PKCS#12 archives are "
|
||||
"not supported"
|
||||
) from error
|
||||
return _normalize_pem_bundle(certificate_data)
|
||||
|
||||
if private_key is None:
|
||||
raise ValueError("the PKCS#12 archive does not contain a private key")
|
||||
if certificate is None and not additional_certs:
|
||||
raise ValueError("the PKCS#12 archive does not contain a certificate")
|
||||
|
||||
encoding = serialization.Encoding.DER
|
||||
public_format = serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
key_public_bytes = private_key.public_key().public_bytes(encoding, public_format)
|
||||
if (
|
||||
certificate is not None
|
||||
and certificate.public_key().public_bytes(encoding, public_format)
|
||||
== key_public_bytes
|
||||
):
|
||||
# PKCS#12 blobs are consumed directly by azure-identity; no reordering.
|
||||
return certificate_data
|
||||
|
||||
# Some `openssl pkcs12 -export -certfile` workflows write the leaf
|
||||
# certificate into the additional-certs bag instead of the primary
|
||||
# slot. azure-identity reads the primary certificate for the
|
||||
# thumbprint, so accepting the archive as-is would authenticate
|
||||
# against the wrong thumbprint. Detect that case and raise an
|
||||
# actionable error rather than the opaque "does not match" message.
|
||||
for candidate in additional_certs or ():
|
||||
if (
|
||||
candidate.public_key().public_bytes(encoding, public_format)
|
||||
== key_public_bytes
|
||||
):
|
||||
raise ValueError(
|
||||
"the PKCS#12 archive has the certificate matching the "
|
||||
"private key in the additional-certs bag; re-export the "
|
||||
"archive with the leaf certificate as the primary entry"
|
||||
)
|
||||
raise ValueError("the certificate does not match the private key")
|
||||
|
||||
|
||||
def _normalize_pem_bundle(certificate_data: bytes) -> bytes:
|
||||
"""Validate a PEM bundle and return it with the matching leaf first."""
|
||||
certificate_blocks = _CERTIFICATE_BLOCK_RE.findall(certificate_data)
|
||||
private_key_matches = list(_PRIVATE_KEY_BLOCK_RE.finditer(certificate_data))
|
||||
|
||||
if not certificate_blocks or not private_key_matches:
|
||||
raise ValueError("the payload must contain a certificate and its private key")
|
||||
|
||||
# A bundle may carry more than one private key block (e.g. legacy tools
|
||||
# export both an RSA and a PKCS#8 copy). Try each in order; preserve the
|
||||
# first parse error so that a single encrypted key still surfaces the
|
||||
# TypeError callers rely on to route to the typed certificate errors.
|
||||
first_key_error: Optional[Exception] = None
|
||||
for key_match in private_key_matches:
|
||||
try:
|
||||
private_key = serialization.load_pem_private_key(
|
||||
key_match.group(), password=None, backend=default_backend()
|
||||
)
|
||||
except (ValueError, TypeError) as error:
|
||||
if first_key_error is None:
|
||||
first_key_error = error
|
||||
continue
|
||||
key_public_bytes = private_key.public_key().public_bytes(
|
||||
serialization.Encoding.DER,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
for pem_block in certificate_blocks:
|
||||
candidate = x509.load_pem_x509_certificate(pem_block, default_backend())
|
||||
candidate_public_bytes = candidate.public_key().public_bytes(
|
||||
serialization.Encoding.DER,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
if candidate_public_bytes == key_public_bytes:
|
||||
# azure-identity's CertificateCredential uses the first BEGIN
|
||||
# CERTIFICATE block to compute the credential thumbprint. Put
|
||||
# the matching leaf first so authentication uses the correct
|
||||
# certificate regardless of the bundle's original ordering.
|
||||
return pem_block + b"\n" + key_match.group() + b"\n"
|
||||
|
||||
if first_key_error is not None:
|
||||
raise first_key_error
|
||||
raise ValueError("the certificate does not match the private key")
|
||||
@@ -11,6 +11,7 @@ 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 = {}
|
||||
|
||||
|
||||
@@ -404,6 +404,8 @@ class Provider(ABC):
|
||||
sp_env_auth=arguments.sp_env_auth,
|
||||
browser_auth=arguments.browser_auth,
|
||||
managed_identity_auth=arguments.managed_identity_auth,
|
||||
certificate_auth=arguments.certificate_auth,
|
||||
certificate_path=arguments.certificate_path,
|
||||
tenant_id=arguments.tenant_id,
|
||||
region=arguments.azure_region,
|
||||
subscription_ids=arguments.subscription_id,
|
||||
|
||||
@@ -12,6 +12,7 @@ from prowler.providers.aws.lib.arguments.arguments import (
|
||||
validate_role_session_name,
|
||||
)
|
||||
from prowler.providers.azure.lib.arguments.arguments import validate_azure_region
|
||||
from prowler.providers.common.provider import Provider
|
||||
|
||||
prowler_command = "prowler"
|
||||
|
||||
@@ -154,6 +155,53 @@ class Test_Parser:
|
||||
assert not parsed.managed_identity_auth
|
||||
assert not parsed.shodan
|
||||
|
||||
def test_azure_certificate_auth_arguments(self):
|
||||
certificate_path = "/secure/path/prowler-cert.pem"
|
||||
|
||||
parsed = self.parser.parse(
|
||||
[
|
||||
prowler_command,
|
||||
"azure",
|
||||
"--certificate-auth",
|
||||
"--certificate-path",
|
||||
certificate_path,
|
||||
]
|
||||
)
|
||||
|
||||
assert parsed.certificate_auth
|
||||
assert parsed.certificate_path == certificate_path
|
||||
|
||||
def test_azure_certificate_auth_arguments_are_forwarded(self):
|
||||
certificate_path = "/secure/path/prowler-cert.pem"
|
||||
parsed = self.parser.parse(
|
||||
[
|
||||
prowler_command,
|
||||
"azure",
|
||||
"--certificate-auth",
|
||||
"--certificate-path",
|
||||
certificate_path,
|
||||
]
|
||||
)
|
||||
captured = {}
|
||||
|
||||
class AzureProviderStub:
|
||||
def __init__(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
|
||||
with (
|
||||
patch.object(Provider, "_global", None),
|
||||
patch.object(Provider, "get_class", return_value=AzureProviderStub),
|
||||
patch.object(Provider, "is_builtin", return_value=True),
|
||||
patch(
|
||||
"prowler.providers.common.provider.load_and_validate_config_file",
|
||||
return_value={},
|
||||
),
|
||||
):
|
||||
Provider.init_global_provider(parsed)
|
||||
|
||||
assert captured["certificate_auth"] is True
|
||||
assert captured["certificate_path"] == certificate_path
|
||||
|
||||
def test_default_parser_no_arguments_gcp(self):
|
||||
provider = "gcp"
|
||||
command = [prowler_command, provider]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
Azure certificate authentication in the add-provider wizard with six-step onboarding, in-browser certificate generation, and Mintlify-hosted ARM template deployment links
|
||||
@@ -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" && (
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { describe, expect, vi } from "vitest";
|
||||
|
||||
import { it } from "@/__tests__/fixtures";
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
import { AzureCertificateCredentials } from "@/types";
|
||||
|
||||
import { AzureCertificateCredentialsForm } from "./azure-certificate-credentials-form";
|
||||
|
||||
const Harness = () => {
|
||||
const form = useForm<AzureCertificateCredentials>();
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<AzureCertificateCredentialsForm control={form.control} />
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe("AzureCertificateCredentialsForm browser flow", () => {
|
||||
it("groups the six setup steps into three named phases before the form", async () => {
|
||||
// When
|
||||
const view = await render(<Harness />);
|
||||
|
||||
// Then
|
||||
const phaseHeadings = [
|
||||
view.getByRole("heading", { name: "Create the application" }).element(),
|
||||
view.getByRole("heading", { name: "Configure access" }).element(),
|
||||
view.getByRole("heading", { name: "Deploy and connect" }).element(),
|
||||
];
|
||||
const phases = phaseHeadings.map((heading) => heading.closest("section"));
|
||||
|
||||
expect(phases.every((phase) => phase !== null)).toBe(true);
|
||||
expect(phases.map((phase) => phase?.querySelectorAll("li").length)).toEqual(
|
||||
[2, 2, 2],
|
||||
);
|
||||
const openAzureLink = view
|
||||
.getByRole("link", { name: "Open Azure" })
|
||||
.element();
|
||||
const enterpriseApplicationsLink = view
|
||||
.getByRole("link", { name: "Enterprise applications" })
|
||||
.element();
|
||||
const deployToAzureLink = view
|
||||
.getByRole("link", { name: "Deploy to Azure" })
|
||||
.element();
|
||||
const openTemplateLink = view
|
||||
.getByRole("link", { name: "Open template" })
|
||||
.element();
|
||||
|
||||
expect(phases[0]).toContainElement(openAzureLink);
|
||||
expect(phases[0]).toContainElement(
|
||||
view.getByRole("button", { name: "Generate certificate" }).element(),
|
||||
);
|
||||
expect(phases[1]).toContainElement(enterpriseApplicationsLink);
|
||||
expect(phases[2]).toContainElement(deployToAzureLink);
|
||||
expect(deployToAzureLink.closest("li")).toContainElement(openTemplateLink);
|
||||
expect(openAzureLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/CreateApplicationBlade",
|
||||
);
|
||||
expect(enterpriseApplicationsLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://portal.azure.com/#view/Microsoft_AAD_IAM/StartboardApplicationsMenuBlade/~/AppAppsPreview",
|
||||
);
|
||||
const guidance = phaseHeadings[0].parentElement?.parentElement;
|
||||
const tenantIdInput = view
|
||||
.getByRole("textbox", { name: "Tenant ID" })
|
||||
.element();
|
||||
expect(guidance?.nextElementSibling).toContainElement(tenantIdInput);
|
||||
});
|
||||
|
||||
it("renders deployment guidance and keeps certificate generation functional", async () => {
|
||||
// When
|
||||
const view = await render(<Harness />);
|
||||
|
||||
// Then
|
||||
expect(document.querySelectorAll("ol > li")).toHaveLength(6);
|
||||
expect(
|
||||
view.getByRole("link", { name: "Deploy to Azure" }).element(),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fprowler-cloud%2Fprowler%2Fmaster%2Fpermissions%2Ftemplates%2Fazure%2Fbicep%2Fprowler-scan.json",
|
||||
);
|
||||
expect(
|
||||
view.getByRole("link", { name: "Open template" }).element(),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"https://raw.githubusercontent.com/prowler-cloud/prowler/master/permissions/templates/azure/bicep/prowler-scan.json",
|
||||
);
|
||||
|
||||
await view.getByRole("button", { name: "Generate certificate" }).click();
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(
|
||||
view.getByText(/Downloaded prowler-cert\.cer/).element(),
|
||||
).toBeVisible();
|
||||
},
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
expect(view.getByRole("status").element()).toHaveTextContent(
|
||||
/Downloaded prowler-cert\.cer/,
|
||||
);
|
||||
const bundle = view
|
||||
.getByRole("textbox", {
|
||||
name: "Certificate and Private Key Bundle (Base64)",
|
||||
})
|
||||
.element() as HTMLTextAreaElement;
|
||||
expect(bundle.value.length).toBeGreaterThan(100);
|
||||
});
|
||||
});
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
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";
|
||||
|
||||
const Harness = () => {
|
||||
const form = useForm<AzureCertificateCredentials>();
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<AzureCertificateCredentialsForm control={form.control} />
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const expectExternalLinkIcon = (link: HTMLElement) => {
|
||||
const icon = link.querySelector("svg.lucide-external-link");
|
||||
|
||||
expect(icon).toBeInTheDocument();
|
||||
expect(icon).toHaveAttribute("aria-hidden", "true");
|
||||
expect(icon).toHaveClass("size-3.5", "shrink-0");
|
||||
};
|
||||
|
||||
describe("AzureCertificateCredentialsForm", () => {
|
||||
it("renders the Deploy to Azure link with the docs-hosted template", () => {
|
||||
// Given
|
||||
render(<Harness />);
|
||||
|
||||
// When
|
||||
const link = screen.getByRole("link", { name: "Deploy to Azure" });
|
||||
|
||||
// Then
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
"https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fprowler-cloud%2Fprowler%2Fmaster%2Fpermissions%2Ftemplates%2Fazure%2Fbicep%2Fprowler-scan.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("links the manual template fallback to the docs-hosted JSON", () => {
|
||||
// Given / When
|
||||
render(<Harness />);
|
||||
|
||||
// Then
|
||||
const link = screen.getByRole("link", { name: "Open template" });
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
"https://raw.githubusercontent.com/prowler-cloud/prowler/master/permissions/templates/azure/bicep/prowler-scan.json",
|
||||
);
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
expectExternalLinkIcon(link);
|
||||
expect(
|
||||
screen.getByText(/build your own template in the editor/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders every external link with the shared icon and safe attributes", () => {
|
||||
// Given
|
||||
render(<Harness />);
|
||||
|
||||
// When / Then
|
||||
const links = [
|
||||
{
|
||||
element: screen.getByRole("link", { name: "Full guide" }),
|
||||
href: "https://docs.prowler.com/user-guide/providers/azure/authentication#certificate-authentication",
|
||||
},
|
||||
{
|
||||
element: screen.getByRole("link", { name: "Open Azure" }),
|
||||
href: "https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/CreateApplicationBlade",
|
||||
},
|
||||
{
|
||||
element: screen.getByRole("link", {
|
||||
name: "Enterprise applications",
|
||||
}),
|
||||
href: "https://portal.azure.com/#view/Microsoft_AAD_IAM/StartboardApplicationsMenuBlade/~/AppAppsPreview",
|
||||
},
|
||||
{
|
||||
element: screen.getByRole("link", { name: "Deploy to Azure" }),
|
||||
},
|
||||
{
|
||||
element: screen.getByRole("link", { name: "Open template" }),
|
||||
href: "https://raw.githubusercontent.com/prowler-cloud/prowler/master/permissions/templates/azure/bicep/prowler-scan.json",
|
||||
},
|
||||
];
|
||||
|
||||
for (const { element, href } of links) {
|
||||
if (href) expect(element).toHaveAttribute("href", href);
|
||||
expect(element).toHaveAttribute("target", "_blank");
|
||||
expect(element).toHaveAttribute("rel", "noopener noreferrer");
|
||||
expectExternalLinkIcon(element);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the approved six-step certificate onboarding guidance", () => {
|
||||
// Given / When
|
||||
render(<Harness />);
|
||||
|
||||
// Then
|
||||
const steps = screen.getAllByRole("listitem");
|
||||
expect(steps).toHaveLength(6);
|
||||
expect(steps[0]).toHaveTextContent(
|
||||
"Register an Azure application. From its Overview page, copy the Directory (tenant) ID and Application (client) ID.",
|
||||
);
|
||||
expect(steps[1]).toHaveTextContent(
|
||||
"Generate a certificate. The private bundle fills the field below and prowler-cert.cer downloads for step 3.",
|
||||
);
|
||||
expect(steps[2]).toHaveTextContent(
|
||||
"In your App Registration, upload prowler-cert.cer under Certificates & secrets. Then in API permissions, add the Microsoft Graph application permissions AuditLog.Read.All, Directory.Read.All, and Policy.Read.All, and click Grant admin consent.",
|
||||
);
|
||||
expect(steps[3]).toHaveTextContent(
|
||||
"Open Enterprise applications, select the same app, and copy its Object ID (different from the App Registration's Object ID).",
|
||||
);
|
||||
expect(steps[4]).toHaveTextContent(
|
||||
"Deploy the template with the Service Principal Object ID from step 4. It grants Reader and a custom ProwlerRole on the subscription.",
|
||||
);
|
||||
expect(steps[5]).toHaveTextContent(
|
||||
"Return to Prowler and paste the Tenant ID and Client ID from step 1 into the fields below. The certificate field is already filled.",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("textbox", {
|
||||
name: "Certificate and Private Key Bundle (Base64)",
|
||||
}),
|
||||
).toHaveAttribute(
|
||||
"placeholder",
|
||||
"Auto-filled by 'Generate certificate', or paste your own",
|
||||
);
|
||||
});
|
||||
});
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Control, useFormContext } from "react-hook-form";
|
||||
|
||||
import {
|
||||
WizardInputField,
|
||||
WizardTextareaField,
|
||||
} from "@/components/providers/workflow/forms/fields";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
downloadPublicCertificateFile,
|
||||
generateProwlerCertificate,
|
||||
} from "@/lib/azure-cert-generator";
|
||||
import {
|
||||
getAzureDeploymentQuickLink,
|
||||
PROWLER_AZURE_ARM_TEMPLATE_URL,
|
||||
} from "@/lib/external-urls";
|
||||
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
|
||||
import { AzureCertificateCredentials } from "@/types";
|
||||
|
||||
const AZURE_PORTAL_NEW_APP_REGISTRATION_URL =
|
||||
"https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/CreateApplicationBlade";
|
||||
|
||||
const AZURE_PORTAL_ENTERPRISE_APPLICATIONS_URL =
|
||||
"https://portal.azure.com/#view/Microsoft_AAD_IAM/StartboardApplicationsMenuBlade/~/AppAppsPreview";
|
||||
|
||||
const DOCS_CERT_GENERATION_URL =
|
||||
"https://docs.prowler.com/user-guide/providers/azure/authentication#certificate-authentication";
|
||||
|
||||
export const AzureCertificateCredentialsForm = ({
|
||||
control,
|
||||
}: {
|
||||
control: Control<AzureCertificateCredentials>;
|
||||
}) => {
|
||||
const deployToAzureUrl = getAzureDeploymentQuickLink();
|
||||
const [isGeneratingCert, setIsGeneratingCert] = useState(false);
|
||||
const [generatorError, setGeneratorError] = useState<string | null>(null);
|
||||
const [generatedThumbprint, setGeneratedThumbprint] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const { setValue } = useFormContext<AzureCertificateCredentials>();
|
||||
|
||||
const handleGenerateCertificate = async () => {
|
||||
setGeneratorError(null);
|
||||
setGeneratedThumbprint(null);
|
||||
setIsGeneratingCert(true);
|
||||
try {
|
||||
const result = await generateProwlerCertificate();
|
||||
setValue(
|
||||
ProviderCredentialFields.CERTIFICATE_CONTENT,
|
||||
result.privateKeyBundleBase64Pem,
|
||||
{ shouldValidate: true, shouldDirty: true, shouldTouch: true },
|
||||
);
|
||||
downloadPublicCertificateFile(result.publicCertificateBase64Der);
|
||||
setGeneratedThumbprint(result.thumbprintHex);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to generate the certificate in-browser. Fall back to openssl / PowerShell.";
|
||||
setGeneratorError(message);
|
||||
}
|
||||
setIsGeneratingCert(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-md text-text-neutral-primary leading-9 font-bold">
|
||||
Certificate Authentication (Recommended)
|
||||
</div>
|
||||
<div className="text-text-neutral-tertiary text-xs">
|
||||
Requires <strong>Global Administrator</strong> or{" "}
|
||||
<strong>Privileged Role Administrator</strong> in Microsoft Entra ID
|
||||
(for the admin consent in step 3), plus <strong>Owner</strong> on the
|
||||
target subscription (step 5).{" "}
|
||||
<Link
|
||||
href={DOCS_CERT_GENERATION_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-button-tertiary inline-flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
<span>Full guide</span>
|
||||
</Link>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<section className="border-content-neutral-tertiary flex flex-col gap-3 rounded-md border p-4">
|
||||
<h3 className="text-text-neutral-primary text-sm font-semibold">
|
||||
Create the application
|
||||
</h3>
|
||||
<ol className="flex list-decimal flex-col gap-3 pl-5 text-sm">
|
||||
<li>
|
||||
<div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>
|
||||
Register an Azure application. From its Overview page, copy
|
||||
the Directory (tenant) ID and Application (client) ID.
|
||||
</span>
|
||||
<Button variant="link" size="link-sm" asChild>
|
||||
<a
|
||||
href={AZURE_PORTAL_NEW_APP_REGISTRATION_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
<span>Open Azure</span>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>
|
||||
Generate a certificate. The private bundle fills the field
|
||||
below and <code>prowler-cert.cer</code> downloads for step 3.
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleGenerateCertificate}
|
||||
disabled={isGeneratingCert}
|
||||
>
|
||||
{isGeneratingCert ? "Generating..." : "Generate certificate"}
|
||||
</Button>
|
||||
</div>
|
||||
<div role="status" aria-live="polite" aria-atomic="true">
|
||||
{isGeneratingCert && (
|
||||
<p className="text-text-neutral-tertiary mt-2 text-xs">
|
||||
Generating certificate...
|
||||
</p>
|
||||
)}
|
||||
{generatorError && (
|
||||
<p className="text-text-error-primary mt-2 text-xs">
|
||||
{generatorError}
|
||||
</p>
|
||||
)}
|
||||
{generatedThumbprint && (
|
||||
<p className="text-text-success-primary mt-2 text-xs">
|
||||
Downloaded <code>prowler-cert.cer</code>. Thumbprint{" "}
|
||||
<code>{generatedThumbprint}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section className="border-content-neutral-tertiary flex flex-col gap-3 rounded-md border p-4">
|
||||
<h3 className="text-text-neutral-primary text-sm font-semibold">
|
||||
Configure access
|
||||
</h3>
|
||||
<ol
|
||||
start={3}
|
||||
className="flex list-decimal flex-col gap-3 pl-5 text-sm"
|
||||
>
|
||||
<li>
|
||||
In your App Registration, upload <code>prowler-cert.cer</code>{" "}
|
||||
under <em>Certificates & secrets</em>. Then in{" "}
|
||||
<em>API permissions</em>, add the Microsoft Graph{" "}
|
||||
<strong>application</strong> permissions{" "}
|
||||
<code>AuditLog.Read.All</code>, <code>Directory.Read.All</code>,
|
||||
and <code>Policy.Read.All</code>, and click{" "}
|
||||
<em>Grant admin consent</em>.
|
||||
</li>
|
||||
<li>
|
||||
Open{" "}
|
||||
<Link
|
||||
href={AZURE_PORTAL_ENTERPRISE_APPLICATIONS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-button-tertiary inline-flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
<span>Enterprise applications</span>
|
||||
</Link>
|
||||
, select the same app, and copy its Object ID (different from the
|
||||
App Registration's Object ID).
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section className="border-content-neutral-tertiary flex flex-col gap-3 rounded-md border p-4">
|
||||
<h3 className="text-text-neutral-primary text-sm font-semibold">
|
||||
Deploy and connect
|
||||
</h3>
|
||||
<ol
|
||||
start={5}
|
||||
className="flex list-decimal flex-col gap-3 pl-5 text-sm"
|
||||
>
|
||||
<li>
|
||||
<div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>
|
||||
Deploy the template with the Service Principal Object ID from
|
||||
step 4. It grants Reader and a custom ProwlerRole on the
|
||||
subscription.
|
||||
</span>
|
||||
<Button variant="default" size="sm" asChild>
|
||||
<a
|
||||
href={deployToAzureUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
<span>Deploy to Azure</span>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-text-neutral-tertiary mt-2 text-xs">
|
||||
Manual deployment: in Azure Portal, use Build your own template
|
||||
in the editor.{" "}
|
||||
<Button variant="link" size="link-sm" asChild>
|
||||
<a
|
||||
href={PROWLER_AZURE_ARM_TEMPLATE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
<span>Open template</span>
|
||||
</a>
|
||||
</Button>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
Return to Prowler and paste the Tenant ID and Client ID from step
|
||||
1 into the fields below. The certificate field is already filled.
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name="tenant_id"
|
||||
type="text"
|
||||
label="Tenant ID"
|
||||
labelPlacement="inside"
|
||||
placeholder="Directory (tenant) ID"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
/>
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name="client_id"
|
||||
type="text"
|
||||
label="Client ID"
|
||||
labelPlacement="inside"
|
||||
placeholder="Application (client) ID"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
/>
|
||||
<WizardTextareaField
|
||||
control={control}
|
||||
name="certificate_content"
|
||||
label="Certificate and Private Key Bundle (Base64)"
|
||||
labelPlacement="inside"
|
||||
placeholder="Auto-filled by 'Generate certificate', or paste your own"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
minRows={4}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { Control } from "react-hook-form";
|
||||
|
||||
import { WizardInputField } from "@/components/providers/workflow/forms/fields";
|
||||
import { AzureClientSecretCredentials } from "@/types";
|
||||
|
||||
export const AzureServicePrincipalCredentialsForm = ({
|
||||
control,
|
||||
}: {
|
||||
control: Control<AzureClientSecretCredentials>;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-md text-text-neutral-primary leading-9 font-bold">
|
||||
Service Principal with Client Secret
|
||||
</div>
|
||||
<div className="text-text-neutral-tertiary text-sm">
|
||||
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"
|
||||
type="text"
|
||||
label="Client ID"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the Client ID"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
/>
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name="client_secret"
|
||||
type="password"
|
||||
label="Client Secret"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the Client Secret"
|
||||
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";
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
"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";
|
||||
|
||||
// The `via` values shared with the M365 flow (they both authenticate against
|
||||
// an Entra ID App Registration and reuse the same wizard state machine).
|
||||
// Exposed as a const object so consumers can reference the values by name
|
||||
// instead of duplicating string literals — matches the AWS/M365 patterns
|
||||
// (`AWS_CREDENTIAL_OPTIONS`, `M365_CREDENTIAL_OPTIONS`).
|
||||
export const AZURE_CREDENTIALS_TYPES = {
|
||||
CERTIFICATE: "app_certificate",
|
||||
CLIENT_SECRET: "app_client_secret",
|
||||
EMPTY: "",
|
||||
} as const;
|
||||
|
||||
type AzureCredentialsType =
|
||||
(typeof AZURE_CREDENTIALS_TYPES)[keyof typeof AZURE_CREDENTIALS_TYPES];
|
||||
|
||||
// Local form shape for this selector: only the radio value lives here, the
|
||||
// actual credential fields belong to the downstream credentials form. Kept
|
||||
// exported so `SelectViaAzure` and the tests bind the same type instead of
|
||||
// falling back to `any`.
|
||||
export type AzureCredentialsTypeFormValues = {
|
||||
azureCredentialsType: AzureCredentialsType;
|
||||
};
|
||||
|
||||
type RadioGroupAzureViaCredentialsFormProps = {
|
||||
control: Control<AzureCredentialsTypeFormValues>;
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { Form } from "@/components/shadcn/form";
|
||||
|
||||
import {
|
||||
AzureCredentialsTypeFormValues,
|
||||
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<AzureCredentialsTypeFormValues>({
|
||||
defaultValues: {
|
||||
azureCredentialsType:
|
||||
initialVia === "app_certificate" || initialVia === "app_client_secret"
|
||||
? 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]: "",
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
downloadPublicCertificateFile,
|
||||
generateProwlerCertificate,
|
||||
} from "./azure-cert-generator";
|
||||
|
||||
// jsdom exposes `globalThis.crypto` but historically without a working
|
||||
// `subtle` implementation. Node 20+ ships one on `node:crypto.webcrypto`
|
||||
// that satisfies both @peculiar/x509 and our helper. Bind it once so the
|
||||
// module-level `cryptoProvider.set(...)` inside the SUT resolves it.
|
||||
beforeAll(() => {
|
||||
if (!globalThis.crypto || !globalThis.crypto.subtle) {
|
||||
Object.defineProperty(globalThis, "crypto", {
|
||||
value: webcrypto,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("generateProwlerCertificate", () => {
|
||||
it("produces a certificate + private-key bundle that round-trips through PEM parsers", async () => {
|
||||
// Given / When
|
||||
const result = await generateProwlerCertificate({
|
||||
// Shrink the modulus so the test finishes in <2s under CI without
|
||||
// giving up any of the code paths the helper touches at 4096 bits.
|
||||
modulusLength: 2048,
|
||||
commonName: "prowler-test",
|
||||
validityDays: 30,
|
||||
});
|
||||
|
||||
// Then — DER base64 of the public certificate decodes to a byte array
|
||||
// that starts with the ASN.1 SEQUENCE tag (0x30). A stray bug that
|
||||
// returned PEM instead of DER, or double-encoded the base64, would trip
|
||||
// this straight away.
|
||||
const publicDer = Uint8Array.from(
|
||||
atob(result.publicCertificateBase64Der),
|
||||
(c) => c.charCodeAt(0),
|
||||
);
|
||||
expect(publicDer[0]).toBe(0x30);
|
||||
expect(publicDer.byteLength).toBeGreaterThan(500);
|
||||
|
||||
// The private-key bundle decodes to a UTF-8 PEM string containing both
|
||||
// markers, in the order azure-identity expects (cert first, key second).
|
||||
const bundlePem = new TextDecoder().decode(
|
||||
Uint8Array.from(atob(result.privateKeyBundleBase64Pem), (c) =>
|
||||
c.charCodeAt(0),
|
||||
),
|
||||
);
|
||||
const certIdx = bundlePem.indexOf("-----BEGIN CERTIFICATE-----");
|
||||
const keyIdx = bundlePem.indexOf("-----BEGIN PRIVATE KEY-----");
|
||||
expect(certIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(keyIdx).toBeGreaterThan(certIdx);
|
||||
expect(bundlePem).toContain("-----END CERTIFICATE-----");
|
||||
expect(bundlePem).toContain("-----END PRIVATE KEY-----");
|
||||
|
||||
// Validity window respects the injected days and is a real ISO 8601
|
||||
// timestamp.
|
||||
const notBefore = new Date(result.notBefore);
|
||||
const notAfter = new Date(result.notAfter);
|
||||
expect(notBefore.getTime()).toBeLessThan(notAfter.getTime());
|
||||
const days = (notAfter.getTime() - notBefore.getTime()) / 86_400_000;
|
||||
expect(days).toBeCloseTo(30, 0);
|
||||
});
|
||||
|
||||
it("returns the correct SHA-1 thumbprint format expected by Entra ID", async () => {
|
||||
// Given / When
|
||||
const result = await generateProwlerCertificate({ modulusLength: 2048 });
|
||||
|
||||
// Then — 40 hex chars, uppercase, no separators.
|
||||
expect(result.thumbprintHex).toMatch(/^[0-9A-F]{40}$/);
|
||||
});
|
||||
|
||||
it("throws a friendly error when SubtleCrypto is unavailable", async () => {
|
||||
// Given the browser doesn't expose subtle (insecure origin, ancient
|
||||
// browser, some sandboxes).
|
||||
const originalCrypto = globalThis.crypto;
|
||||
Object.defineProperty(globalThis, "crypto", {
|
||||
value: {},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// When / Then
|
||||
await expect(generateProwlerCertificate()).rejects.toThrow(
|
||||
/Web Crypto API is not available/i,
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
Object.defineProperty(globalThis, "crypto", {
|
||||
value: originalCrypto,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("emits a leaf cert with BasicConstraints(cA=false) and KeyUsage(digitalSignature)", async () => {
|
||||
// Guardrail: audit tooling and strict CA validators flag self-signed
|
||||
// leaves that omit these extensions. A future edit that drops them
|
||||
// from the `extensions:` array must not slip past review.
|
||||
const {
|
||||
BasicConstraintsExtension,
|
||||
KeyUsageFlags,
|
||||
KeyUsagesExtension,
|
||||
X509Certificate,
|
||||
} = await import("@peculiar/x509");
|
||||
|
||||
const result = await generateProwlerCertificate({ modulusLength: 2048 });
|
||||
|
||||
const publicDer = Uint8Array.from(
|
||||
atob(result.publicCertificateBase64Der),
|
||||
(c) => c.charCodeAt(0),
|
||||
);
|
||||
const cert = new X509Certificate(publicDer);
|
||||
|
||||
const basicConstraints = cert.getExtension(BasicConstraintsExtension);
|
||||
expect(basicConstraints).toBeDefined();
|
||||
expect(basicConstraints!.ca).toBe(false);
|
||||
|
||||
const keyUsages = cert.getExtension(KeyUsagesExtension);
|
||||
expect(keyUsages).toBeDefined();
|
||||
// `usages` is a bitmask — verify the digitalSignature bit is set.
|
||||
expect(keyUsages!.usages & KeyUsageFlags.digitalSignature).toBe(
|
||||
KeyUsageFlags.digitalSignature,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadPublicCertificateFile", () => {
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
const originalCreateObjectURL = URL.createObjectURL;
|
||||
const originalRevokeObjectURL = URL.revokeObjectURL;
|
||||
|
||||
afterEach(() => {
|
||||
document.createElement = originalCreateElement;
|
||||
URL.createObjectURL = originalCreateObjectURL;
|
||||
URL.revokeObjectURL = originalRevokeObjectURL;
|
||||
});
|
||||
|
||||
it("triggers an anchor click with the right href and filename, then revokes the blob URL", () => {
|
||||
// Given
|
||||
const clickSpy = vi.fn();
|
||||
const objectUrl = "blob:mock/prowler-cert";
|
||||
URL.createObjectURL = vi.fn(() => objectUrl);
|
||||
const revokeSpy = vi.fn();
|
||||
URL.revokeObjectURL = revokeSpy;
|
||||
|
||||
// The anchor spy is a real HTMLAnchorElement so `document.body.appendChild`
|
||||
// and `removeChild` accept it; we only intercept the `click` method.
|
||||
const realAnchor = originalCreateElement("a");
|
||||
realAnchor.click = clickSpy;
|
||||
document.createElement = vi.fn((tag: string) => {
|
||||
if (tag === "a") return realAnchor;
|
||||
return originalCreateElement(tag);
|
||||
}) as typeof document.createElement;
|
||||
|
||||
// When — pass a valid base64 payload (the helper now decodes it back to
|
||||
// raw DER bytes so the download is a `.cer` file the Portal accepts).
|
||||
// `MII=` is short but valid base64 that decodes to bytes [0x30, 0x82],
|
||||
// matching the ASN.1 SEQUENCE tag prefix that real X.509 DER starts
|
||||
// with — good enough to prove the decode path without pulling in a
|
||||
// real cert.
|
||||
downloadPublicCertificateFile("MII=", "prowler-cert.cer");
|
||||
|
||||
// Then
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1);
|
||||
expect(realAnchor.href).toContain(objectUrl);
|
||||
expect(realAnchor.download).toBe("prowler-cert.cer");
|
||||
// Revoked to avoid leaking the blob URL for the tab's lifetime.
|
||||
expect(revokeSpy).toHaveBeenCalledWith(objectUrl);
|
||||
});
|
||||
|
||||
it("defaults the filename when the caller omits it", () => {
|
||||
// Given
|
||||
URL.createObjectURL = vi.fn(() => "blob:mock");
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
const realAnchor = originalCreateElement("a");
|
||||
realAnchor.click = vi.fn();
|
||||
document.createElement = vi.fn((tag: string) => {
|
||||
if (tag === "a") return realAnchor;
|
||||
return originalCreateElement(tag);
|
||||
}) as typeof document.createElement;
|
||||
|
||||
// When — pass a real base64 payload; the helper now decodes it back to
|
||||
// raw DER bytes so the download is a valid `.cer` file the Portal accepts
|
||||
// without any manual decoding step. `AA==` decodes to a single 0x00 byte,
|
||||
// which is enough to exercise the base64→bytes path without pulling a
|
||||
// real certificate into the test.
|
||||
downloadPublicCertificateFile("AA==");
|
||||
|
||||
// Then
|
||||
expect(realAnchor.download).toBe("prowler-cert.cer");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
// In-browser X.509 self-signed certificate generator for the Azure
|
||||
// certificate-authentication onboarding flow.
|
||||
//
|
||||
// The keypair is generated with the browser's native Web Crypto API
|
||||
// (`crypto.subtle.generateKey`) and never leaves the tab. `@peculiar/x509`
|
||||
// wraps the public key in a self-signed X.509 certificate whose SHA-1
|
||||
// thumbprint we can hand back to the user; the private key is exported as
|
||||
// base64-encoded PEM ready to paste into the Prowler wizard's Certificate
|
||||
// Private Key field.
|
||||
//
|
||||
// See the certificate authentication guide in
|
||||
// docs/user-guide/providers/azure/authentication.mdx for the equivalent
|
||||
// openssl/PowerShell recipes, and PROWLER-2378 for the Deploy-to-Azure
|
||||
// quick-start feature this UX affordance belongs to.
|
||||
|
||||
// `@peculiar/x509` transitively depends on `tsyringe`, which pulls in a
|
||||
// decorators/DI runtime that requires the `reflect-metadata` polyfill. The
|
||||
// import has to happen before anything from `@peculiar/x509` is imported so
|
||||
// the metadata store is registered on `Reflect` first.
|
||||
import "reflect-metadata";
|
||||
|
||||
import {
|
||||
BasicConstraintsExtension,
|
||||
cryptoProvider,
|
||||
KeyUsageFlags,
|
||||
KeyUsagesExtension,
|
||||
X509CertificateGenerator,
|
||||
} from "@peculiar/x509";
|
||||
|
||||
export interface GeneratedProwlerCertificate {
|
||||
/**
|
||||
* Base64 of the DER-encoded X.509 public certificate. The download helper
|
||||
* converts this value into the `.cer` file uploaded to the App Registration.
|
||||
*/
|
||||
publicCertificateBase64Der: string;
|
||||
/**
|
||||
* Base64 of the PEM bundle containing both the X.509 certificate and the
|
||||
* PKCS#8 private key. `azure.identity.CertificateCredential` accepts this
|
||||
* exact shape; the Prowler wizard pastes it into the Certificate Private
|
||||
* Key (Base64) textarea.
|
||||
*/
|
||||
privateKeyBundleBase64Pem: string;
|
||||
/** Human-readable SHA-1 thumbprint, uppercase hex, matching what Entra ID displays. */
|
||||
thumbprintHex: string;
|
||||
/** ISO 8601 not-valid-before timestamp of the generated cert. */
|
||||
notBefore: string;
|
||||
/** ISO 8601 not-valid-after timestamp of the generated cert. */
|
||||
notAfter: string;
|
||||
}
|
||||
|
||||
export interface GenerateProwlerCertificateOptions {
|
||||
/**
|
||||
* Common name to embed in the certificate subject. Defaults to "Prowler"
|
||||
* to match the openssl/PowerShell examples in the docs.
|
||||
*/
|
||||
commonName?: string;
|
||||
/** Certificate lifetime in days. Default 365. */
|
||||
validityDays?: number;
|
||||
/** RSA modulus length. Default 4096 (matches the openssl example). */
|
||||
modulusLength?: 2048 | 3072 | 4096;
|
||||
/**
|
||||
* Injected clock for deterministic tests. Defaults to `Date.now()`.
|
||||
*/
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
const DEFAULTS: Required<
|
||||
Pick<
|
||||
GenerateProwlerCertificateOptions,
|
||||
"commonName" | "validityDays" | "modulusLength"
|
||||
>
|
||||
> = {
|
||||
commonName: "Prowler",
|
||||
validityDays: 365,
|
||||
modulusLength: 4096,
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a fresh self-signed X.509 certificate + RSA-4096 keypair in the
|
||||
* browser. Nothing crosses the network — the private key exists only in the
|
||||
* returned object and inside the caller's memory.
|
||||
*
|
||||
* Throws when the browser does not expose SubtleCrypto (e.g. insecure origin
|
||||
* or old browser). Callers should surface a friendly fallback ("use openssl
|
||||
* instead") when that happens.
|
||||
*/
|
||||
export async function generateProwlerCertificate(
|
||||
options: GenerateProwlerCertificateOptions = {},
|
||||
): Promise<GeneratedProwlerCertificate> {
|
||||
const commonName = options.commonName ?? DEFAULTS.commonName;
|
||||
const validityDays = options.validityDays ?? DEFAULTS.validityDays;
|
||||
const modulusLength = options.modulusLength ?? DEFAULTS.modulusLength;
|
||||
const now = options.now ?? (() => new Date());
|
||||
|
||||
const subtle = globalThis.crypto?.subtle;
|
||||
if (!subtle) {
|
||||
throw new Error(
|
||||
"Web Crypto API is not available in this browser. Use the openssl or PowerShell instructions from the certificate generation guide instead.",
|
||||
);
|
||||
}
|
||||
// Bind @peculiar/x509 to the browser's native SubtleCrypto here rather
|
||||
// than at module load time: doing it inside the function keeps the check
|
||||
// above authoritative (the crypto object could have been swapped by a
|
||||
// test harness between import and call) and avoids side-effects when
|
||||
// consumers only import the types.
|
||||
cryptoProvider.set(globalThis.crypto);
|
||||
|
||||
const keyPair = (await subtle.generateKey(
|
||||
{
|
||||
name: "RSASSA-PKCS1-v1_5",
|
||||
modulusLength,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: "SHA-256",
|
||||
},
|
||||
true,
|
||||
["sign", "verify"],
|
||||
)) as CryptoKeyPair;
|
||||
|
||||
const notBefore = now();
|
||||
const notAfter = new Date(
|
||||
notBefore.getTime() + validityDays * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
const cert = await X509CertificateGenerator.createSelfSigned({
|
||||
// Random serial: 16 hex chars is plenty for identification purposes and
|
||||
// matches how `openssl x509 -req` chooses serials by default.
|
||||
serialNumber: randomHex(16),
|
||||
name: `CN=${commonName}`,
|
||||
notBefore,
|
||||
notAfter,
|
||||
signingAlgorithm: {
|
||||
name: "RSASSA-PKCS1-v1_5",
|
||||
hash: "SHA-256",
|
||||
},
|
||||
keys: keyPair,
|
||||
// Match `openssl x509 -req` defaults: mark the leaf as end-entity
|
||||
// (`cA=false`) and declare `digitalSignature` so audit tooling and
|
||||
// strict CA validators don't flag the certificate as unusual.
|
||||
extensions: [
|
||||
new BasicConstraintsExtension(false, undefined, true),
|
||||
new KeyUsagesExtension(KeyUsageFlags.digitalSignature, true),
|
||||
],
|
||||
});
|
||||
|
||||
const certPem = cert.toString("pem");
|
||||
const certDer = new Uint8Array(cert.rawData);
|
||||
const publicCertificateBase64Der = toBase64(certDer);
|
||||
|
||||
const privateKeyPkcs8 = new Uint8Array(
|
||||
await subtle.exportKey("pkcs8", keyPair.privateKey),
|
||||
);
|
||||
const privateKeyPem = pkcs8ToPem(privateKeyPkcs8);
|
||||
|
||||
// Bundle order matches what azure-identity expects: certificate first,
|
||||
// private key second. The full bundle is then base64-encoded so it can
|
||||
// live inside a single form field / JSON payload.
|
||||
const bundlePem = `${certPem.trim()}\n${privateKeyPem.trim()}\n`;
|
||||
const privateKeyBundleBase64Pem = toBase64(
|
||||
new TextEncoder().encode(bundlePem),
|
||||
);
|
||||
|
||||
const thumbprintBytes = new Uint8Array(await subtle.digest("SHA-1", certDer));
|
||||
const thumbprintHex = bytesToHexUpper(thumbprintBytes);
|
||||
|
||||
return {
|
||||
publicCertificateBase64Der,
|
||||
privateKeyBundleBase64Pem,
|
||||
thumbprintHex,
|
||||
notBefore: notBefore.toISOString(),
|
||||
notAfter: notAfter.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a browser download of the public certificate as a `.cer` file (raw
|
||||
* DER bytes) so the user can upload it directly on the App Registration's
|
||||
* *Certificates* blade in the Azure Portal — no terminal step or manual
|
||||
* base64 decoding required.
|
||||
*
|
||||
* The Portal upload accepts `.cer`, `.pem` and `.crt`; we emit `.cer` because
|
||||
* it matches the raw DER bytes we already have and is the extension the
|
||||
* Portal upload dialog shows first.
|
||||
*
|
||||
* Split from `generateProwlerCertificate` so the pure generator can be unit
|
||||
* tested without stubbing `document.createElement`.
|
||||
*/
|
||||
export function downloadPublicCertificateFile(
|
||||
publicCertificateBase64Der: string,
|
||||
filename = "prowler-cert.cer",
|
||||
): void {
|
||||
const derBytes = base64ToBytes(publicCertificateBase64Der);
|
||||
const blob = new Blob([derBytes as BlobPart], {
|
||||
type: "application/x-x509-ca-cert",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
// Free the blob URL immediately; the browser has already started the
|
||||
// download at this point, so revoking is safe.
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// -- helpers ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Uint8Array → base64. Kept private to this module because the codebase does
|
||||
* not yet have a shared helper and this one only needs to handle small
|
||||
* payloads (a cert + key are ~5 KB total). If a shared helper appears later,
|
||||
* swap this out.
|
||||
*/
|
||||
function toBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
const byte = bytes[i];
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of `toBase64` — decode a base64 string back to raw bytes. Only used
|
||||
* by `downloadPublicCertificateFile` to reconstitute the DER blob for the
|
||||
* `.cer` download; the generator itself works in raw bytes end-to-end.
|
||||
*/
|
||||
function base64ToBytes(base64: string): Uint8Array {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function randomHex(chars: number): string {
|
||||
const bytes = new Uint8Array(Math.ceil(chars / 2));
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
return bytesToHexUpper(bytes).slice(0, chars);
|
||||
}
|
||||
|
||||
function bytesToHexUpper(bytes: Uint8Array): string {
|
||||
let hex = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
const byte = bytes[i];
|
||||
hex += byte.toString(16).padStart(2, "0").toUpperCase();
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
// `@peculiar/x509` exports certs to PEM directly but not PKCS#8 keys — we
|
||||
// build the PEM ourselves so the private key format is deterministic and
|
||||
// matches what `openssl pkey -inform DER` would emit.
|
||||
function pkcs8ToPem(pkcs8: Uint8Array): string {
|
||||
const base64 = toBase64(pkcs8);
|
||||
// 64-char lines is the classic PEM formatting; azure-identity and every
|
||||
// other PEM parser accept both wrapped and unwrapped, but wrapping keeps
|
||||
// the file human-readable.
|
||||
const wrapped = base64.match(/.{1,64}/g)?.join("\n") ?? base64;
|
||||
return `-----BEGIN PRIVATE KEY-----\n${wrapped}\n-----END PRIVATE KEY-----`;
|
||||
}
|
||||
|
||||
// Named export needed by @/lib/shared/base64 fallback below.
|
||||
export const __internal = { pkcs8ToPem, bytesToHexUpper, randomHex };
|
||||
@@ -16,6 +16,8 @@ export const PROVIDER_CREDENTIALS_ERROR_MAPPING: Record<string, string> = {
|
||||
[ErrorPointers.AWS_SESSION_TOKEN]: ProviderCredentialFields.AWS_SESSION_TOKEN,
|
||||
[ErrorPointers.CLIENT_ID]: ProviderCredentialFields.CLIENT_ID,
|
||||
[ErrorPointers.CLIENT_SECRET]: ProviderCredentialFields.CLIENT_SECRET,
|
||||
[ErrorPointers.CERTIFICATE_CONTENT]:
|
||||
ProviderCredentialFields.CERTIFICATE_CONTENT,
|
||||
[ErrorPointers.USER]: ProviderCredentialFields.USER,
|
||||
[ErrorPointers.PASSWORD]: ProviderCredentialFields.PASSWORD,
|
||||
[ErrorPointers.TENANT_ID]: ProviderCredentialFields.TENANT_ID,
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
buildGitHubPersonalAccessTokenOrgUrl,
|
||||
getAWSCredentialsTemplateLinks,
|
||||
getAWSOrgDeploymentQuickLink,
|
||||
getAzureDeploymentQuickLink,
|
||||
PRECONFIGURED_CREDENTIAL_URLS,
|
||||
getProviderHelpText,
|
||||
PROWLER_AZURE_ARM_TEMPLATE_URL,
|
||||
PROWLER_CF_TEMPLATE_URL,
|
||||
} from "./external-urls";
|
||||
|
||||
@@ -285,6 +287,24 @@ describe("buildGitHubPersonalAccessTokenOrgUrl", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAzureDeploymentQuickLink", () => {
|
||||
it("uses the public Mintlify documentation asset", () => {
|
||||
// Given / When
|
||||
const url = getAzureDeploymentQuickLink();
|
||||
|
||||
// Then
|
||||
expect(PROWLER_AZURE_ARM_TEMPLATE_URL).toBe(
|
||||
"https://raw.githubusercontent.com/prowler-cloud/prowler/master/permissions/templates/azure/bicep/prowler-scan.json",
|
||||
);
|
||||
expect(url).toBe(
|
||||
"https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fprowler-cloud%2Fprowler%2Fmaster%2Fpermissions%2Ftemplates%2Fazure%2Fbicep%2Fprowler-scan.json",
|
||||
);
|
||||
expect(url).not.toContain("localhost");
|
||||
expect(url).not.toContain("prowler-cloud-public.s3");
|
||||
expect(decodeURIComponent(url)).not.toContain("/uri//templates/azure/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProviderHelpText", () => {
|
||||
const AWS_SHORTLINK = "https://goto.prowler.com/provider-aws";
|
||||
const AWS_CREDENTIALS_STEP_DOCS =
|
||||
|
||||
@@ -40,6 +40,13 @@ 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";
|
||||
|
||||
// Stopgap: point the Azure Portal at the raw template on GitHub until the
|
||||
// docs deploy publishes the file under `docs.prowler.com/assets/...`.
|
||||
// The Portal fetches this URL over HTTPS, so `raw.githubusercontent.com`
|
||||
// works exactly the same for the Deploy-to-Azure flow.
|
||||
export const PROWLER_AZURE_ARM_TEMPLATE_URL =
|
||||
"https://raw.githubusercontent.com/prowler-cloud/prowler/master/permissions/templates/azure/bicep/prowler-scan.json";
|
||||
|
||||
// Prowler Cloud billing/subscription management page.
|
||||
export const BILLING_URL = "https://cloud.prowler.com/billing";
|
||||
|
||||
@@ -215,6 +222,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 +408,8 @@ export const getAWSOrgDeploymentQuickLink = ({
|
||||
|
||||
return buildCloudFormationQuickCreateLink(parameters);
|
||||
};
|
||||
|
||||
export const getAzureDeploymentQuickLink = (): string =>
|
||||
`https://portal.azure.com/#create/Microsoft.Template/uri/${encodeURIComponent(
|
||||
PROWLER_AZURE_ARM_TEMPLATE_URL,
|
||||
)}`;
|
||||
|
||||
@@ -78,6 +78,14 @@ export const buildAzureSecret = (formData: FormData) => {
|
||||
formData,
|
||||
ProviderCredentialFields.TENANT_ID,
|
||||
),
|
||||
// Certificate auth (PROWLER-2378). The backend `AzureProviderSecret`
|
||||
// serializer accepts either `client_secret` or `certificate_content`
|
||||
// and rejects both-empty, so we always forward the field — the empty
|
||||
// one gets stripped by `filterEmptyValues` below.
|
||||
[ProviderCredentialFields.CERTIFICATE_CONTENT]: getFormValue(
|
||||
formData,
|
||||
ProviderCredentialFields.CERTIFICATE_CONTENT,
|
||||
),
|
||||
};
|
||||
return filterEmptyValues(secret);
|
||||
};
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"@langchain/openai": "1.4.5",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"@next/third-parties": "16.2.9",
|
||||
"@peculiar/x509": "2.0.0",
|
||||
"@radix-ui/react-alert-dialog": "1.1.14",
|
||||
"@radix-ui/react-avatar": "1.1.11",
|
||||
"@radix-ui/react-checkbox": "1.3.3",
|
||||
@@ -107,6 +108,7 @@
|
||||
"react-hook-form": "7.62.0",
|
||||
"react-markdown": "10.1.0",
|
||||
"recharts": "2.15.4",
|
||||
"reflect-metadata": "0.2.2",
|
||||
"require-in-the-middle": "8.0.1",
|
||||
"server-only": "0.0.1",
|
||||
"sharp": "0.35.3",
|
||||
|
||||
Generated
+177
@@ -85,6 +85,9 @@ importers:
|
||||
'@next/third-parties':
|
||||
specifier: 16.2.9
|
||||
version: 16.2.9(next@16.2.11(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)
|
||||
'@peculiar/x509':
|
||||
specifier: 2.0.0
|
||||
version: 2.0.0
|
||||
'@radix-ui/react-alert-dialog':
|
||||
specifier: 1.1.14
|
||||
version: 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
@@ -259,6 +262,9 @@ importers:
|
||||
recharts:
|
||||
specifier: 2.15.4
|
||||
version: 2.15.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
reflect-metadata:
|
||||
specifier: 0.2.2
|
||||
version: 0.2.2
|
||||
require-in-the-middle:
|
||||
specifier: 8.0.1
|
||||
version: 8.0.1
|
||||
@@ -1787,6 +1793,43 @@ packages:
|
||||
'@panva/hkdf@1.2.1':
|
||||
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
|
||||
|
||||
'@peculiar/asn1-cms@2.8.0':
|
||||
resolution: {integrity: sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==}
|
||||
|
||||
'@peculiar/asn1-csr@2.8.0':
|
||||
resolution: {integrity: sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==}
|
||||
|
||||
'@peculiar/asn1-ecc@2.8.0':
|
||||
resolution: {integrity: sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==}
|
||||
|
||||
'@peculiar/asn1-pfx@2.8.0':
|
||||
resolution: {integrity: sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==}
|
||||
|
||||
'@peculiar/asn1-pkcs8@2.8.0':
|
||||
resolution: {integrity: sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==}
|
||||
|
||||
'@peculiar/asn1-pkcs9@2.8.0':
|
||||
resolution: {integrity: sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==}
|
||||
|
||||
'@peculiar/asn1-rsa@2.8.0':
|
||||
resolution: {integrity: sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==}
|
||||
|
||||
'@peculiar/asn1-schema@2.8.0':
|
||||
resolution: {integrity: sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==}
|
||||
|
||||
'@peculiar/asn1-x509-attr@2.8.0':
|
||||
resolution: {integrity: sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==}
|
||||
|
||||
'@peculiar/asn1-x509@2.8.0':
|
||||
resolution: {integrity: sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==}
|
||||
|
||||
'@peculiar/utils@2.0.3':
|
||||
resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==}
|
||||
|
||||
'@peculiar/x509@2.0.0':
|
||||
resolution: {integrity: sha512-r10lkuy6BNfRmyYdRAfgu6dq0HOmyIV2OLhXWE3gDEPBdX1b8miztJVyX/UxWhLwemNyDP3CLZHpDxDwSY0xaA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@playwright/test@1.56.1':
|
||||
resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3835,6 +3878,10 @@ packages:
|
||||
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
asn1js@3.0.10:
|
||||
resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -6157,6 +6204,13 @@ packages:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
pvtsutils@1.3.6:
|
||||
resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
|
||||
|
||||
pvutils@1.2.0:
|
||||
resolution: {integrity: sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
qs@6.15.2:
|
||||
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
|
||||
engines: {node: '>=0.6'}
|
||||
@@ -6272,6 +6326,9 @@ packages:
|
||||
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
reflect-metadata@0.2.2:
|
||||
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
||||
|
||||
reflect.getprototypeof@1.0.10:
|
||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -6805,9 +6862,16 @@ packages:
|
||||
resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==}
|
||||
engines: {node: '>=6.10'}
|
||||
|
||||
tslib@1.14.1:
|
||||
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
tsyringe@4.10.0:
|
||||
resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -8840,6 +8904,99 @@ snapshots:
|
||||
|
||||
'@panva/hkdf@1.2.1': {}
|
||||
|
||||
'@peculiar/asn1-cms@2.8.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.8.0
|
||||
'@peculiar/asn1-x509': 2.8.0
|
||||
'@peculiar/asn1-x509-attr': 2.8.0
|
||||
asn1js: 3.0.10
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-csr@2.8.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.8.0
|
||||
'@peculiar/asn1-x509': 2.8.0
|
||||
asn1js: 3.0.10
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-ecc@2.8.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.8.0
|
||||
'@peculiar/asn1-x509': 2.8.0
|
||||
asn1js: 3.0.10
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-pfx@2.8.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-cms': 2.8.0
|
||||
'@peculiar/asn1-pkcs8': 2.8.0
|
||||
'@peculiar/asn1-rsa': 2.8.0
|
||||
'@peculiar/asn1-schema': 2.8.0
|
||||
asn1js: 3.0.10
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-pkcs8@2.8.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.8.0
|
||||
'@peculiar/asn1-x509': 2.8.0
|
||||
asn1js: 3.0.10
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-pkcs9@2.8.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-cms': 2.8.0
|
||||
'@peculiar/asn1-pfx': 2.8.0
|
||||
'@peculiar/asn1-pkcs8': 2.8.0
|
||||
'@peculiar/asn1-schema': 2.8.0
|
||||
'@peculiar/asn1-x509': 2.8.0
|
||||
'@peculiar/asn1-x509-attr': 2.8.0
|
||||
asn1js: 3.0.10
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-rsa@2.8.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.8.0
|
||||
'@peculiar/asn1-x509': 2.8.0
|
||||
asn1js: 3.0.10
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-schema@2.8.0':
|
||||
dependencies:
|
||||
'@peculiar/utils': 2.0.3
|
||||
asn1js: 3.0.10
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-x509-attr@2.8.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.8.0
|
||||
'@peculiar/asn1-x509': 2.8.0
|
||||
asn1js: 3.0.10
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-x509@2.8.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.8.0
|
||||
'@peculiar/utils': 2.0.3
|
||||
asn1js: 3.0.10
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/utils@2.0.3':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/x509@2.0.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-cms': 2.8.0
|
||||
'@peculiar/asn1-csr': 2.8.0
|
||||
'@peculiar/asn1-ecc': 2.8.0
|
||||
'@peculiar/asn1-pkcs9': 2.8.0
|
||||
'@peculiar/asn1-rsa': 2.8.0
|
||||
'@peculiar/asn1-schema': 2.8.0
|
||||
'@peculiar/asn1-x509': 2.8.0
|
||||
pvtsutils: 1.3.6
|
||||
tslib: 2.8.1
|
||||
tsyringe: 4.10.0
|
||||
|
||||
'@playwright/test@1.56.1':
|
||||
dependencies:
|
||||
playwright: 1.56.1
|
||||
@@ -11035,6 +11192,12 @@ snapshots:
|
||||
get-intrinsic: 1.3.0
|
||||
is-array-buffer: 3.0.5
|
||||
|
||||
asn1js@3.0.10:
|
||||
dependencies:
|
||||
pvtsutils: 1.3.6
|
||||
pvutils: 1.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
ast-types-flow@0.0.8: {}
|
||||
@@ -13761,6 +13924,12 @@ snapshots:
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
pvtsutils@1.3.6:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
pvutils@1.2.0: {}
|
||||
|
||||
qs@6.15.2:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
@@ -13888,6 +14057,8 @@ snapshots:
|
||||
indent-string: 4.0.0
|
||||
strip-indent: 3.0.0
|
||||
|
||||
reflect-metadata@0.2.2: {}
|
||||
|
||||
reflect.getprototypeof@1.0.10:
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
@@ -14545,8 +14716,14 @@ snapshots:
|
||||
|
||||
ts-dedent@2.2.0: {}
|
||||
|
||||
tslib@1.14.1: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsyringe@4.10.0:
|
||||
dependencies:
|
||||
tslib: 1.14.1
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
|
||||
@@ -123,9 +123,12 @@ export interface AWSProviderCredential {
|
||||
secretAccessKey?: string;
|
||||
}
|
||||
|
||||
// AZURE credential options
|
||||
// AZURE credential options — mirror the M365 selector added for the
|
||||
// Deploy-to-Azure quick-start (PROWLER-2378). "credentials" keeps the
|
||||
// legacy name for the client-secret path so existing specs keep working.
|
||||
export const AZURE_CREDENTIAL_OPTIONS = {
|
||||
AZURE_CREDENTIALS: "credentials",
|
||||
AZURE_CERTIFICATE_CREDENTIALS: "certificate",
|
||||
} as const;
|
||||
|
||||
// AZURE credential type
|
||||
@@ -136,7 +139,8 @@ type AZURECredentialType =
|
||||
export interface AZUREProviderCredential {
|
||||
type: AZURECredentialType;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
clientSecret?: string;
|
||||
certificateContent?: string;
|
||||
tenantId: string;
|
||||
}
|
||||
|
||||
@@ -316,6 +320,10 @@ export class ProvidersPage extends BasePage {
|
||||
readonly roleCredentialsRadio: Locator;
|
||||
readonly staticCredentialsRadio: Locator;
|
||||
|
||||
// Azure credentials type selection
|
||||
readonly azureServicePrincipalRadio: Locator;
|
||||
readonly azureCertificateCredentialsRadio: Locator;
|
||||
|
||||
// M365 credentials type selection
|
||||
readonly m365StaticCredentialsRadio: Locator;
|
||||
readonly m365CertificateCredentialsRadio: Locator;
|
||||
@@ -336,6 +344,7 @@ export class ProvidersPage extends BasePage {
|
||||
readonly azureSubscriptionIdInput: Locator;
|
||||
readonly azureClientIdInput: Locator;
|
||||
readonly azureClientSecretInput: Locator;
|
||||
readonly azureCertificateContentInput: Locator;
|
||||
readonly azureTenantIdInput: Locator;
|
||||
|
||||
// M365 provider form elements
|
||||
@@ -458,6 +467,9 @@ export class ProvidersPage extends BasePage {
|
||||
this.azureClientSecretInput = page.getByRole("textbox", {
|
||||
name: "Client Secret",
|
||||
});
|
||||
this.azureCertificateContentInput = page.getByRole("textbox", {
|
||||
name: "Certificate and Private Key Bundle (Base64)",
|
||||
});
|
||||
this.azureTenantIdInput = page.getByRole("textbox", { name: "Tenant ID" });
|
||||
|
||||
// M365 provider form inputs
|
||||
@@ -595,6 +607,16 @@ export class ProvidersPage extends BasePage {
|
||||
name: /Connect via Credentials/i,
|
||||
});
|
||||
|
||||
// Radios for selecting Azure credentials method (PROWLER-2378 added the
|
||||
// certificate flow; the client-secret radio stayed but is now inside a
|
||||
// selector step instead of being the default form).
|
||||
this.azureServicePrincipalRadio = page.getByRole("radio", {
|
||||
name: /Service Principal with Client Secret/i,
|
||||
});
|
||||
this.azureCertificateCredentialsRadio = page.getByRole("radio", {
|
||||
name: /Certificate Authentication/i,
|
||||
});
|
||||
|
||||
// Radios for selecting M365 credentials method
|
||||
this.m365StaticCredentialsRadio = page.getByRole("radio", {
|
||||
name: /App Client Secret Credentials/i,
|
||||
@@ -1076,6 +1098,24 @@ export class ProvidersPage extends BasePage {
|
||||
}
|
||||
}
|
||||
|
||||
async selectAzureCredentialsType(type: AZURECredentialType): Promise<void> {
|
||||
// PROWLER-2378 introduced a credential-type selector for Azure, mirroring
|
||||
// AWS/GCP/M365. The credentials form is now behind a radio choice, so
|
||||
// any spec that reaches Azure credentials must pick the type first.
|
||||
await this.verifyWizardModalOpen();
|
||||
await expect(this.azureServicePrincipalRadio).toBeVisible();
|
||||
|
||||
if (type === AZURE_CREDENTIAL_OPTIONS.AZURE_CREDENTIALS) {
|
||||
await this.azureServicePrincipalRadio.click({ force: true });
|
||||
} else if (
|
||||
type === AZURE_CREDENTIAL_OPTIONS.AZURE_CERTIFICATE_CREDENTIALS
|
||||
) {
|
||||
await this.azureCertificateCredentialsRadio.click({ force: true });
|
||||
} else {
|
||||
throw new Error(`Invalid Azure credential type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
async selectM365CredentialsType(type: M365CredentialType): Promise<void> {
|
||||
await this.verifyWizardModalOpen();
|
||||
await expect(this.m365StaticCredentialsRadio).toBeVisible();
|
||||
@@ -1201,6 +1241,18 @@ export class ProvidersPage extends BasePage {
|
||||
}
|
||||
}
|
||||
|
||||
async fillAzureCertificateCredentials(
|
||||
credentials: AZUREProviderCredential,
|
||||
): Promise<void> {
|
||||
await this.azureClientIdInput.fill(credentials.clientId);
|
||||
await this.azureTenantIdInput.fill(credentials.tenantId);
|
||||
if (credentials.certificateContent) {
|
||||
await this.azureCertificateContentInput.fill(
|
||||
credentials.certificateContent,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fillM365Credentials(
|
||||
credentials: M365ProviderCredential,
|
||||
): Promise<void> {
|
||||
@@ -1578,6 +1630,35 @@ export class ProvidersPage extends BasePage {
|
||||
await expect(this.m365TenantIdInput).toBeVisible();
|
||||
}
|
||||
|
||||
async verifyAzureCertificateCredentialsPageLoaded(): Promise<void> {
|
||||
await this.verifyPageHasProwlerTitle();
|
||||
await expect(this.wizardModal.locator("ol > li")).toHaveCount(6);
|
||||
await expect(
|
||||
this.page.getByRole("link", {
|
||||
name: "Deploy to Azure",
|
||||
exact: true,
|
||||
}),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fprowler-cloud%2Fprowler%2Fmaster%2Fpermissions%2Ftemplates%2Fazure%2Fbicep%2Fprowler-scan.json",
|
||||
);
|
||||
await expect(
|
||||
this.page.getByRole("link", {
|
||||
name: "Open template",
|
||||
exact: true,
|
||||
}),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"https://raw.githubusercontent.com/prowler-cloud/prowler/master/permissions/templates/azure/bicep/prowler-scan.json",
|
||||
);
|
||||
await expect(
|
||||
this.page.getByRole("button", { name: "Generate certificate" }),
|
||||
).toBeVisible();
|
||||
await expect(this.azureClientIdInput).toBeVisible();
|
||||
await expect(this.azureTenantIdInput).toBeVisible();
|
||||
await expect(this.azureCertificateContentInput).toBeVisible();
|
||||
}
|
||||
|
||||
async verifyM365CertificateCredentialsPageLoaded(): Promise<void> {
|
||||
// Verify the M365 certificate credentials page is loaded
|
||||
|
||||
|
||||
@@ -174,6 +174,51 @@
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `PROVIDER-E2E-020` - Add Azure Provider with Certificate Credentials
|
||||
|
||||
**Priority:** `critical`
|
||||
|
||||
**Tags:**
|
||||
|
||||
- type → @e2e, @serial
|
||||
- feature → @providers
|
||||
- provider → @azure
|
||||
|
||||
**Description/Objective:** Validates the complete flow of adding an Azure provider with an existing App Registration certificate bundle.
|
||||
|
||||
**Preconditions:**
|
||||
|
||||
- Admin user authentication required
|
||||
- Environment variables configured: E2E_AZURE_SUBSCRIPTION_ID, E2E_AZURE_CLIENT_ID, E2E_AZURE_TENANT_ID, E2E_AZURE_CERTIFICATE_CONTENT
|
||||
- The public certificate matching the bundle is uploaded to the App Registration
|
||||
- Reader and ProwlerRole are assigned on the target subscription
|
||||
|
||||
### Flow Steps
|
||||
|
||||
1. Navigate to the Providers page and open the add-provider wizard
|
||||
2. Select Azure and enter the subscription details
|
||||
3. Select Certificate Authentication
|
||||
4. Verify the six-step onboarding guide, Deploy to Azure link, manual template link, and certificate generation are available
|
||||
5. Enter the Tenant ID, Client ID, and base64-encoded certificate bundle
|
||||
6. Confirm the provider connection without launching a scan
|
||||
7. Verify the provider appears in the Providers table
|
||||
|
||||
### Expected Result
|
||||
|
||||
- Azure provider connects with certificate credentials
|
||||
- Provider appears in the Providers table with the expected subscription ID
|
||||
|
||||
### Key Verification Points
|
||||
|
||||
- The certificate authentication form displays all six onboarding steps
|
||||
- Deploy to Azure loads the ARM template from the Mintlify documentation asset
|
||||
- Open template links directly to the Mintlify-hosted ARM JSON
|
||||
- Certificate generation remains available in the authentication form
|
||||
- The certificate and private key bundle is submitted through the certificate field
|
||||
- Provider connection succeeds without a client secret
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `PROVIDER-E2E-004` - Add M365 Provider with Static Credentials
|
||||
|
||||
**Priority:** `critical`
|
||||
|
||||
@@ -349,12 +349,13 @@ test.describe("Add Provider", () => {
|
||||
const subscriptionId = process.env.E2E_AZURE_SUBSCRIPTION_ID ?? "";
|
||||
const clientId = process.env.E2E_AZURE_CLIENT_ID ?? "";
|
||||
const clientSecret = process.env.E2E_AZURE_SECRET_ID ?? "";
|
||||
const certificateContent = process.env.E2E_AZURE_CERTIFICATE_CONTENT ?? "";
|
||||
const tenantId = process.env.E2E_AZURE_TENANT_ID ?? "";
|
||||
|
||||
// Setup before each test
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.skip(
|
||||
!subscriptionId || !clientId || !clientSecret || !tenantId,
|
||||
!subscriptionId || !clientId || !tenantId,
|
||||
"Azure E2E env vars are not set",
|
||||
);
|
||||
providersPage = new ProvidersPage(page);
|
||||
@@ -377,6 +378,8 @@ test.describe("Add Provider", () => {
|
||||
],
|
||||
},
|
||||
async ({ page }) => {
|
||||
test.skip(!clientSecret, "E2E_AZURE_SECRET_ID is not set");
|
||||
|
||||
// Prepare test data for AZURE provider
|
||||
const azureProviderData: AZUREProviderData = {
|
||||
subscriptionId: subscriptionId,
|
||||
@@ -406,6 +409,10 @@ test.describe("Add Provider", () => {
|
||||
await providersPage.fillAZUREProviderDetails(azureProviderData);
|
||||
await providersPage.clickNext();
|
||||
|
||||
// Azure now shows a credential-type selector (PROWLER-2378) — pick
|
||||
// the client-secret path before landing on the credentials form.
|
||||
await providersPage.selectAzureCredentialsType(azureCredentials.type);
|
||||
|
||||
// Fill static credentials details
|
||||
await providersPage.fillAZURECredentials(azureCredentials);
|
||||
await providersPage.clickNext();
|
||||
@@ -416,6 +423,52 @@ test.describe("Add Provider", () => {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"should add a new Azure provider with certificate credentials",
|
||||
{
|
||||
tag: [
|
||||
"@critical",
|
||||
"@e2e",
|
||||
"@providers",
|
||||
"@azure",
|
||||
"@serial",
|
||||
"@PROVIDER-E2E-020",
|
||||
],
|
||||
},
|
||||
async ({ page }) => {
|
||||
test.skip(
|
||||
!certificateContent,
|
||||
"E2E_AZURE_CERTIFICATE_CONTENT is not set",
|
||||
);
|
||||
|
||||
const azureProviderData: AZUREProviderData = {
|
||||
subscriptionId,
|
||||
alias: "Test E2E Azure Account - Certificate",
|
||||
};
|
||||
const azureCredentials: AZUREProviderCredential = {
|
||||
type: AZURE_CREDENTIAL_OPTIONS.AZURE_CERTIFICATE_CREDENTIALS,
|
||||
clientId,
|
||||
certificateContent,
|
||||
tenantId,
|
||||
};
|
||||
|
||||
await providersPage.goto();
|
||||
await providersPage.verifyPageLoaded();
|
||||
await providersPage.clickAddProvider();
|
||||
await providersPage.verifyConnectAccountPageLoaded();
|
||||
await providersPage.selectAZUREProvider();
|
||||
await providersPage.fillAZUREProviderDetails(azureProviderData);
|
||||
await providersPage.clickNext();
|
||||
await providersPage.selectAzureCredentialsType(azureCredentials.type);
|
||||
await providersPage.verifyAzureCertificateCredentialsPageLoaded();
|
||||
await providersPage.fillAzureCertificateCredentials(azureCredentials);
|
||||
await providersPage.clickNext();
|
||||
await providersPage.completeProviderConnectionWithoutLaunchingScan(
|
||||
subscriptionId,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test.describe.serial("Add M365 Provider", () => {
|
||||
|
||||
+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;
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
addCredentialsFormSchema,
|
||||
addCredentialsRoleFormSchema,
|
||||
addProviderFormSchema,
|
||||
CERTIFICATE_CONTENT_MAX_SIZE_ERROR,
|
||||
KUBECONFIG_UNSUPPORTED_COMMAND_AUTHENTICATION_ERROR,
|
||||
MAX_CERTIFICATE_CONTENT_LENGTH,
|
||||
samlConfigFormSchema,
|
||||
} from "./formSchemas";
|
||||
|
||||
@@ -54,6 +56,127 @@ describe("addCredentialsRoleFormSchema", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("addCredentialsFormSchema - azure certificate", () => {
|
||||
const BASE_AZURE_VALUES = {
|
||||
[ProviderCredentialFields.PROVIDER_ID]: "provider-azure-1",
|
||||
[ProviderCredentialFields.PROVIDER_TYPE]: "azure",
|
||||
[ProviderCredentialFields.TENANT_ID]:
|
||||
"12345678-1234-1234-1234-123456789012",
|
||||
[ProviderCredentialFields.CLIENT_ID]:
|
||||
"87654321-4321-4321-4321-210987654321",
|
||||
[ProviderCredentialFields.CERTIFICATE_CONTENT]: "Y2VydGlmaWNhdGU=",
|
||||
} as const;
|
||||
|
||||
it("rejects malformed tenant and client UUIDs", () => {
|
||||
// Given
|
||||
const schema = addCredentialsFormSchema("azure", "app_certificate");
|
||||
|
||||
// When
|
||||
const result = schema.safeParse({
|
||||
...BASE_AZURE_VALUES,
|
||||
[ProviderCredentialFields.TENANT_ID]: "not-a-uuid",
|
||||
[ProviderCredentialFields.CLIENT_ID]: "also-not-a-uuid",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: [ProviderCredentialFields.TENANT_ID],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
path: [ProviderCredentialFields.CLIENT_ID],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects malformed base64 certificate content", () => {
|
||||
// Given
|
||||
const schema = addCredentialsFormSchema("azure", "app_certificate");
|
||||
|
||||
// When
|
||||
const result = schema.safeParse({
|
||||
...BASE_AZURE_VALUES,
|
||||
[ProviderCredentialFields.CERTIFICATE_CONTENT]: "not!base64",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
message: "Certificate and Private Key Bundle must be valid base64",
|
||||
path: [ProviderCredentialFields.CERTIFICATE_CONTENT],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects simultaneous client-secret and certificate credentials", () => {
|
||||
// Given
|
||||
const schema = addCredentialsFormSchema("azure", "app_certificate");
|
||||
|
||||
// When
|
||||
const result = schema.safeParse({
|
||||
...BASE_AZURE_VALUES,
|
||||
[ProviderCredentialFields.CLIENT_SECRET]: "fake-client-secret",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects certificate content that exceeds the base64 length cap", () => {
|
||||
// Guardrail against a future edit dropping the `.max(...)` on the
|
||||
// Azure `certificate_content` field: a payload larger than the API's
|
||||
// `_MAX_CERTIFICATE_CONTENT_LENGTH` must never leave the browser.
|
||||
const schema = addCredentialsFormSchema("azure", "app_certificate");
|
||||
// Padding-safe: 4-char multiple of "A" (all valid base64 chars) longer
|
||||
// than the cap, so only the size check rejects (isValidBase64 passes).
|
||||
const oversized = "A".repeat(MAX_CERTIFICATE_CONTENT_LENGTH + 4);
|
||||
|
||||
const result = schema.safeParse({
|
||||
...BASE_AZURE_VALUES,
|
||||
[ProviderCredentialFields.CERTIFICATE_CONTENT]: oversized,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
message: CERTIFICATE_CONTENT_MAX_SIZE_ERROR,
|
||||
path: [ProviderCredentialFields.CERTIFICATE_CONTENT],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a wrapped-line certificate content that fits after stripping whitespace", () => {
|
||||
// openssl and PowerShell wrap base64 output at 64 chars with LF/CRLF.
|
||||
// The API strips whitespace before enforcing the cap; the client does
|
||||
// the same via `.transform(strip)`. A legitimate ~50 KB base64 that
|
||||
// exceeds the cap only because of embedded whitespace must still pass.
|
||||
const schema = addCredentialsFormSchema("azure", "app_certificate");
|
||||
const rawBase64 = "A".repeat(MAX_CERTIFICATE_CONTENT_LENGTH);
|
||||
const wrapped = rawBase64.match(/.{1,64}/g)!.join("\r\n");
|
||||
// Sanity: wrapping made it longer than the cap.
|
||||
expect(wrapped.length).toBeGreaterThan(MAX_CERTIFICATE_CONTENT_LENGTH);
|
||||
|
||||
const result = schema.safeParse({
|
||||
...BASE_AZURE_VALUES,
|
||||
[ProviderCredentialFields.CERTIFICATE_CONTENT]: wrapped,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
// The parsed value is the stripped base64 — matches what the API sees.
|
||||
expect(
|
||||
result.data[ProviderCredentialFields.CERTIFICATE_CONTENT],
|
||||
).toBe(rawBase64);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addProviderFormSchema - okta", () => {
|
||||
const validUidFixtures = [
|
||||
"acme.okta.com",
|
||||
|
||||
+114
-8
@@ -7,6 +7,16 @@ import { MAX_SAML_ADDITIONAL_EMAIL_DOMAINS } from "@/types/saml";
|
||||
|
||||
import { PROVIDER_TYPES, ProviderType } from "./providers";
|
||||
|
||||
// Matches the API's `_MAX_CERTIFICATE_CONTENT_LENGTH` in
|
||||
// `api/src/backend/api/v1/serializers.py`, i.e. base64 of the SDK's 50 KiB
|
||||
// `_MAX_CERTIFICATE_BUNDLE_BYTES` cap. Reject oversized certificate
|
||||
// content client-side so the user sees the error inline before a
|
||||
// round-trip that the API would 400 with the same message.
|
||||
export const MAX_CERTIFICATE_CONTENT_LENGTH = 68268;
|
||||
|
||||
export const CERTIFICATE_CONTENT_MAX_SIZE_ERROR =
|
||||
"Certificate content exceeds the maximum size.";
|
||||
|
||||
export const KUBECONFIG_UNSUPPORTED_COMMAND_AUTHENTICATION_ERROR =
|
||||
"Kubernetes kubeconfig command-based authentication is not supported in Prowler Cloud for security reasons.";
|
||||
|
||||
@@ -14,6 +24,23 @@ const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
};
|
||||
|
||||
const isValidBase64 = (value: string): boolean => {
|
||||
if (
|
||||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(
|
||||
value,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
atob(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const kubeconfigContainsUnsupportedCommandAuthentication = (
|
||||
value: string,
|
||||
): boolean => {
|
||||
@@ -212,15 +239,35 @@ export const addCredentialsFormSchema = (
|
||||
}
|
||||
: providerType === "azure"
|
||||
? {
|
||||
[ProviderCredentialFields.CLIENT_ID]: z
|
||||
// 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.guid({
|
||||
error: "Client ID must be a valid GUID",
|
||||
}),
|
||||
[ProviderCredentialFields.CLIENT_SECRET]: z.string().optional(),
|
||||
[ProviderCredentialFields.CERTIFICATE_CONTENT]: z
|
||||
.string()
|
||||
.min(1, "Client ID is required"),
|
||||
[ProviderCredentialFields.CLIENT_SECRET]: z
|
||||
.string()
|
||||
.min(1, "Client Secret is required"),
|
||||
[ProviderCredentialFields.TENANT_ID]: z
|
||||
.string()
|
||||
.min(1, "Tenant ID is required"),
|
||||
// The API strips base64 whitespace before enforcing its
|
||||
// 68268-char cap; measure the same value on the client so
|
||||
// a legitimate CRLF-wrapped paste (openssl / PowerShell
|
||||
// default line wrap) is not rejected as "too large".
|
||||
.transform((value) => value.replace(/\s+/g, ""))
|
||||
.pipe(
|
||||
z
|
||||
.string()
|
||||
.max(
|
||||
MAX_CERTIFICATE_CONTENT_LENGTH,
|
||||
CERTIFICATE_CONTENT_MAX_SIZE_ERROR,
|
||||
),
|
||||
)
|
||||
.optional(),
|
||||
[ProviderCredentialFields.TENANT_ID]: z.guid({
|
||||
error: "Tenant ID must be a valid GUID",
|
||||
}),
|
||||
}
|
||||
: providerType === "gcp"
|
||||
? {
|
||||
@@ -260,6 +307,19 @@ export const addCredentialsFormSchema = (
|
||||
.optional(),
|
||||
[ProviderCredentialFields.CERTIFICATE_CONTENT]: z
|
||||
.string()
|
||||
// Same whitespace-then-cap contract as Azure — the
|
||||
// API strips base64 whitespace before enforcing the
|
||||
// 68268-char cap, so the client measures the same
|
||||
// stripped value.
|
||||
.transform((value) => value.replace(/\s+/g, ""))
|
||||
.pipe(
|
||||
z
|
||||
.string()
|
||||
.max(
|
||||
MAX_CERTIFICATE_CONTENT_LENGTH,
|
||||
CERTIFICATE_CONTENT_MAX_SIZE_ERROR,
|
||||
),
|
||||
)
|
||||
.optional(),
|
||||
[ProviderCredentialFields.TENANT_ID]: z
|
||||
.string()
|
||||
@@ -443,6 +503,52 @@ 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 label rather than the technical
|
||||
// `certificate_content` field name.
|
||||
const clientSecret = data[ProviderCredentialFields.CLIENT_SECRET];
|
||||
const certificateContent =
|
||||
data[ProviderCredentialFields.CERTIFICATE_CONTENT];
|
||||
if (clientSecret?.trim() && certificateContent?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Use either a Client Secret or Certificate and Private Key Bundle, not both",
|
||||
path: [ProviderCredentialFields.CERTIFICATE_CONTENT],
|
||||
});
|
||||
}
|
||||
|
||||
if (via === "app_client_secret") {
|
||||
if (!clientSecret || clientSecret.trim() === "") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Client Secret is required",
|
||||
path: [ProviderCredentialFields.CLIENT_SECRET],
|
||||
});
|
||||
}
|
||||
} else if (via === "app_certificate") {
|
||||
if (!certificateContent || certificateContent.trim() === "") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Certificate and Private Key Bundle is required",
|
||||
path: [ProviderCredentialFields.CERTIFICATE_CONTENT],
|
||||
});
|
||||
} else if (!isValidBase64(certificateContent)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Certificate and Private Key Bundle must be valid base64",
|
||||
path: [ProviderCredentialFields.CERTIFICATE_CONTENT],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (providerType === "m365") {
|
||||
// Validate based on the via parameter
|
||||
if (via === "app_client_secret") {
|
||||
|
||||
+7
-1
@@ -125,6 +125,13 @@ export default defineConfig(() => {
|
||||
"vitest-browser-react",
|
||||
"msw/browser",
|
||||
|
||||
// Azure certificate generator (PROWLER-2378). Vite would otherwise
|
||||
// discover these two on first import inside a component test, which
|
||||
// triggers a mid-run "optimized dependencies changed. reloading" and
|
||||
// races with Playwright's route handlers.
|
||||
"@peculiar/x509",
|
||||
"reflect-metadata",
|
||||
|
||||
// React runtime (pre-bundle so a cold run doesn't re-optimize and
|
||||
// reload mid-test — see the on-demand-reload note above).
|
||||
"react-dom/client",
|
||||
@@ -206,7 +213,6 @@ export default defineConfig(() => {
|
||||
"@uiw/react-codemirror",
|
||||
"@sentry/nextjs",
|
||||
"@extractus/feed-extractor",
|
||||
"@stripe/stripe-js",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user