mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e01ce68f10 | ||
|
|
721a80921b | ||
|
|
ef62731c0a | ||
|
|
f67cf7c3e5 | ||
|
|
fcf06e148c | ||
|
|
a1e9d243be | ||
|
|
26fe612ea7 | ||
|
|
bd5afb6981 | ||
|
|
c593800e24 | ||
|
|
f1e331ad23 | ||
|
|
2f91cf430b | ||
|
|
f2d0e714c8 | ||
|
|
936d2c02a3 | ||
|
|
6a52bf432d | ||
|
|
5317c589b3 | ||
|
|
6a411881d6 | ||
|
|
6b4950f922 | ||
|
|
d6354068af | ||
|
|
4d368d7f1d | ||
|
|
1871efba93 |
@@ -175,3 +175,4 @@ docker-compose.override.yml
|
||||
docker-compose-dev.override.yml
|
||||
# Local Pi runtime state
|
||||
.atl/
|
||||
.gga
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
`POST /api/v1/providers` OpenAPI schema documents Azure certificate authentication credentials
|
||||
@@ -0,0 +1 @@
|
||||
`certificate_content` support for Azure provider secrets, with mutual exclusion against `client_secret` and certificate/private-key bundle validation
|
||||
@@ -6091,16 +6091,6 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
- in: query
|
||||
name: filter[updated_at__gte]
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
- in: query
|
||||
name: filter[updated_at__lte]
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
- name: sort
|
||||
required: false
|
||||
in: query
|
||||
@@ -16312,7 +16302,7 @@ paths:
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserResponse'
|
||||
$ref: '#/components/schemas/UserMeResponse'
|
||||
description: ''
|
||||
components:
|
||||
schemas:
|
||||
@@ -16444,6 +16434,17 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AttackPathsQueryParameter'
|
||||
outcome:
|
||||
type: object
|
||||
nullable: true
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
partial:
|
||||
type: boolean
|
||||
readOnly: true
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
@@ -17680,7 +17681,11 @@ components:
|
||||
can be generated from your Atlassian account settings.
|
||||
domain:
|
||||
type: string
|
||||
description: The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').
|
||||
description: The Jira site name without the '.atlassian.net' suffix
|
||||
(e.g., 'your-domain').
|
||||
minLength: 1
|
||||
maxLength: 63
|
||||
pattern: ^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$
|
||||
required:
|
||||
- user_mail
|
||||
- api_token
|
||||
@@ -17865,7 +17870,11 @@ components:
|
||||
can be generated from your Atlassian account settings.
|
||||
domain:
|
||||
type: string
|
||||
description: The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').
|
||||
description: The Jira site name without the '.atlassian.net'
|
||||
suffix (e.g., 'your-domain').
|
||||
minLength: 1
|
||||
maxLength: 63
|
||||
pattern: ^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$
|
||||
required:
|
||||
- user_mail
|
||||
- api_token
|
||||
@@ -18127,7 +18136,11 @@ components:
|
||||
can be generated from your Atlassian account settings.
|
||||
domain:
|
||||
type: string
|
||||
description: The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').
|
||||
description: The Jira site name without the '.atlassian.net' suffix
|
||||
(e.g., 'your-domain').
|
||||
minLength: 1
|
||||
maxLength: 63
|
||||
pattern: ^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$
|
||||
required:
|
||||
- user_mail
|
||||
- api_token
|
||||
@@ -20554,7 +20567,11 @@ components:
|
||||
can be generated from your Atlassian account settings.
|
||||
domain:
|
||||
type: string
|
||||
description: The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').
|
||||
description: The Jira site name without the '.atlassian.net'
|
||||
suffix (e.g., 'your-domain').
|
||||
minLength: 1
|
||||
maxLength: 63
|
||||
pattern: ^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$
|
||||
required:
|
||||
- user_mail
|
||||
- api_token
|
||||
@@ -21272,7 +21289,7 @@ components:
|
||||
- role_arn
|
||||
- external_id
|
||||
- type: object
|
||||
title: Azure Static Credentials
|
||||
title: Azure Client Secret Credentials
|
||||
properties:
|
||||
client_id:
|
||||
type: string
|
||||
@@ -21290,6 +21307,26 @@ components:
|
||||
- client_id
|
||||
- client_secret
|
||||
- tenant_id
|
||||
- type: object
|
||||
title: Azure Certificate Credentials
|
||||
properties:
|
||||
client_id:
|
||||
type: string
|
||||
description: The Azure application (client) ID for authentication
|
||||
in Azure AD.
|
||||
certificate_content:
|
||||
type: string
|
||||
description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate
|
||||
and its matching private key for certificate-based authentication.
|
||||
maxLength: 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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.exceptions import UnsupportedAlgorithm
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||
|
||||
# Reject bundles larger than this before parsing: legitimate PEM and PFX
|
||||
# bundles are under ~10 KiB, and a multi-MB payload would waste memory on
|
||||
# the doubling that base64 decoding plus PKCS#12/PEM parsing perform.
|
||||
_MAX_CERTIFICATE_BUNDLE_BYTES = 50 * 1024
|
||||
|
||||
_CERTIFICATE_BLOCK_RE = re.compile(
|
||||
rb"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
# Covers PKCS#8 (encrypted or not), legacy RSA/EC/DSA and OpenSSH PEM labels.
|
||||
# The actual decoding is delegated to `load_pem_private_key` below.
|
||||
_PRIVATE_KEY_BLOCK_RE = re.compile(
|
||||
rb"-----BEGIN (?:ENCRYPTED |OPENSSH |RSA |EC |DSA )?PRIVATE KEY-----"
|
||||
rb".*?"
|
||||
rb"-----END (?:ENCRYPTED |OPENSSH |RSA |EC |DSA )?PRIVATE KEY-----",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def validate_certificate_bundle(certificate_data: bytes) -> bytes:
|
||||
"""Validate the bundle and return a normalized copy safe for azure-identity.
|
||||
|
||||
Accepts either a PKCS#12/PFX blob (encrypted or unencrypted with a null
|
||||
password) or a concatenated PEM bundle. Raises ``ValueError`` when the
|
||||
payload exceeds the maximum bundle size, is missing a certificate,
|
||||
missing a private key, or contains a pair whose public keys do not
|
||||
match. Raises ``TypeError`` for password-protected PEM private keys,
|
||||
which cryptography surfaces from
|
||||
``load_pem_private_key(..., password=None)``.
|
||||
|
||||
The normalized bytes always place the leaf certificate before the private
|
||||
key so ``azure.identity.CertificateCredential`` — which uses the first
|
||||
``BEGIN CERTIFICATE`` block to compute the credential thumbprint — never
|
||||
picks an intermediate CA over the matching leaf. PKCS#12 blobs are
|
||||
returned as-is.
|
||||
"""
|
||||
if len(certificate_data) > _MAX_CERTIFICATE_BUNDLE_BYTES:
|
||||
raise ValueError(
|
||||
f"the payload exceeds the maximum bundle size of "
|
||||
f"{_MAX_CERTIFICATE_BUNDLE_BYTES} bytes"
|
||||
)
|
||||
try:
|
||||
private_key, certificate, additional_certs = pkcs12.load_key_and_certificates(
|
||||
certificate_data, None, default_backend()
|
||||
)
|
||||
except (ValueError, UnsupportedAlgorithm) as error:
|
||||
# `load_key_and_certificates` also raises `ValueError` when the
|
||||
# PKCS#12 archive is password-protected (message text: "Invalid
|
||||
# password or PKCS12 data"). Fall through to the PEM parser only
|
||||
# when the payload smells like PEM; otherwise raise a specific
|
||||
# error so the caller does not see the misleading "missing
|
||||
# certificate or key" message from `_normalize_pem_bundle`.
|
||||
if b"-----BEGIN" not in certificate_data:
|
||||
raise ValueError(
|
||||
"the payload is not a valid PEM bundle nor an unencrypted "
|
||||
"PKCS#12 archive; password-protected PKCS#12 archives are "
|
||||
"not supported"
|
||||
) from error
|
||||
return _normalize_pem_bundle(certificate_data)
|
||||
|
||||
if private_key is None:
|
||||
raise ValueError("the PKCS#12 archive does not contain a private key")
|
||||
if certificate is None and not additional_certs:
|
||||
raise ValueError("the PKCS#12 archive does not contain a certificate")
|
||||
|
||||
encoding = serialization.Encoding.DER
|
||||
public_format = serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
key_public_bytes = private_key.public_key().public_bytes(encoding, public_format)
|
||||
if (
|
||||
certificate is not None
|
||||
and certificate.public_key().public_bytes(encoding, public_format)
|
||||
== key_public_bytes
|
||||
):
|
||||
# PKCS#12 blobs are consumed directly by azure-identity; no reordering.
|
||||
return certificate_data
|
||||
|
||||
# Some `openssl pkcs12 -export -certfile` workflows write the leaf
|
||||
# certificate into the additional-certs bag instead of the primary
|
||||
# slot. azure-identity reads the primary certificate for the
|
||||
# thumbprint, so accepting the archive as-is would authenticate
|
||||
# against the wrong thumbprint. Detect that case and raise an
|
||||
# actionable error rather than the opaque "does not match" message.
|
||||
for candidate in additional_certs or ():
|
||||
if (
|
||||
candidate.public_key().public_bytes(encoding, public_format)
|
||||
== key_public_bytes
|
||||
):
|
||||
raise ValueError(
|
||||
"the PKCS#12 archive has the certificate matching the "
|
||||
"private key in the additional-certs bag; re-export the "
|
||||
"archive with the leaf certificate as the primary entry"
|
||||
)
|
||||
raise ValueError("the certificate does not match the private key")
|
||||
|
||||
|
||||
def _normalize_pem_bundle(certificate_data: bytes) -> bytes:
|
||||
"""Validate a PEM bundle and return it with the matching leaf first."""
|
||||
certificate_blocks = _CERTIFICATE_BLOCK_RE.findall(certificate_data)
|
||||
private_key_matches = list(_PRIVATE_KEY_BLOCK_RE.finditer(certificate_data))
|
||||
|
||||
if not certificate_blocks or not private_key_matches:
|
||||
raise ValueError("the payload must contain a certificate and its private key")
|
||||
|
||||
# A bundle may carry more than one private key block (e.g. legacy tools
|
||||
# export both an RSA and a PKCS#8 copy). Try each in order; preserve the
|
||||
# first parse error so that a single encrypted key still surfaces the
|
||||
# TypeError callers rely on to route to the typed certificate errors.
|
||||
first_key_error: Optional[Exception] = None
|
||||
for key_match in private_key_matches:
|
||||
try:
|
||||
private_key = serialization.load_pem_private_key(
|
||||
key_match.group(), password=None, backend=default_backend()
|
||||
)
|
||||
except (ValueError, TypeError) as error:
|
||||
if first_key_error is None:
|
||||
first_key_error = error
|
||||
continue
|
||||
key_public_bytes = private_key.public_key().public_bytes(
|
||||
serialization.Encoding.DER,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
for pem_block in certificate_blocks:
|
||||
candidate = x509.load_pem_x509_certificate(pem_block, default_backend())
|
||||
candidate_public_bytes = candidate.public_key().public_bytes(
|
||||
serialization.Encoding.DER,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
if candidate_public_bytes == key_public_bytes:
|
||||
# azure-identity's CertificateCredential uses the first BEGIN
|
||||
# CERTIFICATE block to compute the credential thumbprint. Put
|
||||
# the matching leaf first so authentication uses the correct
|
||||
# certificate regardless of the bundle's original ordering.
|
||||
return pem_block + b"\n" + key_match.group() + b"\n"
|
||||
|
||||
if first_key_error is not None:
|
||||
raise first_key_error
|
||||
raise ValueError("the certificate does not match the private key")
|
||||
@@ -11,6 +11,7 @@ class AzureIdentityInfo(BaseModel):
|
||||
identity_type: str = ""
|
||||
tenant_ids: list[str] = []
|
||||
tenant_domain: str = "Unknown tenant domain (missing AAD permissions)"
|
||||
certificate_thumbprint: str = ""
|
||||
subscriptions: dict = {}
|
||||
locations: dict = {}
|
||||
|
||||
|
||||
@@ -404,6 +404,8 @@ class Provider(ABC):
|
||||
sp_env_auth=arguments.sp_env_auth,
|
||||
browser_auth=arguments.browser_auth,
|
||||
managed_identity_auth=arguments.managed_identity_auth,
|
||||
certificate_auth=arguments.certificate_auth,
|
||||
certificate_path=arguments.certificate_path,
|
||||
tenant_id=arguments.tenant_id,
|
||||
region=arguments.azure_region,
|
||||
subscription_ids=arguments.subscription_id,
|
||||
|
||||
@@ -12,6 +12,7 @@ from prowler.providers.aws.lib.arguments.arguments import (
|
||||
validate_role_session_name,
|
||||
)
|
||||
from prowler.providers.azure.lib.arguments.arguments import validate_azure_region
|
||||
from prowler.providers.common.provider import Provider
|
||||
|
||||
prowler_command = "prowler"
|
||||
|
||||
@@ -154,6 +155,53 @@ class Test_Parser:
|
||||
assert not parsed.managed_identity_auth
|
||||
assert not parsed.shodan
|
||||
|
||||
def test_azure_certificate_auth_arguments(self):
|
||||
certificate_path = "/secure/path/prowler-cert.pem"
|
||||
|
||||
parsed = self.parser.parse(
|
||||
[
|
||||
prowler_command,
|
||||
"azure",
|
||||
"--certificate-auth",
|
||||
"--certificate-path",
|
||||
certificate_path,
|
||||
]
|
||||
)
|
||||
|
||||
assert parsed.certificate_auth
|
||||
assert parsed.certificate_path == certificate_path
|
||||
|
||||
def test_azure_certificate_auth_arguments_are_forwarded(self):
|
||||
certificate_path = "/secure/path/prowler-cert.pem"
|
||||
parsed = self.parser.parse(
|
||||
[
|
||||
prowler_command,
|
||||
"azure",
|
||||
"--certificate-auth",
|
||||
"--certificate-path",
|
||||
certificate_path,
|
||||
]
|
||||
)
|
||||
captured = {}
|
||||
|
||||
class AzureProviderStub:
|
||||
def __init__(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
|
||||
with (
|
||||
patch.object(Provider, "_global", None),
|
||||
patch.object(Provider, "get_class", return_value=AzureProviderStub),
|
||||
patch.object(Provider, "is_builtin", return_value=True),
|
||||
patch(
|
||||
"prowler.providers.common.provider.load_and_validate_config_file",
|
||||
return_value={},
|
||||
),
|
||||
):
|
||||
Provider.init_global_provider(parsed)
|
||||
|
||||
assert captured["certificate_auth"] is True
|
||||
assert captured["certificate_path"] == certificate_path
|
||||
|
||||
def test_default_parser_no_arguments_gcp(self):
|
||||
provider = "gcp"
|
||||
command = [prowler_command, provider]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user