Compare commits

...
Author SHA1 Message Date
Lydia Vilchez e01ce68f10 fix(api): scope the secret reassignment to Azure certificate_content only 2026-09-01 21:49:16 +02:00
Lydia Vilchez 721a80921b fix(api): persist per-provider secret normalization and drop M365 scope creep 2026-09-01 15:15:26 +02:00
Lydia Vilchez ef62731c0a fix(api): tighten Azure certificate cap and route oversized/whitespace inputs through the typed error 2026-08-31 21:31:31 +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
19 changed files with 4026 additions and 107 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 @@
`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
+265 -46
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: 68268
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,8 @@ components:
where the application is registered.
certificate_content:
type: string
description: The certificate content in base64 format for
certificate-based authentication.
description: The certificate content in base64 format for certificate-based
authentication.
required:
- client_id
- tenant_id
@@ -21387,7 +21424,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 +21488,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 +23439,7 @@ components:
- role_arn
- external_id
- type: object
title: Azure Static Credentials
title: Azure Client Secret Credentials
properties:
client_id:
type: string
@@ -23414,6 +23457,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: 68268
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:
@@ -23510,7 +23573,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 +23636,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 +23905,7 @@ components:
- role_arn
- external_id
- type: object
title: Azure Static Credentials
title: Azure Client Secret Credentials
properties:
client_id:
type: string
@@ -23853,6 +23923,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: 68268
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 +23985,8 @@ components:
where the application is registered.
certificate_content:
type: string
description: The certificate content in base64 format for
certificate-based authentication.
description: The certificate content in base64 format for certificate-based
authentication.
required:
- client_id
- tenant_id
@@ -23950,7 +24040,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 +24104,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 +24393,7 @@ components:
- role_arn
- external_id
- type: object
title: Azure Static Credentials
title: Azure Client Secret Credentials
properties:
client_id:
type: string
@@ -24315,6 +24411,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: 68268
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:
@@ -24411,7 +24527,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 +24590,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 +26932,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 +27069,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,268 @@ 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 inside
# `validate_certificate_content` (before base64 decoding or bundle
# parsing runs) so a multi-MB blob cannot exhaust API-worker
# memory. The typed `azure-certificate-content` code lets JSON:API
# clients recognize this as a certificate failure rather than a
# generic length violation.
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
assert (
serializer.errors["certificate_content"][0].code
== "azure-certificate-content"
)
def test_tolerates_whitespace_in_certificate_content(self):
# A base64 payload with embedded whitespace (CRLF from a Windows
# terminal, wrapped copy-paste) must not be rejected as "invalid
# base64" — whitespace carries no information in the encoding.
import base64
bundle_b64 = self.certificate_bundle()
# Insert CRLF every 64 chars and leading/trailing spaces to mimic
# a copy-paste from a terminal export.
wrapped = (
" "
+ "\r\n".join(bundle_b64[i : i + 64] for i in range(0, len(bundle_b64), 64))
+ " "
)
serializer = AzureProviderSecret(
data={**self.BASE, "certificate_content": wrapped}
)
assert serializer.is_valid(), serializer.errors
# The stored value is the whitespace-stripped payload, so the SDK
# sees exactly the bytes it would from a clean base64 upload.
assert serializer.validated_data["certificate_content"] == bundle_b64
# And it still decodes to the original bundle unchanged.
assert base64.b64decode(
serializer.validated_data["certificate_content"]
) == base64.b64decode(bundle_b64)
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 +507,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(
+253
View File
@@ -3444,6 +3444,259 @@ current-context: test-context
provider_secret.refresh_from_db()
assert "region" not in provider_secret.secret
@staticmethod
def _azure_certificate_bundle_base64():
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_provider_secrets_create_azure_persists_whitespace_stripped_certificate(
self,
authenticated_client,
azure_provider,
):
# `AzureProviderSecret.validate_certificate_content` strips whitespace
# inside the base64 payload. The outer write path must reassign the
# validator's normalized output so the value that reaches Fernet
# storage matches what the SDK will later decode.
clean_b64 = self._azure_certificate_bundle_base64()
wrapped_b64 = (
" "
+ "\r\n".join(clean_b64[i : i + 64] for i in range(0, len(clean_b64), 64))
+ " "
)
data = {
"data": {
"type": "provider-secrets",
"attributes": {
"name": "Azure Cert Secret",
"secret_type": ProviderSecret.TypeChoices.STATIC,
"secret": {
"client_id": "87654321-4321-4321-4321-210987654321",
"tenant_id": "12345678-1234-1234-1234-123456789012",
"certificate_content": wrapped_b64,
},
},
"relationships": {
"provider": {
"data": {"type": "providers", "id": str(azure_provider.id)}
}
},
}
}
response = authenticated_client.post(
reverse("providersecret-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED, response.content
provider_secret = ProviderSecret.objects.get(id=response.json()["data"]["id"])
# Persisted value is the whitespace-stripped payload — proves the
# per-provider validator's return value is what Fernet encrypted,
# not the raw upload.
assert provider_secret.secret["certificate_content"] == clean_b64
def test_provider_secrets_update_azure_persists_whitespace_stripped_certificate(
self,
authenticated_client,
azure_provider,
):
create_response = authenticated_client.post(
reverse("providersecret-list"),
data=json.dumps(
{
"data": {
"type": "provider-secrets",
"attributes": {
"name": "Azure Cert Secret",
"secret_type": ProviderSecret.TypeChoices.STATIC,
"secret": {
"client_id": "87654321-4321-4321-4321-210987654321",
"tenant_id": "12345678-1234-1234-1234-123456789012",
"certificate_content": self._azure_certificate_bundle_base64(),
},
},
"relationships": {
"provider": {
"data": {
"type": "providers",
"id": str(azure_provider.id),
}
}
},
}
}
),
content_type="application/vnd.api+json",
)
assert create_response.status_code == status.HTTP_201_CREATED
provider_secret = ProviderSecret.objects.get(
id=create_response.json()["data"]["id"]
)
clean_b64 = self._azure_certificate_bundle_base64()
wrapped_b64 = "\r\n".join(
clean_b64[i : i + 64] for i in range(0, len(clean_b64), 64)
)
response = authenticated_client.patch(
reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
data=json.dumps(
{
"data": {
"type": "provider-secrets",
"id": str(provider_secret.id),
"attributes": {
"secret": {
"client_id": "87654321-4321-4321-4321-210987654321",
"tenant_id": "12345678-1234-1234-1234-123456789012",
"certificate_content": wrapped_b64,
}
},
}
}
),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_200_OK, response.content
provider_secret.refresh_from_db()
assert provider_secret.secret["certificate_content"] == clean_b64
def test_provider_secrets_create_m365_preserves_whitespace_in_client_secret(
self,
authenticated_client,
m365_provider,
):
# DRF `CharField.trim_whitespace` (the default) would silently strip
# surrounding whitespace off opaque credentials if the outer write
# path blindly persisted the validated dict. Legitimate M365 client
# secrets can contain leading/trailing whitespace, so the raw
# submitted value must survive the round-trip unchanged.
whitespace_secret = " M365-Secret-With-Padding "
data = {
"data": {
"type": "provider-secrets",
"attributes": {
"name": "M365 Secret",
"secret_type": ProviderSecret.TypeChoices.STATIC,
"secret": {
"client_id": "87654321-4321-4321-4321-210987654321",
"tenant_id": "12345678-1234-1234-1234-123456789012",
"client_secret": whitespace_secret,
},
},
"relationships": {
"provider": {
"data": {"type": "providers", "id": str(m365_provider.id)}
}
},
}
}
response = authenticated_client.post(
reverse("providersecret-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED, response.content
provider_secret = ProviderSecret.objects.get(id=response.json()["data"]["id"])
assert provider_secret.secret["client_secret"] == whitespace_secret
def test_provider_secrets_update_image_preserves_whitespace_in_registry_password(
self,
authenticated_client,
image_provider,
):
# Container-registry passwords can be opaque high-entropy strings
# generated by tooling that includes trailing whitespace as part of
# the credential. The outer write path must keep the submitted
# value verbatim so the registry still accepts it after a PATCH.
create_response = authenticated_client.post(
reverse("providersecret-list"),
data=json.dumps(
{
"data": {
"type": "provider-secrets",
"attributes": {
"name": "Registry Secret",
"secret_type": ProviderSecret.TypeChoices.STATIC,
"secret": {
"registry_username": "prowler",
"registry_password": "initial-password",
},
},
"relationships": {
"provider": {
"data": {
"type": "providers",
"id": str(image_provider.id),
}
}
},
}
}
),
content_type="application/vnd.api+json",
)
assert create_response.status_code == status.HTTP_201_CREATED
whitespace_password = " registry-password-with-padding "
provider_secret = ProviderSecret.objects.get(
id=create_response.json()["data"]["id"]
)
response = authenticated_client.patch(
reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
data=json.dumps(
{
"data": {
"type": "provider-secrets",
"id": str(provider_secret.id),
"attributes": {
"secret": {
"registry_username": "prowler",
"registry_password": whitespace_password,
}
},
}
}
),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_200_OK, response.content
provider_secret.refresh_from_db()
assert provider_secret.secret["registry_password"] == whitespace_password
@pytest.mark.parametrize(
"attributes, error_code, error_pointer",
(
@@ -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",
+102 -2
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,77 @@ 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.
# `((51200 + 2) // 3) * 4` = 68268 base64 chars for a bundle exactly at the
# SDK cap. Keep the two constants aligned if either side moves.
_MAX_CERTIFICATE_CONTENT_LENGTH = 68268
class AzureProviderSecret(serializers.Serializer):
client_id = serializers.CharField()
client_secret = serializers.CharField()
client_secret = serializers.CharField(required=False)
tenant_id = serializers.CharField()
# The size cap is enforced in `validate_certificate_content` with the
# typed `azure-certificate-content` code, not via `max_length=`. DRF's
# built-in max-length check runs before per-field validators and emits
# `code="max_length"`, which JSON:API clients keyed to the typed
# certificate error code cannot recognize as a certificate failure.
certificate_content = serializers.CharField(required=False)
def validate(self, attrs):
if attrs.get("client_secret") and attrs.get("certificate_content"):
raise serializers.ValidationError(
"You cannot provide both client_secret and certificate_content.",
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:
# Tolerate whitespace inside the base64 payload: exports from
# Windows terminals (CRLF) or wrapped copy-paste survive without
# tripping `base64.b64decode(validate=True)`, and the reader is
# base64 anyway — internal whitespace carries no information.
certificate_content = "".join(certificate_content.split())
if len(certificate_content) > _MAX_CERTIFICATE_CONTENT_LENGTH:
# Reject oversized payloads with the typed certificate code
# so JSON:API clients recognize this as a certificate-content
# failure rather than a generic length violation.
raise serializers.ValidationError(
"Certificate content exceeds the maximum size.",
code="azure-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"
@@ -2092,8 +2163,24 @@ class ProviderSecretCreateSerializer(
validated_secret = self.validate_secret_based_on_provider(
provider.provider, secret_type, secret
)
# OCI persists the full validated dict on purpose (its serializer
# already performs the sanitization it wants). For Azure, only the
# `certificate_content` field must be replaced with the normalized
# (whitespace-stripped) value; the rest of the dict is left as the
# caller submitted it so opaque credentials elsewhere in the payload
# keep whatever whitespace they carried. Every other provider stays
# on the outer JSONField's raw dict — DRF's `CharField.trim_whitespace`
# would otherwise silently mutate opaque tokens/passwords.
if provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
validated_attrs["secret"] = validated_secret
elif (
provider.provider == Provider.ProviderChoices.AZURE.value
and validated_secret.get("certificate_content")
):
validated_attrs["secret"] = {
**secret,
"certificate_content": validated_secret["certificate_content"],
}
return validated_attrs
@@ -2128,8 +2215,21 @@ class ProviderSecretUpdateSerializer(BaseWriteProviderSecretSerializer):
validated_secret = self.validate_secret_based_on_provider(
provider.provider, secret_type, secret
)
# Same targeted persistence as `ProviderSecretCreateSerializer.validate`:
# OCI keeps its full validated dict, Azure replaces only
# `certificate_content`, and every other provider stays on the raw
# submitted dict so opaque credentials do not get their whitespace
# silently trimmed.
if provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
validated_attrs["secret"] = validated_secret
elif (
provider.provider == Provider.ProviderChoices.AZURE.value
and validated_secret.get("certificate_content")
):
validated_attrs["secret"] = {
**secret,
"certificate_content": validated_secret["certificate_content"],
}
return validated_attrs
+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 @@
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