Compare commits

...
Author SHA1 Message Date
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
10 changed files with 2962 additions and 58 deletions
+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 @@
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