mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-03-22 03:08:23 +00:00
feat(ui): add Cloudflare provider support (#9910)
Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com> Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
committed by
GitHub
parent
9f6121bc05
commit
bb5a4371bd
@@ -49,6 +49,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- Update Azure Key Vault service metadata to new format [(#9621)](https://github.com/prowler-cloud/prowler/pull/9621)
|
||||
- Update Azure Entra ID service metadata to new format [(#9619)](https://github.com/prowler-cloud/prowler/pull/9619)
|
||||
- Update Azure Virtual Machines service metadata to new format [(#9629)](https://github.com/prowler-cloud/prowler/pull/9629)
|
||||
- Cloudflare provider credential validation with specific exceptions [(#9910)](https://github.com/prowler-cloud/prowler/pull/9910)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
|
||||
@@ -3,6 +3,14 @@ import os
|
||||
from typing import Iterable
|
||||
|
||||
from cloudflare import Cloudflare
|
||||
from cloudflare._exceptions import (
|
||||
AuthenticationError as CloudflareSDKAuthenticationError,
|
||||
)
|
||||
from cloudflare._exceptions import (
|
||||
BadRequestError,
|
||||
PermissionDeniedError,
|
||||
RateLimitError,
|
||||
)
|
||||
from colorama import Fore, Style
|
||||
|
||||
from prowler.config.config import (
|
||||
@@ -13,10 +21,16 @@ from prowler.config.config import (
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.lib.utils.utils import print_boxes
|
||||
from prowler.providers.cloudflare.exceptions.exceptions import (
|
||||
CloudflareAuthenticationError,
|
||||
CloudflareCredentialsError,
|
||||
CloudflareIdentityError,
|
||||
CloudflareInvalidAccountError,
|
||||
CloudflareInvalidAPIKeyError,
|
||||
CloudflareInvalidAPITokenError,
|
||||
CloudflareNoAccountsError,
|
||||
CloudflareRateLimitError,
|
||||
CloudflareSessionError,
|
||||
CloudflareUserTokenRequiredError,
|
||||
)
|
||||
from prowler.providers.cloudflare.lib.mutelist.mutelist import CloudflareMutelist
|
||||
from prowler.providers.cloudflare.models import (
|
||||
@@ -164,6 +178,13 @@ class CloudflareProvider(Provider):
|
||||
api_token: Cloudflare API token (optional, falls back to env var).
|
||||
api_key: Cloudflare API key (optional, falls back to env var).
|
||||
api_email: Cloudflare API email (optional, falls back to env var).
|
||||
|
||||
Returns:
|
||||
CloudflareSession: The initialized Cloudflare session.
|
||||
|
||||
Raises:
|
||||
CloudflareCredentialsError: If no credentials are provided.
|
||||
CloudflareSessionError: If session setup fails.
|
||||
"""
|
||||
# Use provided credentials or fall back to environment variables
|
||||
token = api_token or os.environ.get("CLOUDFLARE_API_TOKEN", "")
|
||||
@@ -172,10 +193,10 @@ class CloudflareProvider(Provider):
|
||||
|
||||
# Warn if both auth methods are set, use API Token (recommended)
|
||||
if token and key and email:
|
||||
logger.error(
|
||||
logger.warning(
|
||||
"Both API Token and API Key + Email credentials are set. "
|
||||
"Using API Token (recommended). "
|
||||
"To avoid this error, unset CLOUDFLARE_API_KEY and CLOUDFLARE_API_EMAIL, or CLOUDFLARE_API_TOKEN."
|
||||
"To avoid this warning, unset CLOUDFLARE_API_KEY and CLOUDFLARE_API_EMAIL, or CLOUDFLARE_API_TOKEN."
|
||||
)
|
||||
|
||||
# The Cloudflare SDK reads credentials from environment variables automatically.
|
||||
@@ -194,7 +215,7 @@ class CloudflareProvider(Provider):
|
||||
else:
|
||||
raise CloudflareCredentialsError(
|
||||
file=os.path.basename(__file__),
|
||||
message="Cloudflare credentials not found. Set CLOUDFLARE_API_TOKEN or both CLOUDFLARE_API_KEY and CLOUDFLARE_API_EMAIL environment variables.",
|
||||
message="Cloudflare credentials not found. Set CLOUDFLARE_API_TOKEN or both CLOUDFLARE_API_KEY and CLOUDFLARE_API_EMAIL.",
|
||||
)
|
||||
|
||||
return CloudflareSession(
|
||||
@@ -203,6 +224,8 @@ class CloudflareProvider(Provider):
|
||||
api_key=key or None,
|
||||
api_email=email or None,
|
||||
)
|
||||
except CloudflareCredentialsError:
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.critical(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
|
||||
@@ -222,7 +245,17 @@ class CloudflareProvider(Provider):
|
||||
|
||||
@staticmethod
|
||||
def setup_identity(session: CloudflareSession) -> CloudflareIdentityInfo:
|
||||
"""Fetch user and account metadata for Cloudflare."""
|
||||
"""Fetch user and account metadata for Cloudflare.
|
||||
|
||||
Args:
|
||||
session: The Cloudflare session.
|
||||
|
||||
Returns:
|
||||
CloudflareIdentityInfo: The identity information.
|
||||
|
||||
Raises:
|
||||
CloudflareIdentityError: If identity setup fails.
|
||||
"""
|
||||
try:
|
||||
client = session.client
|
||||
user_id = None
|
||||
@@ -273,6 +306,171 @@ class CloudflareProvider(Provider):
|
||||
original_exception=error,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def validate_credentials(session: CloudflareSession) -> None:
|
||||
"""Validate Cloudflare credentials by making API calls.
|
||||
|
||||
This method validates the credentials by attempting to retrieve user info
|
||||
and falling back to listing accounts if user.get() fails.
|
||||
|
||||
Args:
|
||||
session: The Cloudflare session to validate.
|
||||
|
||||
Raises:
|
||||
CloudflareUserTokenRequiredError: If the token requires user-level auth.
|
||||
CloudflareInvalidAPITokenError: If the API token format is invalid.
|
||||
CloudflareInvalidAPIKeyError: If the API key or email is invalid.
|
||||
CloudflareNoAccountsError: If no accounts are accessible.
|
||||
CloudflareRateLimitError: If rate limited by Cloudflare API.
|
||||
CloudflareAuthenticationError: For other authentication errors.
|
||||
"""
|
||||
client = session.client
|
||||
|
||||
try:
|
||||
# Try user.get() first - this validates the token quickly
|
||||
client.user.get()
|
||||
return
|
||||
except PermissionDeniedError as error:
|
||||
error_str = str(error)
|
||||
# Check for user-level authentication required (code 9109)
|
||||
if "9109" in error_str:
|
||||
logger.error(f"CloudflareUserTokenRequiredError: {error}")
|
||||
raise CloudflareUserTokenRequiredError(
|
||||
file=os.path.basename(__file__),
|
||||
)
|
||||
# Check for invalid API key or email (code 9103) - comes as 403
|
||||
if "9103" in error_str or "Unknown X-Auth-Key" in error_str:
|
||||
logger.error(f"CloudflareInvalidAPIKeyError: {error}")
|
||||
raise CloudflareInvalidAPIKeyError(
|
||||
file=os.path.basename(__file__),
|
||||
)
|
||||
# For other permission errors, try accounts.list() as fallback
|
||||
logger.warning(
|
||||
f"Unable to retrieve Cloudflare user info: {error}. "
|
||||
"Trying accounts.list() as fallback."
|
||||
)
|
||||
except BadRequestError as error:
|
||||
error_str = str(error)
|
||||
# Invalid credentials format (code 6003/6111)
|
||||
# Differentiate based on which auth method was used
|
||||
if "6003" in error_str or "6111" in error_str:
|
||||
if session.api_key and session.api_email:
|
||||
# User is using API Key + Email
|
||||
logger.error(f"CloudflareInvalidAPIKeyError: {error}")
|
||||
raise CloudflareInvalidAPIKeyError(
|
||||
file=os.path.basename(__file__),
|
||||
)
|
||||
else:
|
||||
# User is using API Token
|
||||
logger.error(f"CloudflareInvalidAPITokenError: {error}")
|
||||
raise CloudflareInvalidAPITokenError(
|
||||
file=os.path.basename(__file__),
|
||||
)
|
||||
# Invalid API key or email (explicit message)
|
||||
if "Unknown X-Auth-Key" in error_str or "X-Auth-Email" in error_str:
|
||||
logger.error(f"CloudflareInvalidAPIKeyError: {error}")
|
||||
raise CloudflareInvalidAPIKeyError(
|
||||
file=os.path.basename(__file__),
|
||||
)
|
||||
# Re-raise for other bad request errors
|
||||
raise CloudflareAuthenticationError(
|
||||
file=os.path.basename(__file__),
|
||||
original_exception=error,
|
||||
)
|
||||
except CloudflareSDKAuthenticationError as error:
|
||||
logger.error(f"CloudflareAuthenticationError: {error}")
|
||||
raise CloudflareAuthenticationError(
|
||||
file=os.path.basename(__file__),
|
||||
original_exception=error,
|
||||
)
|
||||
except RateLimitError as error:
|
||||
logger.error(f"CloudflareRateLimitError: {error}")
|
||||
raise CloudflareRateLimitError(
|
||||
file=os.path.basename(__file__),
|
||||
original_exception=error,
|
||||
)
|
||||
except Exception as error:
|
||||
# For unexpected errors during user.get(), try fallback
|
||||
logger.warning(
|
||||
f"Unable to retrieve Cloudflare user info: {error}. "
|
||||
"Trying accounts.list() as fallback."
|
||||
)
|
||||
|
||||
# Fallback: try accounts.list()
|
||||
try:
|
||||
accounts = list(client.accounts.list())
|
||||
if not accounts:
|
||||
logger.error("CloudflareNoAccountsError: No accounts found")
|
||||
raise CloudflareNoAccountsError(
|
||||
file=os.path.basename(__file__),
|
||||
)
|
||||
except PermissionDeniedError as error:
|
||||
error_str = str(error)
|
||||
if "9109" in error_str:
|
||||
logger.error(f"CloudflareUserTokenRequiredError: {error}")
|
||||
raise CloudflareUserTokenRequiredError(
|
||||
file=os.path.basename(__file__),
|
||||
)
|
||||
# Check for invalid API key or email (code 9103) - comes as 403
|
||||
if "9103" in error_str or "Unknown X-Auth-Key" in error_str:
|
||||
logger.error(f"CloudflareInvalidAPIKeyError: {error}")
|
||||
raise CloudflareInvalidAPIKeyError(
|
||||
file=os.path.basename(__file__),
|
||||
)
|
||||
logger.error(f"CloudflareAuthenticationError: {error}")
|
||||
raise CloudflareAuthenticationError(
|
||||
file=os.path.basename(__file__),
|
||||
original_exception=error,
|
||||
)
|
||||
except BadRequestError as error:
|
||||
error_str = str(error)
|
||||
# Invalid credentials format (code 6003/6111)
|
||||
if "6003" in error_str or "6111" in error_str:
|
||||
if session.api_key and session.api_email:
|
||||
logger.error(f"CloudflareInvalidAPIKeyError: {error}")
|
||||
raise CloudflareInvalidAPIKeyError(
|
||||
file=os.path.basename(__file__),
|
||||
)
|
||||
else:
|
||||
logger.error(f"CloudflareInvalidAPITokenError: {error}")
|
||||
raise CloudflareInvalidAPITokenError(
|
||||
file=os.path.basename(__file__),
|
||||
)
|
||||
if "Unknown X-Auth-Key" in error_str or "X-Auth-Email" in error_str:
|
||||
logger.error(f"CloudflareInvalidAPIKeyError: {error}")
|
||||
raise CloudflareInvalidAPIKeyError(
|
||||
file=os.path.basename(__file__),
|
||||
)
|
||||
raise CloudflareAuthenticationError(
|
||||
file=os.path.basename(__file__),
|
||||
original_exception=error,
|
||||
)
|
||||
except CloudflareSDKAuthenticationError as error:
|
||||
logger.error(f"CloudflareAuthenticationError: {error}")
|
||||
raise CloudflareAuthenticationError(
|
||||
file=os.path.basename(__file__),
|
||||
original_exception=error,
|
||||
)
|
||||
except RateLimitError as error:
|
||||
logger.error(f"CloudflareRateLimitError: {error}")
|
||||
raise CloudflareRateLimitError(
|
||||
file=os.path.basename(__file__),
|
||||
original_exception=error,
|
||||
)
|
||||
except (
|
||||
CloudflareNoAccountsError,
|
||||
CloudflareUserTokenRequiredError,
|
||||
CloudflareInvalidAPITokenError,
|
||||
CloudflareInvalidAPIKeyError,
|
||||
):
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.error(f"CloudflareAuthenticationError: {error}")
|
||||
raise CloudflareAuthenticationError(
|
||||
file=os.path.basename(__file__),
|
||||
original_exception=error,
|
||||
)
|
||||
|
||||
def print_credentials(self) -> None:
|
||||
report_title = (
|
||||
f"{Style.BRIGHT}Using the Cloudflare credentials below:{Style.RESET_ALL}"
|
||||
@@ -335,16 +533,33 @@ class CloudflareProvider(Provider):
|
||||
|
||||
Returns:
|
||||
Connection: Connection object with is_connected status.
|
||||
|
||||
Raises:
|
||||
CloudflareCredentialsError: If no credentials are provided.
|
||||
CloudflareSessionError: If session setup fails.
|
||||
CloudflareUserTokenRequiredError: If the token requires user-level auth.
|
||||
CloudflareInvalidAPITokenError: If the API token format is invalid.
|
||||
CloudflareInvalidAPIKeyError: If the API key or email is invalid.
|
||||
CloudflareNoAccountsError: If no accounts are accessible.
|
||||
CloudflareRateLimitError: If rate limited by Cloudflare API.
|
||||
CloudflareAuthenticationError: For other authentication errors.
|
||||
"""
|
||||
try:
|
||||
# Use max_retries=0 for connection test to get immediate feedback
|
||||
# on invalid credentials without waiting for retry attempts
|
||||
session = CloudflareProvider.setup_session(
|
||||
api_token=api_token,
|
||||
api_key=api_key,
|
||||
api_email=api_email,
|
||||
max_retries=0,
|
||||
)
|
||||
_ = session.client.user.get()
|
||||
|
||||
# Validate credentials
|
||||
CloudflareProvider.validate_credentials(session)
|
||||
|
||||
return Connection(is_connected=True)
|
||||
except Exception as error:
|
||||
|
||||
except CloudflareCredentialsError as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
@@ -352,5 +567,73 @@ class CloudflareProvider(Provider):
|
||||
raise error
|
||||
return Connection(is_connected=False, error=error)
|
||||
|
||||
except CloudflareSessionError as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
if raise_on_exception:
|
||||
raise error
|
||||
return Connection(is_connected=False, error=error)
|
||||
|
||||
except CloudflareUserTokenRequiredError as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
if raise_on_exception:
|
||||
raise error
|
||||
return Connection(is_connected=False, error=error)
|
||||
|
||||
except CloudflareInvalidAPITokenError as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
if raise_on_exception:
|
||||
raise error
|
||||
return Connection(is_connected=False, error=error)
|
||||
|
||||
except CloudflareInvalidAPIKeyError as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
if raise_on_exception:
|
||||
raise error
|
||||
return Connection(is_connected=False, error=error)
|
||||
|
||||
except CloudflareNoAccountsError as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
if raise_on_exception:
|
||||
raise error
|
||||
return Connection(is_connected=False, error=error)
|
||||
|
||||
except CloudflareRateLimitError as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
if raise_on_exception:
|
||||
raise error
|
||||
return Connection(is_connected=False, error=error)
|
||||
|
||||
except CloudflareAuthenticationError as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
if raise_on_exception:
|
||||
raise error
|
||||
return Connection(is_connected=False, error=error)
|
||||
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
formatted_error = CloudflareAuthenticationError(
|
||||
file=os.path.basename(__file__),
|
||||
original_exception=error,
|
||||
)
|
||||
if raise_on_exception:
|
||||
raise formatted_error
|
||||
return Connection(is_connected=False, error=formatted_error)
|
||||
|
||||
def validate_arguments(self) -> None:
|
||||
return None
|
||||
|
||||
@@ -34,16 +34,38 @@ class CloudflareBaseException(ProwlerException):
|
||||
"message": "Cloudflare API call failed",
|
||||
"remediation": "Inspect the API response details and permissions for the failing request.",
|
||||
},
|
||||
(9007, "CloudflareCredentialsConflictError"): {
|
||||
"message": "Conflicting Cloudflare credentials provided",
|
||||
"remediation": "Use either API Token or API Key + Email, not both. Unset CLOUDFLARE_API_TOKEN or unset both CLOUDFLARE_API_KEY and CLOUDFLARE_API_EMAIL.",
|
||||
(9007, "CloudflareNoAccountsError"): {
|
||||
"message": "No Cloudflare accounts found",
|
||||
"remediation": "Verify your API token has the required permissions to list accounts.",
|
||||
},
|
||||
(9008, "CloudflareUserTokenRequiredError"): {
|
||||
"message": "User-level API token required",
|
||||
"remediation": "Create a User API Token under My Profile (not an Account-owned token), or use API Key + Email authentication.",
|
||||
},
|
||||
(9009, "CloudflareInvalidAPIKeyError"): {
|
||||
"message": "Invalid API Key or Email",
|
||||
"remediation": "Verify your API Key and Email are correct. The API Key can be found in your Cloudflare profile.",
|
||||
},
|
||||
(9010, "CloudflareInvalidAPITokenError"): {
|
||||
"message": "Invalid API Token format",
|
||||
"remediation": "Check that your API Token is correctly formatted. Tokens should be alphanumeric strings.",
|
||||
},
|
||||
(9011, "CloudflareRateLimitError"): {
|
||||
"message": "Cloudflare API rate limit exceeded",
|
||||
"remediation": "Wait before retrying. Consider reducing the frequency of API calls.",
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, code, file=None, original_exception=None, message=None):
|
||||
provider = "Cloudflare"
|
||||
error_info = self.CLOUDFLARE_ERROR_CODES.get((code, self.__class__.__name__))
|
||||
if message:
|
||||
if error_info is None:
|
||||
error_info = {
|
||||
"message": message or "Unknown Cloudflare error",
|
||||
"remediation": "Check the Cloudflare API documentation for more details.",
|
||||
}
|
||||
elif message:
|
||||
error_info = error_info.copy()
|
||||
error_info["message"] = message
|
||||
super().__init__(
|
||||
code=code,
|
||||
@@ -117,10 +139,46 @@ class CloudflareAPIError(CloudflareBaseException):
|
||||
)
|
||||
|
||||
|
||||
class CloudflareCredentialsConflictError(CloudflareBaseException):
|
||||
"""Exception for conflicting Cloudflare credentials."""
|
||||
class CloudflareNoAccountsError(CloudflareBaseException):
|
||||
"""Exception for no Cloudflare accounts found."""
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
9007, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class CloudflareUserTokenRequiredError(CloudflareBaseException):
|
||||
"""Exception for missing user-level Cloudflare authentication."""
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
9008, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class CloudflareInvalidAPIKeyError(CloudflareBaseException):
|
||||
"""Exception for invalid Cloudflare API Key or Email."""
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
9009, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class CloudflareInvalidAPITokenError(CloudflareBaseException):
|
||||
"""Exception for invalid Cloudflare API Token format."""
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
9010, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class CloudflareRateLimitError(CloudflareBaseException):
|
||||
"""Exception for Cloudflare API rate limit exceeded."""
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
9011, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
@@ -6,6 +6,10 @@ from prowler.providers.cloudflare.cloudflare_provider import CloudflareProvider
|
||||
from prowler.providers.cloudflare.exceptions.exceptions import (
|
||||
CloudflareCredentialsError,
|
||||
CloudflareInvalidAccountError,
|
||||
CloudflareInvalidAPIKeyError,
|
||||
CloudflareInvalidAPITokenError,
|
||||
CloudflareNoAccountsError,
|
||||
CloudflareUserTokenRequiredError,
|
||||
)
|
||||
from prowler.providers.cloudflare.models import (
|
||||
CloudflareAccount,
|
||||
@@ -95,69 +99,47 @@ class TestCloudflareProvider:
|
||||
assert provider.session.api_email == API_EMAIL
|
||||
|
||||
def test_cloudflare_provider_test_connection_success(self):
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.cloudflare.cloudflare_provider.CloudflareProvider.setup_session",
|
||||
return_value=CloudflareSession(
|
||||
client=MagicMock(),
|
||||
api_token=API_TOKEN,
|
||||
api_key=None,
|
||||
api_email=None,
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.cloudflare.cloudflare_provider.CloudflareProvider.setup_identity",
|
||||
return_value=CloudflareIdentityInfo(
|
||||
user_id=USER_ID,
|
||||
email=USER_EMAIL,
|
||||
accounts=[
|
||||
CloudflareAccount(
|
||||
id=ACCOUNT_ID,
|
||||
name=ACCOUNT_NAME,
|
||||
type="standard",
|
||||
)
|
||||
],
|
||||
audited_accounts=[ACCOUNT_ID],
|
||||
),
|
||||
mock_client = MagicMock()
|
||||
# Simulate successful user.get() call
|
||||
mock_client.user.get.return_value = MagicMock(id=USER_ID, email=USER_EMAIL)
|
||||
|
||||
with patch(
|
||||
"prowler.providers.cloudflare.cloudflare_provider.CloudflareProvider.setup_session",
|
||||
return_value=CloudflareSession(
|
||||
client=mock_client,
|
||||
api_token=API_TOKEN,
|
||||
api_key=None,
|
||||
api_email=None,
|
||||
),
|
||||
):
|
||||
provider = CloudflareProvider()
|
||||
connection = provider.test_connection()
|
||||
connection = CloudflareProvider.test_connection(api_token=API_TOKEN)
|
||||
|
||||
assert isinstance(connection, Connection)
|
||||
assert connection.is_connected is True
|
||||
assert connection.error is None
|
||||
|
||||
def test_cloudflare_provider_test_connection_failure(self):
|
||||
def test_cloudflare_provider_test_connection_failure_no_accounts(self):
|
||||
mock_client = MagicMock()
|
||||
mock_client.user.get.side_effect = Exception("Connection failed")
|
||||
mock_client.accounts.list.return_value = iter([]) # Empty accounts list
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.cloudflare.cloudflare_provider.CloudflareProvider.setup_session",
|
||||
return_value=CloudflareSession(
|
||||
client=mock_client,
|
||||
api_token=API_TOKEN,
|
||||
api_key=None,
|
||||
api_email=None,
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.cloudflare.cloudflare_provider.CloudflareProvider.setup_identity",
|
||||
return_value=CloudflareIdentityInfo(
|
||||
user_id=USER_ID,
|
||||
email=USER_EMAIL,
|
||||
accounts=[],
|
||||
audited_accounts=[],
|
||||
),
|
||||
with patch(
|
||||
"prowler.providers.cloudflare.cloudflare_provider.CloudflareProvider.setup_session",
|
||||
return_value=CloudflareSession(
|
||||
client=mock_client,
|
||||
api_token=API_TOKEN,
|
||||
api_key=None,
|
||||
api_email=None,
|
||||
),
|
||||
):
|
||||
provider = CloudflareProvider()
|
||||
connection = provider.test_connection()
|
||||
connection = CloudflareProvider.test_connection(
|
||||
api_token=API_TOKEN, raise_on_exception=False
|
||||
)
|
||||
|
||||
assert isinstance(connection, Connection)
|
||||
assert connection.is_connected is False
|
||||
assert connection.error is not None
|
||||
assert isinstance(connection.error, CloudflareNoAccountsError)
|
||||
|
||||
def test_cloudflare_provider_no_credentials_raises_error(self):
|
||||
with patch(
|
||||
@@ -305,3 +287,262 @@ class TestCloudflareProvider:
|
||||
assert provider.audit_config is not None
|
||||
assert provider.fixer_config is not None
|
||||
assert provider.mutelist is not None
|
||||
|
||||
|
||||
class TestCloudflareValidateCredentials:
|
||||
"""Tests for validate_credentials method."""
|
||||
|
||||
def test_validate_credentials_success(self):
|
||||
"""Test successful credential validation."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.user.get.return_value = MagicMock(id=USER_ID, email=USER_EMAIL)
|
||||
|
||||
session = CloudflareSession(
|
||||
client=mock_client,
|
||||
api_token=API_TOKEN,
|
||||
api_key=None,
|
||||
api_email=None,
|
||||
)
|
||||
|
||||
# Should not raise any exception
|
||||
CloudflareProvider.validate_credentials(session)
|
||||
mock_client.user.get.assert_called_once()
|
||||
|
||||
def test_validate_credentials_user_token_required(self):
|
||||
"""Test that user token required error is raised for Account tokens."""
|
||||
mock_client = MagicMock()
|
||||
# Simulate error code 9109 - user-level authentication required
|
||||
from cloudflare._exceptions import PermissionDeniedError
|
||||
|
||||
mock_client.user.get.side_effect = PermissionDeniedError(
|
||||
"Error code: 403 - {'errors': [{'code': 9109, 'message': 'Valid user-level authentication not found'}]}",
|
||||
response=MagicMock(status_code=403),
|
||||
body=None,
|
||||
)
|
||||
|
||||
session = CloudflareSession(
|
||||
client=mock_client,
|
||||
api_token=API_TOKEN,
|
||||
api_key=None,
|
||||
api_email=None,
|
||||
)
|
||||
|
||||
with pytest.raises(CloudflareUserTokenRequiredError):
|
||||
CloudflareProvider.validate_credentials(session)
|
||||
|
||||
def test_validate_credentials_invalid_api_token(self):
|
||||
"""Test that invalid API token error is raised."""
|
||||
mock_client = MagicMock()
|
||||
from cloudflare._exceptions import BadRequestError
|
||||
|
||||
mock_client.user.get.side_effect = BadRequestError(
|
||||
"Error code: 400 - {'errors': [{'code': 6003, 'message': 'Invalid request headers', 'error_chain': [{'code': 6111}]}]}",
|
||||
response=MagicMock(status_code=400),
|
||||
body=None,
|
||||
)
|
||||
|
||||
session = CloudflareSession(
|
||||
client=mock_client,
|
||||
api_token="invalid_token",
|
||||
api_key=None,
|
||||
api_email=None,
|
||||
)
|
||||
|
||||
with pytest.raises(CloudflareInvalidAPITokenError):
|
||||
CloudflareProvider.validate_credentials(session)
|
||||
|
||||
def test_validate_credentials_invalid_api_key(self):
|
||||
"""Test that invalid API key error is raised (403 with code 9103)."""
|
||||
mock_client = MagicMock()
|
||||
from cloudflare._exceptions import PermissionDeniedError
|
||||
|
||||
# Real error: 403 with code 9103 "Unknown X-Auth-Key or X-Auth-Email"
|
||||
mock_client.user.get.side_effect = PermissionDeniedError(
|
||||
"Error code: 403 - {'success': False, 'errors': [{'code': 9103, 'message': 'Unknown X-Auth-Key or X-Auth-Email'}]}",
|
||||
response=MagicMock(status_code=403),
|
||||
body=None,
|
||||
)
|
||||
|
||||
session = CloudflareSession(
|
||||
client=mock_client,
|
||||
api_token=None,
|
||||
api_key="invalid_key",
|
||||
api_email="invalid@email.com",
|
||||
)
|
||||
|
||||
with pytest.raises(CloudflareInvalidAPIKeyError):
|
||||
CloudflareProvider.validate_credentials(session)
|
||||
|
||||
def test_validate_credentials_invalid_api_key_bad_request(self):
|
||||
"""Test that invalid API key error is raised when using API Key + Email with 6003 error."""
|
||||
mock_client = MagicMock()
|
||||
from cloudflare._exceptions import BadRequestError
|
||||
|
||||
# Same error code as token but using API Key + Email auth
|
||||
mock_client.user.get.side_effect = BadRequestError(
|
||||
"Error code: 400 - {'errors': [{'code': 6003, 'message': 'Invalid request headers'}]}",
|
||||
response=MagicMock(status_code=400),
|
||||
body=None,
|
||||
)
|
||||
|
||||
session = CloudflareSession(
|
||||
client=mock_client,
|
||||
api_token=None,
|
||||
api_key="invalid_key",
|
||||
api_email="invalid@email.com",
|
||||
)
|
||||
|
||||
# Should raise CloudflareInvalidAPIKeyError, NOT CloudflareInvalidAPITokenError
|
||||
with pytest.raises(CloudflareInvalidAPIKeyError):
|
||||
CloudflareProvider.validate_credentials(session)
|
||||
|
||||
def test_validate_credentials_fallback_to_accounts_list(self):
|
||||
"""Test fallback to accounts.list() when user.get() fails with non-auth error."""
|
||||
mock_client = MagicMock()
|
||||
# Simulate a non-auth error on user.get()
|
||||
mock_client.user.get.side_effect = Exception("Some other error")
|
||||
# accounts.list() returns valid accounts
|
||||
mock_account = MagicMock()
|
||||
mock_account.id = ACCOUNT_ID
|
||||
mock_client.accounts.list.return_value = iter([mock_account])
|
||||
|
||||
session = CloudflareSession(
|
||||
client=mock_client,
|
||||
api_token=API_TOKEN,
|
||||
api_key=None,
|
||||
api_email=None,
|
||||
)
|
||||
|
||||
# Should not raise - fallback succeeded
|
||||
CloudflareProvider.validate_credentials(session)
|
||||
mock_client.accounts.list.assert_called_once()
|
||||
|
||||
def test_validate_credentials_no_accounts(self):
|
||||
"""Test that no accounts error is raised when accounts.list() is empty."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.user.get.side_effect = Exception("Some error")
|
||||
mock_client.accounts.list.return_value = iter([]) # Empty
|
||||
|
||||
session = CloudflareSession(
|
||||
client=mock_client,
|
||||
api_token=API_TOKEN,
|
||||
api_key=None,
|
||||
api_email=None,
|
||||
)
|
||||
|
||||
with pytest.raises(CloudflareNoAccountsError):
|
||||
CloudflareProvider.validate_credentials(session)
|
||||
|
||||
|
||||
class TestCloudflareTestConnection:
|
||||
"""Tests for test_connection method."""
|
||||
|
||||
def test_test_connection_returns_prowler_exception(self):
|
||||
"""Test that test_connection returns Prowler exceptions, not raw SDK errors."""
|
||||
mock_client = MagicMock()
|
||||
from cloudflare._exceptions import BadRequestError
|
||||
|
||||
mock_client.user.get.side_effect = BadRequestError(
|
||||
"Error code: 400 - {'errors': [{'code': 6003}]}",
|
||||
response=MagicMock(status_code=400),
|
||||
body=None,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"prowler.providers.cloudflare.cloudflare_provider.CloudflareProvider.setup_session",
|
||||
return_value=CloudflareSession(
|
||||
client=mock_client,
|
||||
api_token=API_TOKEN,
|
||||
api_key=None,
|
||||
api_email=None,
|
||||
),
|
||||
):
|
||||
connection = CloudflareProvider.test_connection(
|
||||
api_token=API_TOKEN, raise_on_exception=False
|
||||
)
|
||||
|
||||
assert connection.is_connected is False
|
||||
assert isinstance(connection.error, CloudflareInvalidAPITokenError)
|
||||
|
||||
def test_test_connection_user_token_required(self):
|
||||
"""Test that user token required error is properly returned."""
|
||||
mock_client = MagicMock()
|
||||
from cloudflare._exceptions import PermissionDeniedError
|
||||
|
||||
mock_client.user.get.side_effect = PermissionDeniedError(
|
||||
"Error code: 403 - {'errors': [{'code': 9109}]}",
|
||||
response=MagicMock(status_code=403),
|
||||
body=None,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"prowler.providers.cloudflare.cloudflare_provider.CloudflareProvider.setup_session",
|
||||
return_value=CloudflareSession(
|
||||
client=mock_client,
|
||||
api_token=API_TOKEN,
|
||||
api_key=None,
|
||||
api_email=None,
|
||||
),
|
||||
):
|
||||
connection = CloudflareProvider.test_connection(
|
||||
api_token=API_TOKEN, raise_on_exception=False
|
||||
)
|
||||
|
||||
assert connection.is_connected is False
|
||||
assert isinstance(connection.error, CloudflareUserTokenRequiredError)
|
||||
# Verify the error message is user-friendly
|
||||
assert "User-level API token required" in str(connection.error)
|
||||
|
||||
def test_test_connection_invalid_api_key(self):
|
||||
"""Test that invalid API key error is properly returned."""
|
||||
mock_client = MagicMock()
|
||||
from cloudflare._exceptions import BadRequestError
|
||||
|
||||
mock_client.user.get.side_effect = BadRequestError(
|
||||
"Unknown X-Auth-Key or X-Auth-Email",
|
||||
response=MagicMock(status_code=400),
|
||||
body=None,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"prowler.providers.cloudflare.cloudflare_provider.CloudflareProvider.setup_session",
|
||||
return_value=CloudflareSession(
|
||||
client=mock_client,
|
||||
api_token=None,
|
||||
api_key=API_KEY,
|
||||
api_email=API_EMAIL,
|
||||
),
|
||||
):
|
||||
connection = CloudflareProvider.test_connection(
|
||||
api_key=API_KEY, api_email=API_EMAIL, raise_on_exception=False
|
||||
)
|
||||
|
||||
assert connection.is_connected is False
|
||||
assert isinstance(connection.error, CloudflareInvalidAPIKeyError)
|
||||
# Verify the error message is user-friendly
|
||||
assert "Invalid API Key or Email" in str(connection.error)
|
||||
|
||||
def test_test_connection_raises_when_requested(self):
|
||||
"""Test that exceptions are raised when raise_on_exception=True."""
|
||||
mock_client = MagicMock()
|
||||
from cloudflare._exceptions import BadRequestError
|
||||
|
||||
mock_client.user.get.side_effect = BadRequestError(
|
||||
"Error code: 400 - {'errors': [{'code': 6003}]}",
|
||||
response=MagicMock(status_code=400),
|
||||
body=None,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"prowler.providers.cloudflare.cloudflare_provider.CloudflareProvider.setup_session",
|
||||
return_value=CloudflareSession(
|
||||
client=mock_client,
|
||||
api_token=API_TOKEN,
|
||||
api_key=None,
|
||||
api_email=None,
|
||||
),
|
||||
):
|
||||
with pytest.raises(CloudflareInvalidAPITokenError):
|
||||
CloudflareProvider.test_connection(
|
||||
api_token=API_TOKEN, raise_on_exception=True
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
- OpenStack provider support in the UI [(#10046)](https://github.com/prowler-cloud/prowler/pull/10046)
|
||||
- PDF report available for the CSA CCM compliance framework [(#10088)](https://github.com/prowler-cloud/prowler/pull/10088)
|
||||
- Cloudflare provider support [(#9910)](https://github.com/prowler-cloud/prowler/pull/9910)
|
||||
- CSV and PDF download buttons in compliance views [(#10093)](https://github.com/prowler-cloud/prowler/pull/10093)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
AlibabaCloudProviderBadge,
|
||||
AWSProviderBadge,
|
||||
AzureProviderBadge,
|
||||
CloudflareProviderBadge,
|
||||
GCPProviderBadge,
|
||||
GitHubProviderBadge,
|
||||
IacProviderBadge,
|
||||
@@ -37,6 +38,7 @@ const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
|
||||
oraclecloud: <OracleCloudProviderBadge width={18} height={18} />,
|
||||
mongodbatlas: <MongoDBAtlasProviderBadge width={18} height={18} />,
|
||||
alibabacloud: <AlibabaCloudProviderBadge width={18} height={18} />,
|
||||
cloudflare: <CloudflareProviderBadge width={18} height={18} />,
|
||||
openstack: <OpenStackProviderBadge width={18} height={18} />,
|
||||
};
|
||||
|
||||
|
||||
@@ -63,6 +63,11 @@ const AlibabaCloudProviderBadge = lazy(() =>
|
||||
default: m.AlibabaCloudProviderBadge,
|
||||
})),
|
||||
);
|
||||
const CloudflareProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.CloudflareProviderBadge,
|
||||
})),
|
||||
);
|
||||
const OpenStackProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.OpenStackProviderBadge,
|
||||
@@ -119,6 +124,10 @@ const PROVIDER_DATA: Record<
|
||||
label: "Alibaba Cloud",
|
||||
icon: AlibabaCloudProviderBadge,
|
||||
},
|
||||
cloudflare: {
|
||||
label: "Cloudflare",
|
||||
icon: CloudflareProviderBadge,
|
||||
},
|
||||
openstack: {
|
||||
label: "OpenStack",
|
||||
icon: OpenStackProviderBadge,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "@/components/providers/workflow/forms";
|
||||
import { SelectViaAlibabaCloud } from "@/components/providers/workflow/forms/select-credentials-type/alibabacloud";
|
||||
import { SelectViaAWS } from "@/components/providers/workflow/forms/select-credentials-type/aws";
|
||||
import { SelectViaCloudflare } from "@/components/providers/workflow/forms/select-credentials-type/cloudflare";
|
||||
import {
|
||||
AddViaServiceAccountForm,
|
||||
SelectViaGCP,
|
||||
@@ -43,6 +44,8 @@ export default async function AddCredentialsPage({ searchParams }: Props) {
|
||||
if (providerType === "m365") return <SelectViaM365 initialVia={via} />;
|
||||
if (providerType === "alibabacloud")
|
||||
return <SelectViaAlibabaCloud initialVia={via} />;
|
||||
if (providerType === "cloudflare")
|
||||
return <SelectViaCloudflare initialVia={via} />;
|
||||
return null;
|
||||
|
||||
case "credentials":
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
AlibabaCloudProviderBadge,
|
||||
AWSProviderBadge,
|
||||
AzureProviderBadge,
|
||||
CloudflareProviderBadge,
|
||||
GCPProviderBadge,
|
||||
GitHubProviderBadge,
|
||||
IacProviderBadge,
|
||||
@@ -102,6 +103,15 @@ export const CustomProviderInputAlibabaCloud = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const CustomProviderInputCloudflare = () => {
|
||||
return (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<CloudflareProviderBadge width={25} height={25} />
|
||||
<p className="text-sm">Cloudflare</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CustomProviderInputOpenStack = () => {
|
||||
return (
|
||||
<div className="flex items-center gap-x-2">
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
AlibabaCloudProviderBadge,
|
||||
AWSProviderBadge,
|
||||
AzureProviderBadge,
|
||||
CloudflareProviderBadge,
|
||||
GCPProviderBadge,
|
||||
GitHubProviderBadge,
|
||||
IacProviderBadge,
|
||||
@@ -24,6 +25,7 @@ export const PROVIDER_ICONS = {
|
||||
oraclecloud: OracleCloudProviderBadge,
|
||||
mongodbatlas: MongoDBAtlasProviderBadge,
|
||||
alibabacloud: AlibabaCloudProviderBadge,
|
||||
cloudflare: CloudflareProviderBadge,
|
||||
openstack: OpenStackProviderBadge,
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { IconSvgProps } from "@/types";
|
||||
|
||||
export const CloudflareProviderBadge: React.FC<IconSvgProps> = ({
|
||||
size,
|
||||
width,
|
||||
height,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size || height}
|
||||
role="presentation"
|
||||
viewBox="0 0 256 256"
|
||||
width={size || width}
|
||||
{...props}
|
||||
>
|
||||
<g fill="none">
|
||||
<rect width="256" height="256" fill="#f4f2ed" rx="60" />
|
||||
<g transform="translate(40, 40) scale(5.5)">
|
||||
<path
|
||||
d="M22.012 22.222c.197-.675.122-1.294-.206-1.754-.3-.422-.807-.666-1.416-.694l-11.545-.15c-.075 0-.14-.038-.178-.094s-.047-.13-.028-.206c.038-.113.15-.197.272-.206l11.648-.15c1.38-.066 2.88-1.182 3.404-2.55l.666-1.735a.38.38 0 0 0 .02-.225c-.75-3.395-3.78-5.927-7.4-5.927-3.34 0-6.17 2.157-7.184 5.15-.657-.488-1.5-.75-2.392-.666-1.604.16-2.9 1.444-3.048 3.048a3.58 3.58 0 0 0 .084 1.191A4.84 4.84 0 0 0 0 22.1c0 .234.02.47.047.703.02.113.113.197.225.197H21.58a.29.29 0 0 0 .272-.206l.16-.572z"
|
||||
fill="#f38020"
|
||||
/>
|
||||
<path
|
||||
d="M25.688 14.803l-.32.01c-.075 0-.14.056-.17.13l-.45 1.566c-.197.675-.122 1.294.206 1.754.3.422.807.666 1.416.694l2.457.15c.075 0 .14.038.178.094s.047.14.028.206c-.038.113-.15.197-.272.206l-2.56.15c-1.388.066-2.88 1.182-3.404 2.55l-.188.478c-.038.094.028.188.13.188h8.797a.23.23 0 0 0 .225-.169A6.41 6.41 0 0 0 32 21.106a6.32 6.32 0 0 0-6.312-6.302"
|
||||
fill="#faae40"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
@@ -5,6 +5,7 @@ import { IconSvgProps } from "@/types";
|
||||
import { AlibabaCloudProviderBadge } from "./alibabacloud-provider-badge";
|
||||
import { AWSProviderBadge } from "./aws-provider-badge";
|
||||
import { AzureProviderBadge } from "./azure-provider-badge";
|
||||
import { CloudflareProviderBadge } from "./cloudflare-provider-badge";
|
||||
import { GCPProviderBadge } from "./gcp-provider-badge";
|
||||
import { GitHubProviderBadge } from "./github-provider-badge";
|
||||
import { IacProviderBadge } from "./iac-provider-badge";
|
||||
@@ -18,6 +19,7 @@ export {
|
||||
AlibabaCloudProviderBadge,
|
||||
AWSProviderBadge,
|
||||
AzureProviderBadge,
|
||||
CloudflareProviderBadge,
|
||||
GCPProviderBadge,
|
||||
GitHubProviderBadge,
|
||||
IacProviderBadge,
|
||||
@@ -40,5 +42,6 @@ export const PROVIDER_ICONS: Record<string, FC<IconSvgProps>> = {
|
||||
"Oracle Cloud Infrastructure": OracleCloudProviderBadge,
|
||||
"MongoDB Atlas": MongoDBAtlasProviderBadge,
|
||||
"Alibaba Cloud": AlibabaCloudProviderBadge,
|
||||
Cloudflare: CloudflareProviderBadge,
|
||||
OpenStack: OpenStackProviderBadge,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { SelectViaAlibabaCloud } from "@/components/providers/workflow/forms/select-credentials-type/alibabacloud";
|
||||
import { SelectViaAWS } from "@/components/providers/workflow/forms/select-credentials-type/aws";
|
||||
import { SelectViaCloudflare } from "@/components/providers/workflow/forms/select-credentials-type/cloudflare";
|
||||
import { SelectViaGCP } from "@/components/providers/workflow/forms/select-credentials-type/gcp";
|
||||
import { SelectViaGitHub } from "@/components/providers/workflow/forms/select-credentials-type/github";
|
||||
import { SelectViaM365 } from "@/components/providers/workflow/forms/select-credentials-type/m365";
|
||||
@@ -29,6 +30,9 @@ export const CredentialsUpdateInfo = ({
|
||||
if (providerType === "m365") {
|
||||
return <SelectViaM365 initialVia={initialVia} />;
|
||||
}
|
||||
if (providerType === "cloudflare") {
|
||||
return <SelectViaCloudflare initialVia={initialVia} />;
|
||||
}
|
||||
if (providerType === "alibabacloud") {
|
||||
return <SelectViaAlibabaCloud initialVia={initialVia} />;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
AlibabaCloudProviderBadge,
|
||||
AWSProviderBadge,
|
||||
AzureProviderBadge,
|
||||
CloudflareProviderBadge,
|
||||
GCPProviderBadge,
|
||||
GitHubProviderBadge,
|
||||
IacProviderBadge,
|
||||
@@ -74,6 +75,11 @@ const PROVIDERS = [
|
||||
label: "Alibaba Cloud",
|
||||
badge: AlibabaCloudProviderBadge,
|
||||
},
|
||||
{
|
||||
value: "cloudflare",
|
||||
label: "Cloudflare",
|
||||
badge: CloudflareProviderBadge,
|
||||
},
|
||||
{
|
||||
value: "openstack",
|
||||
label: "OpenStack",
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
AWSCredentials,
|
||||
AWSCredentialsRole,
|
||||
AzureCredentials,
|
||||
CloudflareApiKeyCredentials,
|
||||
CloudflareTokenCredentials,
|
||||
GCPDefaultCredentials,
|
||||
GCPServiceAccountKey,
|
||||
IacCredentials,
|
||||
@@ -36,6 +38,10 @@ import {
|
||||
} from "./select-credentials-type/alibabacloud/credentials-type";
|
||||
import { AWSStaticCredentialsForm } from "./select-credentials-type/aws/credentials-type";
|
||||
import { AWSRoleCredentialsForm } from "./select-credentials-type/aws/credentials-type/aws-role-credentials-form";
|
||||
import {
|
||||
CloudflareApiKeyCredentialsForm,
|
||||
CloudflareApiTokenCredentialsForm,
|
||||
} from "./select-credentials-type/cloudflare";
|
||||
import { GCPDefaultCredentialsForm } from "./select-credentials-type/gcp/credentials-type";
|
||||
import { GCPServiceAccountKeyForm } from "./select-credentials-type/gcp/credentials-type/gcp-service-account-key-form";
|
||||
import {
|
||||
@@ -208,6 +214,22 @@ export const BaseCredentialsForm = ({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{providerType === "cloudflare" &&
|
||||
searchParamsObj.get("via") === "api_token" && (
|
||||
<CloudflareApiTokenCredentialsForm
|
||||
control={
|
||||
form.control as unknown as Control<CloudflareTokenCredentials>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{providerType === "cloudflare" &&
|
||||
searchParamsObj.get("via") === "api_key" && (
|
||||
<CloudflareApiKeyCredentialsForm
|
||||
control={
|
||||
form.control as unknown as Control<CloudflareApiKeyCredentials>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{providerType === "openstack" && (
|
||||
<OpenStackCredentialsForm
|
||||
control={form.control as unknown as Control<OpenStackCredentials>}
|
||||
|
||||
@@ -72,6 +72,11 @@ const getProviderFieldDetails = (providerType?: ProviderType) => {
|
||||
label: "Account ID",
|
||||
placeholder: "e.g. 1234567890123456",
|
||||
};
|
||||
case "cloudflare":
|
||||
return {
|
||||
label: "Account ID",
|
||||
placeholder: "e.g. a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
|
||||
};
|
||||
case "openstack":
|
||||
return {
|
||||
label: "Project ID",
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { Control } from "react-hook-form";
|
||||
|
||||
import { CustomInput } from "@/components/ui/custom";
|
||||
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
|
||||
import { CloudflareApiKeyCredentials } from "@/types";
|
||||
|
||||
export const CloudflareApiKeyCredentialsForm = ({
|
||||
control,
|
||||
}: {
|
||||
control: Control<CloudflareApiKeyCredentials>;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-md text-default-foreground leading-9 font-bold">
|
||||
Connect via API Key + Email
|
||||
</div>
|
||||
<div className="text-default-500 text-sm">
|
||||
Provide your Cloudflare Global API Key and the email address
|
||||
associated with your Cloudflare account.
|
||||
</div>
|
||||
</div>
|
||||
<CustomInput
|
||||
control={control}
|
||||
name={ProviderCredentialFields.CLOUDFLARE_API_EMAIL}
|
||||
type="text"
|
||||
label="Email"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter your Cloudflare account email"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
/>
|
||||
<CustomInput
|
||||
control={control}
|
||||
name={ProviderCredentialFields.CLOUDFLARE_API_KEY}
|
||||
type="password"
|
||||
label="Global API Key"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter your Cloudflare Global API Key"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
/>
|
||||
<div className="text-default-400 text-xs">
|
||||
Credentials never leave your browser unencrypted and are stored as
|
||||
secrets in the backend. You can regenerate your API Key from the
|
||||
Cloudflare dashboard anytime if needed.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { Control } from "react-hook-form";
|
||||
|
||||
import { CustomInput } from "@/components/ui/custom";
|
||||
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
|
||||
import { CloudflareTokenCredentials } from "@/types";
|
||||
|
||||
export const CloudflareApiTokenCredentialsForm = ({
|
||||
control,
|
||||
}: {
|
||||
control: Control<CloudflareTokenCredentials>;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-md text-default-foreground leading-9 font-bold">
|
||||
Connect via API Token
|
||||
</div>
|
||||
<div className="text-default-500 text-sm">
|
||||
Provide a Cloudflare API Token with read permissions to the resources
|
||||
you want Prowler to assess. This is the recommended authentication
|
||||
method.
|
||||
</div>
|
||||
</div>
|
||||
<CustomInput
|
||||
control={control}
|
||||
name={ProviderCredentialFields.CLOUDFLARE_API_TOKEN}
|
||||
type="password"
|
||||
label="API Token"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter your Cloudflare API Token"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
/>
|
||||
<div className="text-default-400 text-xs">
|
||||
Tokens never leave your browser unencrypted and are stored as secrets in
|
||||
the backend. You can revoke the token from the Cloudflare dashboard
|
||||
anytime if needed.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CloudflareApiKeyCredentialsForm } from "./cloudflare-api-key-credentials-form";
|
||||
export { CloudflareApiTokenCredentialsForm } from "./cloudflare-api-token-credentials-form";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
CloudflareApiKeyCredentialsForm,
|
||||
CloudflareApiTokenCredentialsForm,
|
||||
} from "./credentials-type";
|
||||
export { SelectViaCloudflare } from "./select-via-cloudflare";
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { RadioGroup } from "@heroui/radio";
|
||||
import { Control, Controller } from "react-hook-form";
|
||||
|
||||
import { CustomRadio } from "@/components/ui/custom";
|
||||
import { FormMessage } from "@/components/ui/form";
|
||||
|
||||
type RadioGroupCloudflareViaCredentialsFormProps = {
|
||||
control: Control<any>;
|
||||
isInvalid: boolean;
|
||||
errorMessage?: string;
|
||||
onChange?: (value: string) => void;
|
||||
};
|
||||
|
||||
export const RadioGroupCloudflareViaCredentialsTypeForm = ({
|
||||
control,
|
||||
isInvalid,
|
||||
errorMessage,
|
||||
onChange,
|
||||
}: RadioGroupCloudflareViaCredentialsFormProps) => {
|
||||
return (
|
||||
<Controller
|
||||
name="cloudflareCredentialsType"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<RadioGroup
|
||||
className="flex flex-wrap"
|
||||
isInvalid={isInvalid}
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
if (onChange) {
|
||||
onChange(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="text-default-500 text-sm">
|
||||
Select Authentication Method
|
||||
</span>
|
||||
<CustomRadio
|
||||
description="Connect using a Cloudflare API Token (recommended)"
|
||||
value="api_token"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span className="ml-2">API Token</span>
|
||||
</div>
|
||||
</CustomRadio>
|
||||
<CustomRadio
|
||||
description="Connect using Global API Key and Email"
|
||||
value="api_key"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span className="ml-2">API Key + Email</span>
|
||||
</div>
|
||||
</CustomRadio>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
{errorMessage && (
|
||||
<FormMessage className="text-text-error">
|
||||
{errorMessage}
|
||||
</FormMessage>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { Form } from "@/components/ui/form";
|
||||
|
||||
import { RadioGroupCloudflareViaCredentialsTypeForm } from "./radio-group-cloudflare-via-credentials-type-form";
|
||||
|
||||
interface SelectViaCloudflareProps {
|
||||
initialVia?: string;
|
||||
}
|
||||
|
||||
export const SelectViaCloudflare = ({
|
||||
initialVia,
|
||||
}: SelectViaCloudflareProps) => {
|
||||
const router = useRouter();
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
cloudflareCredentialsType: initialVia || "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSelectionChange = (value: string) => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("via", value);
|
||||
router.push(url.toString());
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<RadioGroupCloudflareViaCredentialsTypeForm
|
||||
control={form.control}
|
||||
isInvalid={!!form.formState.errors.cloudflareCredentialsType}
|
||||
errorMessage={form.formState.errors.cloudflareCredentialsType?.message}
|
||||
onChange={handleSelectionChange}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
AlibabaCloudProviderBadge,
|
||||
AWSProviderBadge,
|
||||
AzureProviderBadge,
|
||||
CloudflareProviderBadge,
|
||||
GCPProviderBadge,
|
||||
GitHubProviderBadge,
|
||||
IacProviderBadge,
|
||||
@@ -35,6 +36,8 @@ export const getProviderLogo = (provider: ProviderType) => {
|
||||
return <MongoDBAtlasProviderBadge width={35} height={35} />;
|
||||
case "alibabacloud":
|
||||
return <AlibabaCloudProviderBadge width={35} height={35} />;
|
||||
case "cloudflare":
|
||||
return <CloudflareProviderBadge width={35} height={35} />;
|
||||
case "openstack":
|
||||
return <OpenStackProviderBadge width={35} height={35} />;
|
||||
default:
|
||||
@@ -64,6 +67,8 @@ export const getProviderName = (provider: ProviderType): string => {
|
||||
return "MongoDB Atlas";
|
||||
case "alibabacloud":
|
||||
return "Alibaba Cloud";
|
||||
case "cloudflare":
|
||||
return "Cloudflare";
|
||||
case "openstack":
|
||||
return "OpenStack";
|
||||
default:
|
||||
|
||||
@@ -46,8 +46,12 @@ export const useCredentialsForm = ({
|
||||
if (providerType === "gcp" && via === "service-account") {
|
||||
return addCredentialsServiceAccountFormSchema(providerType);
|
||||
}
|
||||
// For GitHub and M365, we need to pass the via parameter to determine which fields are required
|
||||
if (providerType === "github" || providerType === "m365") {
|
||||
// For GitHub, M365, and Cloudflare, we need to pass the via parameter to determine which fields are required
|
||||
if (
|
||||
providerType === "github" ||
|
||||
providerType === "m365" ||
|
||||
providerType === "cloudflare"
|
||||
) {
|
||||
return addCredentialsFormSchema(providerType, via);
|
||||
}
|
||||
return addCredentialsFormSchema(providerType);
|
||||
@@ -192,6 +196,22 @@ export const useCredentialsForm = ({
|
||||
[ProviderCredentialFields.ALIBABACLOUD_ACCESS_KEY_ID]: "",
|
||||
[ProviderCredentialFields.ALIBABACLOUD_ACCESS_KEY_SECRET]: "",
|
||||
};
|
||||
case "cloudflare":
|
||||
// Cloudflare credentials based on via parameter
|
||||
if (via === "api_token") {
|
||||
return {
|
||||
...baseDefaults,
|
||||
[ProviderCredentialFields.CLOUDFLARE_API_TOKEN]: "",
|
||||
};
|
||||
}
|
||||
if (via === "api_key") {
|
||||
return {
|
||||
...baseDefaults,
|
||||
[ProviderCredentialFields.CLOUDFLARE_API_KEY]: "",
|
||||
[ProviderCredentialFields.CLOUDFLARE_API_EMAIL]: "",
|
||||
};
|
||||
}
|
||||
return baseDefaults;
|
||||
case "openstack":
|
||||
return {
|
||||
...baseDefaults,
|
||||
|
||||
@@ -30,6 +30,12 @@ export const PROVIDER_CREDENTIALS_ERROR_MAPPING: Record<string, string> = {
|
||||
ProviderCredentialFields.SERVICE_ACCOUNT_KEY,
|
||||
[ErrorPointers.ATLAS_PUBLIC_KEY]: ProviderCredentialFields.ATLAS_PUBLIC_KEY,
|
||||
[ErrorPointers.ATLAS_PRIVATE_KEY]: ProviderCredentialFields.ATLAS_PRIVATE_KEY,
|
||||
[ErrorPointers.CLOUDFLARE_API_TOKEN]:
|
||||
ProviderCredentialFields.CLOUDFLARE_API_TOKEN,
|
||||
[ErrorPointers.CLOUDFLARE_API_KEY]:
|
||||
ProviderCredentialFields.CLOUDFLARE_API_KEY,
|
||||
[ErrorPointers.CLOUDFLARE_API_EMAIL]:
|
||||
ProviderCredentialFields.CLOUDFLARE_API_EMAIL,
|
||||
[ErrorPointers.OPENSTACK_CLOUDS_YAML_CONTENT]:
|
||||
ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CONTENT,
|
||||
[ErrorPointers.OPENSTACK_CLOUDS_YAML_CLOUD]:
|
||||
|
||||
@@ -58,6 +58,11 @@ export const getProviderHelpText = (provider: string) => {
|
||||
text: "Need help connecting your Alibaba Cloud account?",
|
||||
link: "https://goto.prowler.com/provider-alibabacloud",
|
||||
};
|
||||
case "cloudflare":
|
||||
return {
|
||||
text: "Need help connecting your Cloudflare account?",
|
||||
link: "https://goto.prowler.com/provider-cloudflare",
|
||||
};
|
||||
case "openstack":
|
||||
return {
|
||||
text: "Need help connecting your OpenStack cloud?",
|
||||
|
||||
@@ -332,6 +332,61 @@ export const buildOracleCloudSecret = (
|
||||
return filterEmptyValues(secret);
|
||||
};
|
||||
|
||||
/**
|
||||
* Clean a Cloudflare API token by removing common copy-paste issues:
|
||||
* - Leading/trailing whitespace
|
||||
* - "Bearer " prefix (if user copied the full header)
|
||||
* - Tabs and other whitespace characters
|
||||
*/
|
||||
const cleanCloudflareToken = (token: string | null | undefined): string => {
|
||||
if (!token) return "";
|
||||
// Remove leading/trailing whitespace and tabs
|
||||
let cleaned = token.trim().replace(/\t/g, "");
|
||||
// Remove "Bearer " prefix if present (case-insensitive)
|
||||
if (cleaned.toLowerCase().startsWith("bearer ")) {
|
||||
cleaned = cleaned.slice(7).trim();
|
||||
}
|
||||
return cleaned;
|
||||
};
|
||||
|
||||
export const buildCloudflareSecret = (formData: FormData) => {
|
||||
// Check which authentication method is being used
|
||||
const hasApiToken =
|
||||
formData.get(ProviderCredentialFields.CLOUDFLARE_API_TOKEN) !== null &&
|
||||
formData.get(ProviderCredentialFields.CLOUDFLARE_API_TOKEN) !== "";
|
||||
const hasApiKey =
|
||||
formData.get(ProviderCredentialFields.CLOUDFLARE_API_KEY) !== null &&
|
||||
formData.get(ProviderCredentialFields.CLOUDFLARE_API_KEY) !== "";
|
||||
|
||||
if (hasApiToken) {
|
||||
const apiToken = getFormValue(
|
||||
formData,
|
||||
ProviderCredentialFields.CLOUDFLARE_API_TOKEN,
|
||||
) as string;
|
||||
return {
|
||||
[ProviderCredentialFields.CLOUDFLARE_API_TOKEN]:
|
||||
cleanCloudflareToken(apiToken),
|
||||
};
|
||||
}
|
||||
|
||||
if (hasApiKey) {
|
||||
const apiKey = getFormValue(
|
||||
formData,
|
||||
ProviderCredentialFields.CLOUDFLARE_API_KEY,
|
||||
) as string;
|
||||
const apiEmail = getFormValue(
|
||||
formData,
|
||||
ProviderCredentialFields.CLOUDFLARE_API_EMAIL,
|
||||
) as string;
|
||||
return filterEmptyValues({
|
||||
[ProviderCredentialFields.CLOUDFLARE_API_KEY]: apiKey?.trim(),
|
||||
[ProviderCredentialFields.CLOUDFLARE_API_EMAIL]: apiEmail?.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
// Main function to build secret configuration
|
||||
export const buildSecretConfig = (
|
||||
formData: FormData,
|
||||
@@ -387,6 +442,10 @@ export const buildSecretConfig = (
|
||||
secret: buildAlibabaCloudSecret(formData, isRole),
|
||||
};
|
||||
},
|
||||
cloudflare: () => ({
|
||||
secretType: "static",
|
||||
secret: buildCloudflareSecret(formData),
|
||||
}),
|
||||
openstack: () => ({
|
||||
secretType: "static",
|
||||
secret: buildOpenStackSecret(formData),
|
||||
|
||||
@@ -68,6 +68,11 @@ export const ProviderCredentialFields = {
|
||||
ALIBABACLOUD_ROLE_ARN: "role_arn",
|
||||
ALIBABACLOUD_ROLE_SESSION_NAME: "role_session_name",
|
||||
|
||||
// Cloudflare fields
|
||||
CLOUDFLARE_API_TOKEN: "api_token",
|
||||
CLOUDFLARE_API_KEY: "api_key",
|
||||
CLOUDFLARE_API_EMAIL: "api_email",
|
||||
|
||||
// OpenStack fields
|
||||
OPENSTACK_CLOUDS_YAML_CONTENT: "clouds_yaml_content",
|
||||
OPENSTACK_CLOUDS_YAML_CLOUD: "clouds_yaml_cloud",
|
||||
@@ -115,6 +120,9 @@ export const ErrorPointers = {
|
||||
ALIBABACLOUD_ACCESS_KEY_SECRET: "/data/attributes/secret/access_key_secret",
|
||||
ALIBABACLOUD_ROLE_ARN: "/data/attributes/secret/role_arn",
|
||||
ALIBABACLOUD_ROLE_SESSION_NAME: "/data/attributes/secret/role_session_name",
|
||||
CLOUDFLARE_API_TOKEN: "/data/attributes/secret/api_token",
|
||||
CLOUDFLARE_API_KEY: "/data/attributes/secret/api_key",
|
||||
CLOUDFLARE_API_EMAIL: "/data/attributes/secret/api_email",
|
||||
OPENSTACK_CLOUDS_YAML_CONTENT: "/data/attributes/secret/clouds_yaml_content",
|
||||
OPENSTACK_CLOUDS_YAML_CLOUD: "/data/attributes/secret/clouds_yaml_cloud",
|
||||
} as const;
|
||||
|
||||
@@ -88,6 +88,7 @@ export const getProviderFormType = (
|
||||
"github",
|
||||
"m365",
|
||||
"alibabacloud",
|
||||
"cloudflare",
|
||||
].includes(providerType);
|
||||
|
||||
// Show selector if no via parameter and provider needs it
|
||||
@@ -129,6 +130,14 @@ export const getProviderFormType = (
|
||||
if (via === "credentials") return "credentials";
|
||||
}
|
||||
|
||||
// Cloudflare credential types
|
||||
if (
|
||||
providerType === "cloudflare" &&
|
||||
["api_token", "api_key"].includes(via || "")
|
||||
) {
|
||||
return "credentials";
|
||||
}
|
||||
|
||||
// Other providers go directly to credentials form
|
||||
if (!needsSelector) {
|
||||
return "credentials";
|
||||
@@ -150,8 +159,11 @@ export const requiresBackButton = (via?: string | null): boolean => {
|
||||
"github_app",
|
||||
"app_client_secret",
|
||||
"app_certificate",
|
||||
"api_token",
|
||||
"api_key",
|
||||
];
|
||||
// Note: "role" is already included for AWS, now also used by AlibabaCloud
|
||||
// "api_token" and "api_key" are used by Cloudflare
|
||||
|
||||
return validViaTypes.includes(via);
|
||||
};
|
||||
|
||||
@@ -334,6 +334,21 @@ export type AlibabaCloudCredentialsRole = {
|
||||
[ProviderCredentialFields.PROVIDER_ID]: string;
|
||||
};
|
||||
|
||||
export type CloudflareTokenCredentials = {
|
||||
[ProviderCredentialFields.CLOUDFLARE_API_TOKEN]: string;
|
||||
[ProviderCredentialFields.PROVIDER_ID]: string;
|
||||
};
|
||||
|
||||
export type CloudflareApiKeyCredentials = {
|
||||
[ProviderCredentialFields.CLOUDFLARE_API_KEY]: string;
|
||||
[ProviderCredentialFields.CLOUDFLARE_API_EMAIL]: string;
|
||||
[ProviderCredentialFields.PROVIDER_ID]: string;
|
||||
};
|
||||
|
||||
export type CloudflareCredentials =
|
||||
| CloudflareTokenCredentials
|
||||
| CloudflareApiKeyCredentials;
|
||||
|
||||
export type OpenStackCredentials = {
|
||||
[ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CONTENT]: string;
|
||||
[ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CLOUD]: string;
|
||||
@@ -353,6 +368,7 @@ export type CredentialsFormSchema =
|
||||
| MongoDBAtlasCredentials
|
||||
| AlibabaCloudCredentials
|
||||
| AlibabaCloudCredentialsRole
|
||||
| CloudflareCredentials
|
||||
| OpenStackCredentials;
|
||||
|
||||
export interface SearchParamsProps {
|
||||
|
||||
@@ -130,6 +130,11 @@ export const addProviderFormSchema = z
|
||||
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
|
||||
providerUid: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
providerType: z.literal("cloudflare"),
|
||||
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
|
||||
providerUid: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
providerType: z.literal("openstack"),
|
||||
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
|
||||
@@ -264,16 +269,44 @@ export const addCredentialsFormSchema = (
|
||||
.string()
|
||||
.min(1, "Access Key Secret is required"),
|
||||
}
|
||||
: providerType === "openstack"
|
||||
: providerType === "cloudflare"
|
||||
? {
|
||||
[ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CONTENT]:
|
||||
[ProviderCredentialFields.CLOUDFLARE_API_TOKEN]:
|
||||
z.string().optional(),
|
||||
[ProviderCredentialFields.CLOUDFLARE_API_KEY]: z
|
||||
.string()
|
||||
.optional(),
|
||||
[ProviderCredentialFields.CLOUDFLARE_API_EMAIL]:
|
||||
z
|
||||
.string()
|
||||
.min(1, "Clouds YAML content is required"),
|
||||
[ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CLOUD]:
|
||||
z.string().min(1, "Cloud name is required"),
|
||||
.superRefine((val, ctx) => {
|
||||
if (val && val.trim() !== "") {
|
||||
const emailRegex =
|
||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(val)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"Please enter a valid email address",
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.optional(),
|
||||
}
|
||||
: {}),
|
||||
: providerType === "openstack"
|
||||
? {
|
||||
[ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CONTENT]:
|
||||
z
|
||||
.string()
|
||||
.min(
|
||||
1,
|
||||
"Clouds YAML content is required",
|
||||
),
|
||||
[ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CLOUD]:
|
||||
z.string().min(1, "Cloud name is required"),
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
.superRefine((data: Record<string, string | undefined>, ctx) => {
|
||||
if (providerType === "m365") {
|
||||
@@ -335,6 +368,37 @@ export const addCredentialsFormSchema = (
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (providerType === "cloudflare") {
|
||||
// For Cloudflare, validation depends on the 'via' parameter
|
||||
if (via === "api_token") {
|
||||
const apiToken = data[ProviderCredentialFields.CLOUDFLARE_API_TOKEN];
|
||||
if (!apiToken || apiToken.trim() === "") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "API Token is required",
|
||||
path: [ProviderCredentialFields.CLOUDFLARE_API_TOKEN],
|
||||
});
|
||||
}
|
||||
} else if (via === "api_key") {
|
||||
const apiKey = data[ProviderCredentialFields.CLOUDFLARE_API_KEY];
|
||||
const apiEmail = data[ProviderCredentialFields.CLOUDFLARE_API_EMAIL];
|
||||
if (!apiKey || apiKey.trim() === "") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "API Key is required",
|
||||
path: [ProviderCredentialFields.CLOUDFLARE_API_KEY],
|
||||
});
|
||||
}
|
||||
if (!apiEmail || apiEmail.trim() === "") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Email is required",
|
||||
path: [ProviderCredentialFields.CLOUDFLARE_API_EMAIL],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const addCredentialsRoleFormSchema = (providerType: string) =>
|
||||
|
||||
@@ -9,6 +9,7 @@ export const PROVIDER_TYPES = [
|
||||
"iac",
|
||||
"oraclecloud",
|
||||
"alibabacloud",
|
||||
"cloudflare",
|
||||
"openstack",
|
||||
] as const;
|
||||
|
||||
@@ -25,6 +26,7 @@ export const PROVIDER_DISPLAY_NAMES: Record<ProviderType, string> = {
|
||||
iac: "Infrastructure as Code",
|
||||
oraclecloud: "Oracle Cloud Infrastructure",
|
||||
alibabacloud: "Alibaba Cloud",
|
||||
cloudflare: "Cloudflare",
|
||||
openstack: "OpenStack",
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user