Compare commits

...
Author SHA1 Message Date
Lydia Vilchez d4990ce012 fix(ui): match API whitespace stripping and lock the cert cap + extensions 2026-09-02 13:17:05 +02:00
Lydia Vilchez 788458be4e fix(ui): wire certificate errors, cap client-side, harden the self-signed cert 2026-09-01 12:37:26 +02:00
Lydia Vilchez 4f0a49eaf1 fix(ui): restore master vitest.config.ts and keep cert-generator deps
The previous branch had an outdated vitest.config.ts that lacked the
'isServerModule' React Compiler guard master added later. Without that
guard, the React Compiler runs on Server Components at test time and
throws 'Cannot read properties of null (reading useMemoCache)' in the
Slack integration tests. Take master's version and re-add the two
optimizeDeps entries our certificate generator requires
(@peculiar/x509 and reflect-metadata).
2026-08-31 18:22:58 +02:00
Lydia Vilchez dda7c69c06 fix(ui): remove missing @stripe/stripe-js from vitest optimizeDeps
@stripe/stripe-js is listed in vitest.config.ts optimizeDeps.include
but is not a declared package.json dependency and no test imports it,
so Vite fails to resolve it at test start with 'Failed to resolve
dependency: @stripe/stripe-js, present in client optimizeDeps.include'
and cascades into 'Invalid hook call' errors across unrelated tests.
Remove the stale entry to unblock the ui-tests check.
2026-08-31 18:22:58 +02:00
Lydia Vilchez 3762c9ba6b feat(ui): add Azure Deploy-to-Azure wizard, Bicep template, and docs
Add the Azure certificate authentication onboarding to the
add-provider wizard. The wizard shows a credential-type selector
(certificate authentication recommended, service principal with
client secret as fallback) and, for the certificate flow, an in-browser
key-pair generator plus a Deploy-to-Azure quick-start that opens the
Azure Portal with the Prowler Bicep template pre-loaded.

Also publishes the ARM template through Prowler documentation with
byte-for-byte drift tests, and rewrites the Azure authentication
guide to reflect the new flow.
2026-08-31 18:22:58 +02:00
Lydia Vilchez f67cf7c3e5 fix(api): harden Azure certificate serializer and complete schema/tests 2026-08-31 18:22:57 +02:00
Lydia Vilchez fcf06e148c refactor(api): reuse SDK certificate validator and fix schema drift
Delete the API copy of `validate_certificate_bundle` and re-export the
SDK one. The local copy diverged: it missed `UnsupportedAlgorithm` on
PKCS#12 loading, `TypeError` on password-protected PEM keys, `DSA` and
`ENCRYPTED`/`OPENSSH` PEM label prefixes, and the leaf-first
normalization the SDK now depends on for azure-identity's thumbprint.
A single source keeps future SDK fixes from silently skipping the API.

Log the caught exception in `AzureProviderSecret.validate_certificate_content`
before raising the client-facing ValidationError so root-causing a bundle
parse failure doesn't need a rerun with debug on.

Drop `additionalProperties: false` from the two new Azure oneOf variants
in the ProviderSecretField schema. No other credential variant sets it,
so codegen'd clients that enforced the flag would reject payloads the
DRF serializer accepts.
2026-08-31 18:22:57 +02:00
Lydia Vilchez a1e9d243be feat(api): accept Azure certificate credentials and document the contract
Extend AzureProviderSecret to accept an optional certificate_content
field with mutual exclusion vs. client_secret, add a certificate
key-pair validator (PEM and PKCS#12), and update the public OpenAPI
schema so the two Azure credential shapes (client secret vs.
certificate content) are documented as mutually exclusive.

Consolidates #12518 into this PR.
2026-08-31 18:22:57 +02:00
Lydia Vilchez 26fe612ea7 fix(azure): cap certificate bundle at 50 KiB before parsing
Legitimate PEM/PKCS#12 bundles are well under 10 KiB. A multi-MB
payload would waste memory on base64 decoding plus PKCS#12/PEM parsing
before the validator rejects it, so the size check runs before any
parsing. Prevents memory-exhaustion attacks from callers that hand
untrusted bytes to `validate_certificate_bundle`.
2026-08-31 18:22:56 +02:00
Lydia Vilchez bd5afb6981 fix(azure): address review feedback on Azure certificate authentication 2026-08-31 18:22:56 +02:00
Lydia Vilchez c593800e24 docs(changelog): describe Azure certificate auth alongside client-secret flow 2026-08-31 18:22:56 +02:00
Lydia Vilchez f1e331ad23 fix(azure): restore validate_arguments/setup_session positional layout and harden cert path
- Move certificate kwargs behind `*,` in `validate_arguments`, `setup_session`
  and `verify_client` so pre-existing positional callers keep binding
  `tenant_id`/`client_id`/`azure_credentials`/`region_config` correctly.
- Hoist the transient `RequestsTransport` in `verify_client` to a local so
  `finally` can close it even when `CertificateCredential.__init__` raises
  before the credential is bound.
- Drop the unused `client_id` parameter from `check_certificate_creds_env_vars`
  (no caller propagates it) and always require `AZURE_CLIENT_ID` on the
  pure env-var flow.
- Update `_normalize_pem_bundle` to iterate every private-key block so a
  bundle whose leaf pairs with a non-first key is normalized correctly;
  preserve the encrypted-key `TypeError` for single-key bundles.
- Add `inspect.signature(...).bind(...)` regressions for the restored
  signatures and a multi-key PEM regression.
2026-08-31 18:22:56 +02:00
Lydia Vilchez 2f91cf430b fix(azure): harden certificate verify_client and restore positional signatures
Address Hugo's four review comments on the certificate authentication
work:

1. `AzureProvider.__init__` and `AzureProvider.validate_static_credentials`
   inserted the certificate kwargs between existing positional
   parameters. A caller that previously passed `resource_groups` or
   `region_config` positionally would silently rebind their argument to
   a certificate flag. Both signatures now keep the pre-existing
   positional layout and mark only the certificate kwargs as
   keyword-only.

2. `verify_client`'s certificate path used to catch `ServiceRequestError`
   directly from `credential.get_token()`, but `azure.identity` wraps
   `_request_token` with `wrap_exceptions`, so a real connect or read
   timeout arrived here as `ClientAuthenticationError` and was reported
   to the user as an invalid certificate. Catch
   `ClientAuthenticationError` and walk `__cause__`/`__context__` via
   the new `_find_transport_cause`: a `ServiceRequestError` or
   `ServiceResponseError` cause maps to
   `AzureCredentialsUnavailableError`; anything else keeps the invalid
   certificate mapping. Pass `retry_total=0` so Azure Core cannot
   multiply the effective deadline, and close the transient credential
   in `finally`, logging and swallowing cleanup failures so `close()`
   cannot replace the primary typed exception.

3. Rewrite the certificate timeout tests to exercise the real
   `CertificateCredential` and `RequestsTransport` pipeline, stubbing
   only `requests.Session.request` with `ConnectTimeout` and
   `ReadTimeout`. Assert `session.request.call_count == 1` to prove
   `retry_total=0` is honoured, cover
   `test_connection(..., raise_on_exception=False)`, and add a
   cleanup-failure case proving `close()` cannot mask the typed error.

4. Add `inspect.signature(...).bind(...)` regressions for `__init__`,
   `test_connection` and `validate_static_credentials` using the
   pre-existing positional call shape, asserting the certificate
   kwargs are `KEYWORD_ONLY`.
2026-08-31 18:22:56 +02:00
Lydia Vilchez f2d0e714c8 fix(azure): enforce certificate token timeout at the HTTP transport
Replace the `ThreadPoolExecutor` + `future.result(timeout=...)` pattern
in `verify_client`'s certificate path with a `RequestsTransport` that
carries the connection/read deadlines. The executor approach could
not cancel a running `credential.get_token`, so timed-out or otherwise
failing calls left the underlying worker and network request alive:
under Entra ID degradation the API and Celery paths accumulated
background workers, and non-timeout exceptions bypassed executor
shutdown entirely.

Transport-layer timeouts terminate the request itself, so there is no
worker to leak and no cleanup path to miss. Translate the resulting
`ServiceRequestError` to `AzureCredentialsUnavailableError` to keep the
existing contract for `verify_client` and `test_connection`.

Update the timeout and leaf-first tests to match the new codepath.
2026-08-31 18:22:56 +02:00
Lydia Vilchez 936d2c02a3 fix(azure): restore test_connection positional signature and cover cert timeouts
Move `provider_id` back to its original positional slot in
`AzureProvider.test_connection`; the keyword-only barrier introduced by
the certificate kwargs was breaking external callers passing it
positionally.

Add the regression coverage Hugo asked for in the SDK PR review:
- `verify_client` translates `FuturesTimeoutError` to
  `AzureCredentialsUnavailableError` for both certificate_content and
  certificate_path (the background token-request path)
- `test_connection(..., raise_on_exception=False)` returns
  `Connection(error=AzureCredentialsUnavailableError)` for both
  certificate variants
- End-to-end leaf-first assertions for the remaining
  `CertificateCredential` call sites: `setup_session` azure_credentials
  certificate_path branch, `verify_client` with content and with path,
  and `validate_static_credentials` re-encoded output
2026-08-31 18:22:56 +02:00
Lydia Vilchez 6a52bf432d test(azure): cover Hugo's regression cases for certificate authentication
Reproduce the original bug where `--tenant-id` was ignored when
AZURE_TENANT_ID was unset: `check_certificate_creds_env_vars` must not
raise when the explicit tenant replaces a missing env var.

Add the missing `requests.exceptions.Timeout` coverage: verify_client
translates the transport error to AzureCredentialsUnavailableError, and
test_connection with raise_on_exception=False returns
Connection(error=AzureCredentialsUnavailableError) instead of a raw
transport exception.

Assert end-to-end that CertificateCredential receives a leaf-first
bundle on both the env-var / --certificate-path branch and the
azure_credentials (API/UI) branch. A regression that skips the
normalization at any call site would silently break auth today; the
helper-only test could not catch that.
2026-08-31 18:22:56 +02:00
Lydia Vilchez 5317c589b3 refactor(azure): handle encrypted PEM keys and tighten bundle test
`cryptography.hazmat.primitives.serialization.load_pem_private_key`
raises TypeError, not ValueError, when the caller passes password=None
against an encrypted key. Add TypeError to verify_client's except tuple
so that path becomes AzureNotValidCertificateContentError or
AzureNotValidCertificatePathError instead of leaking. Assert the leaf-
first ordering explicitly in the bundle test so a regression that returns
the input unchanged cannot silently pass.
2026-08-31 18:22:56 +02:00
Lydia VilchezandClaude Opus 4.7 6a411881d6 refactor(azure): address Hugo review comments on Azure certificate auth
Normalize the PEM bundle at every CertificateCredential call site so
azure-identity uses the leaf certificate for its thumbprint even when
the source bundle lists an intermediate first. `check_certificate_creds_env_vars`
now accepts the explicit CLI tenant_id/client_id so callers don't require
matching AZURE_* env vars when they already have the values. Catch
requests.exceptions.Timeout on the client-secret verification path so the
Entra ID timeout raises a typed AzureCredentialsUnavailableError instead
of leaking a requests exception.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-31 18:22:56 +02:00
Lydia Vilchez 6b4950f922 refactor(azure): address CodeRabbit follow-up review comments
- verify_client certificate branch now manages the ThreadPoolExecutor
  explicitly so shutdown(wait=False) on timeout does not extend the
  30s deadline while credential.get_token is still running.
- client-secret token endpoint uses the shared
  _TOKEN_ACQUISITION_TIMEOUT_SECONDS constant instead of hardcoded 30.
- setup_session env-var certificate branch catches TypeError (raised by
  load_pem_private_key when a PEM key is password-protected and no
  password is supplied) alongside binascii.Error, OSError and friends.
- setup_session re-raises AzureNotValidCertificateContentError and
  AzureNotValidCertificatePathError before the outer except Exception
  wraps them into AzureSetUpSessionError, so callers still see the
  certificate-specific error type.
- validate_arguments error message no longer names --certificate-content
  as a CLI flag (the option only exists on the API/UI credential shape).
- Changelog fragment drops the redundant 'Add' verb per the prowler
  changelog convention.
- Test paths that need a non-existent file use tmp_path instead of
  hardcoded /tmp/ locations that could collide on shared runners.
- validate_arguments, setup_identity and verify_client docstrings
  document the new certificate parameters and typed errors.
2026-08-31 18:22:56 +02:00
Lydia Vilchez d6354068af refactor(azure): harden Azure certificate authentication paths
Address the 15 findings from the SDK code review:

- Certificate bundle validation now walks every PEM certificate block so
  intermediate-before-leaf order (openssl / Key Vault exports) is
  accepted, covers encrypted PKCS#8/DSA/OpenSSH private-key labels, and
  catches cryptography.UnsupportedAlgorithm alongside ValueError.
- validate_arguments rejects --certificate-content/--certificate-path
  without --certificate-auth (or a full static-credentials trio) and no
  longer requires --tenant-id when --certificate-auth is used with an
  env-var flow. --certificate-auth --tenant-id X no longer mistakenly
  raises the browser-auth error.
- setup_session prefers explicit --tenant-id over AZURE_TENANT_ID on the
  env-var certificate path, gates the env-var check on the absence of a
  static-credentials dict, runs validate_certificate_bundle before
  instantiating CertificateCredential, and maps base64/OS errors to
  typed certificate errors.
- verify_client runs the certificate get_token off-thread with a 30s
  hard timeout so a stalled Entra ID endpoint cannot pin a request
  thread or Celery worker, and catches the same base64/OS errors on the
  certificate branch.
- _compute_certificate_thumbprint logs each parser failure instead of
  silently discarding them, and the setattr on CertificateCredential
  falls back to a module-level map keyed by id() so a future
  azure-identity release that adds __slots__ cannot break the feature.
2026-08-31 18:22:56 +02:00
Lydia Vilchez 4d368d7f1d refactor(azure): log certificate parsing errors and tighten cert tests
Address CodeRabbit review:
- Log caught exceptions in the certificate content and path validation
  handlers so failures are diagnosable from the log file, matching the
  established caught-exception logging idiom.
- Assert the full credentials dict in the certificate acceptance tests
  so a stray truthy client_secret or certificate_content that would
  route setup_session to the wrong branch is caught.
2026-08-31 18:22:56 +02:00
Lydia Vilchez 1871efba93 feat(azure): add certificate authentication to Azure SDK
Add certificate-based Service Principal authentication to the Azure
provider. AzureProvider accepts a certificate (base64 content or file
path) and authenticates via azure.identity.CertificateCredential,
mirroring the M365 provider flow. Includes CLI flags
(--certificate-auth, --certificate-content, --certificate-path),
key-pair validation for PEM and PKCS#12 bundles, and unit tests.
2026-08-31 18:22:56 +02:00
60 changed files with 6749 additions and 206 deletions
@@ -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
+1
View File
@@ -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
+273 -50
View File
@@ -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": [
+69 -12
View File
@@ -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
+8
View File
@@ -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
![Grant Admin Consent](/images/providers/grant-admin-consent.png)
![Granted Admin Consent](/images/providers/granted-admin-consent.png)
</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.
![Adding the Reader Role to a Subscription](/images/providers/add-reader-role.png)
</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
![Search Subscription](/images/providers/search-subscriptions.png)
![Subscriptions Page](/images/providers/get-subscription-id.png)
![Search Subscription](/images/providers/search-subscriptions.png)
![Subscriptions Page](/images/providers/get-subscription-id.png)
---
@@ -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`
![Providers Page](/images/prowler-app/cloud-providers-page.png)
![Providers Page](/images/prowler-app/cloud-providers-page.png)
3. Click `Add Provider`
![Add a Provider](/images/prowler-app/add-cloud-provider.png)
![Add a Provider](/images/prowler-app/add-cloud-provider.png)
4. Select `Microsoft Azure`
![Select Microsoft Azure](/images/providers/select-azure-prowler-cloud.png)
![Select Microsoft Azure](/images/providers/select-azure-prowler-cloud.png)
5. Add the Subscription ID and an optional alias, then click `Next`
![Add Subscription ID](/images/providers/add-subscription-id.png)
![Add Subscription ID](/images/providers/add-subscription-id.png)
### 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).
![App Overview](/images/providers/app-overview.png)
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.
![Prowler Cloud Azure Credentials](/images/providers/add-credentials-azure-prowler-cloud.png)
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`
![Prowler Cloud Azure Credentials](/images/providers/add-credentials-azure-prowler-cloud.png)
![Next Detail](/images/providers/click-next-azure.png)
4. Click `Next`, then **Launch Scan**.
4. Click "Launch Scan"
![Launch Scan Azure](/images/providers/launch-scan.png)
![Launch Scan Azure](/images/providers/launch-scan.png)
---
@@ -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
+201
View File
@@ -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(
+147
View File
@@ -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")
+1
View File
@@ -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 = {}
+2
View File
@@ -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,
+48
View File
@@ -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" && (
@@ -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);
});
});
@@ -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",
);
});
});
@@ -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 &amp; 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&apos;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}
/>
</>
);
};
@@ -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&apos;s <em>Certificates &amp; 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
/>
</>
);
};
@@ -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";
@@ -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>
)}
</>
)}
/>
);
};
@@ -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";
+16 -1
View File
@@ -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]: "",
+198
View File
@@ -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");
});
});
+266
View File
@@ -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 };
+2
View File
@@ -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,
+20
View File
@@ -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 =
+20
View File
@@ -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);
};
+12
View File
@@ -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" &&
+2
View File
@@ -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",
+177
View File
@@ -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
+83 -2
View File
@@ -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
+45
View File
@@ -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`
+54 -1
View File
@@ -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
View File
@@ -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;
+123
View File
@@ -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
View File
@@ -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
View File
@@ -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",
],
},
};