-
{title}
+
+
{title}
+ {navigationUrl && (
+
+
+
+ )}
+
{subtitle && (
{subtitle}
From 25098bc82a47f794eb02fc215b40d940efb4f39c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?C=C3=A9sar=20Arroba?=
<19954079+cesararroba@users.noreply.github.com>
Date: Wed, 27 Aug 2025 13:41:39 +0200
Subject: [PATCH 03/37] chore(gha): fix conflict checker action (#8586)
---
.github/workflows/pr-conflict-checker.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/pr-conflict-checker.yml b/.github/workflows/pr-conflict-checker.yml
index 2071dbaa78..ee820e87f0 100644
--- a/.github/workflows/pr-conflict-checker.yml
+++ b/.github/workflows/pr-conflict-checker.yml
@@ -97,6 +97,7 @@ jobs:
if: steps.conflict-check.outputs.has_conflicts == 'false'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
+ github-token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
script: |
try {
await github.rest.issues.removeLabel({
From a1abe6dd2d0b6eac3d48de4bf756296408a9fcad Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Adri=C3=A1n=20Jes=C3=BAs=20Pe=C3=B1a=20Rodr=C3=ADguez?=
Date: Wed, 27 Aug 2025 14:15:09 +0200
Subject: [PATCH 04/37] fix(sh): reset regions information if connection fails
(#8588)
---
api/src/backend/api/tests/test_utils.py | 110 ++++++++++-
api/src/backend/api/utils.py | 4 +
api/src/backend/tasks/jobs/integrations.py | 5 +
.../backend/tasks/tests/test_integrations.py | 178 +++++++++++++++++-
4 files changed, 289 insertions(+), 8 deletions(-)
diff --git a/api/src/backend/api/tests/test_utils.py b/api/src/backend/api/tests/test_utils.py
index 352c8262fa..e94504e902 100644
--- a/api/src/backend/api/tests/test_utils.py
+++ b/api/src/backend/api/tests/test_utils.py
@@ -6,16 +6,18 @@ from rest_framework.exceptions import NotFound, ValidationError
from api.db_router import MainRouter
from api.exceptions import InvitationTokenExpiredException
-from api.models import Invitation, Provider
+from api.models import Integration, Invitation, Provider
from api.utils import (
get_prowler_provider_kwargs,
initialize_prowler_provider,
merge_dicts,
+ prowler_integration_connection_test,
prowler_provider_connection_test,
return_prowler_provider,
validate_invitation,
)
from prowler.providers.aws.aws_provider import AwsProvider
+from prowler.providers.aws.lib.security_hub.security_hub import SecurityHubConnection
from prowler.providers.azure.azure_provider import AzureProvider
from prowler.providers.gcp.gcp_provider import GcpProvider
from prowler.providers.kubernetes.kubernetes_provider import KubernetesProvider
@@ -390,3 +392,109 @@ class TestValidateInvitation:
mock_db.get.assert_called_once_with(
token="VALID_TOKEN", email__iexact="user@example.com"
)
+
+
+class TestProwlerIntegrationConnectionTest:
+ """Test prowler_integration_connection_test function for SecurityHub regions reset."""
+
+ @patch("api.utils.SecurityHub")
+ def test_security_hub_connection_failure_resets_regions(
+ self, mock_security_hub_class
+ ):
+ """Test that SecurityHub connection failure resets regions to empty dict."""
+ # Create integration with existing regions configuration
+ integration = MagicMock()
+ integration.integration_type = Integration.IntegrationChoices.AWS_SECURITY_HUB
+ integration.credentials = {
+ "aws_access_key_id": "test_key",
+ "aws_secret_access_key": "test_secret",
+ }
+ integration.configuration = {
+ "send_only_fails": True,
+ "regions": {
+ "us-east-1": True,
+ "us-west-2": True,
+ "eu-west-1": False,
+ "ap-south-1": False,
+ },
+ }
+
+ # Mock provider relationship
+ mock_provider = MagicMock()
+ mock_provider.uid = "123456789012"
+ mock_relationship = MagicMock()
+ mock_relationship.provider = mock_provider
+ integration.integrationproviderrelationship_set.first.return_value = (
+ mock_relationship
+ )
+
+ # Mock failed SecurityHub connection
+ mock_connection = SecurityHubConnection(
+ is_connected=False,
+ error=Exception("SecurityHub testing"),
+ enabled_regions=set(),
+ disabled_regions=set(),
+ )
+ mock_security_hub_class.test_connection.return_value = mock_connection
+
+ # Call the function
+ result = prowler_integration_connection_test(integration)
+
+ # Assertions
+ assert result.is_connected is False
+ assert str(result.error) == "SecurityHub testing"
+
+ # Verify regions were completely reset to empty dict
+ assert integration.configuration["regions"] == {}
+
+ # Verify save was called to persist the change
+ integration.save.assert_called_once()
+
+ # Verify test_connection was called with correct parameters
+ mock_security_hub_class.test_connection.assert_called_once_with(
+ aws_account_id="123456789012",
+ raise_on_exception=False,
+ aws_access_key_id="test_key",
+ aws_secret_access_key="test_secret",
+ )
+
+ @patch("api.utils.SecurityHub")
+ def test_security_hub_connection_success_saves_regions(
+ self, mock_security_hub_class
+ ):
+ """Test that successful SecurityHub connection saves regions correctly."""
+ integration = MagicMock()
+ integration.integration_type = Integration.IntegrationChoices.AWS_SECURITY_HUB
+ integration.credentials = {
+ "aws_access_key_id": "valid_key",
+ "aws_secret_access_key": "valid_secret",
+ }
+ integration.configuration = {"send_only_fails": False}
+
+ # Mock provider relationship
+ mock_provider = MagicMock()
+ mock_provider.uid = "123456789012"
+ mock_relationship = MagicMock()
+ mock_relationship.provider = mock_provider
+ integration.integrationproviderrelationship_set.first.return_value = (
+ mock_relationship
+ )
+
+ # Mock successful SecurityHub connection with regions
+ mock_connection = SecurityHubConnection(
+ is_connected=True,
+ error=None,
+ enabled_regions={"us-east-1", "eu-west-1"},
+ disabled_regions={"ap-south-1"},
+ )
+ mock_security_hub_class.test_connection.return_value = mock_connection
+
+ result = prowler_integration_connection_test(integration)
+
+ assert result.is_connected is True
+
+ # Verify regions were saved correctly
+ assert integration.configuration["regions"]["us-east-1"] is True
+ assert integration.configuration["regions"]["eu-west-1"] is True
+ assert integration.configuration["regions"]["ap-south-1"] is False
+ integration.save.assert_called_once()
diff --git a/api/src/backend/api/utils.py b/api/src/backend/api/utils.py
index 7cbb7a0247..1cc10674d7 100644
--- a/api/src/backend/api/utils.py
+++ b/api/src/backend/api/utils.py
@@ -223,6 +223,10 @@ def prowler_integration_connection_test(integration: Integration) -> Connection:
# Save regions information in the integration configuration
integration.configuration["regions"] = regions_status
integration.save()
+ else:
+ # Reset regions information if connection fails
+ integration.configuration["regions"] = {}
+ integration.save()
return connection
elif integration.integration_type == Integration.IntegrationChoices.JIRA:
diff --git a/api/src/backend/tasks/jobs/integrations.py b/api/src/backend/tasks/jobs/integrations.py
index 5cc25d09a8..e95d39652e 100644
--- a/api/src/backend/tasks/jobs/integrations.py
+++ b/api/src/backend/tasks/jobs/integrations.py
@@ -220,6 +220,11 @@ def get_security_hub_client_from_integration(
**credentials,
)
return True, security_hub
+ else:
+ # Reset regions information if connection fails
+ with rls_transaction(tenant_id):
+ integration.configuration["regions"] = {}
+ integration.save()
return False, connection
diff --git a/api/src/backend/tasks/tests/test_integrations.py b/api/src/backend/tasks/tests/test_integrations.py
index 4dd68b9f95..1a55f14f2c 100644
--- a/api/src/backend/tasks/tests/test_integrations.py
+++ b/api/src/backend/tasks/tests/test_integrations.py
@@ -492,14 +492,17 @@ class TestProwlerIntegrationConnectionTest:
def test_aws_security_hub_integration_connection_failure(
self, mock_security_hub_class
):
- """Test AWS Security Hub integration connection failure."""
+ """Test AWS Security Hub integration connection failure resets regions."""
integration = MagicMock()
integration.integration_type = Integration.IntegrationChoices.AWS_SECURITY_HUB
integration.credentials = {
"aws_access_key_id": "invalid_key",
"aws_secret_access_key": "invalid_secret",
}
- integration.configuration = {"send_only_fails": False}
+ integration.configuration = {
+ "send_only_fails": False,
+ "regions": {"us-east-1": True, "us-west-2": False}, # Existing regions
+ }
# Mock integration provider relationship
mock_provider = MagicMock()
@@ -524,8 +527,9 @@ class TestProwlerIntegrationConnectionTest:
assert result.is_connected is False
assert result.error == test_exception
- # Verify regions were not saved when connection failed
- integration.save.assert_not_called()
+ # Verify regions were reset to empty dict when connection failed
+ assert integration.configuration["regions"] == {}
+ integration.save.assert_called_once()
@patch("api.utils.SecurityHub")
def test_aws_security_hub_integration_with_provider_credentials(
@@ -575,6 +579,72 @@ class TestProwlerIntegrationConnectionTest:
assert not integration.configuration["regions"]["ap-south-1"]
integration.save.assert_called_once()
+ @patch("api.utils.SecurityHub")
+ def test_aws_security_hub_connection_failure_with_multiple_regions_clears_all(
+ self, mock_security_hub_class
+ ):
+ """Test that SecurityHub connection failure clears all existing regions data."""
+ integration = MagicMock()
+ integration.integration_type = Integration.IntegrationChoices.AWS_SECURITY_HUB
+ integration.credentials = {
+ "aws_access_key_id": "test_key",
+ "aws_secret_access_key": "test_secret",
+ }
+ # Start with complex regions configuration
+ integration.configuration = {
+ "send_only_fails": True,
+ "regions": {
+ "us-east-1": True,
+ "us-east-2": False,
+ "us-west-1": True,
+ "us-west-2": True,
+ "eu-west-1": False,
+ "eu-west-2": True,
+ "eu-central-1": True,
+ "ap-northeast-1": False,
+ "ap-southeast-1": True,
+ "ap-southeast-2": False,
+ },
+ }
+
+ # Mock integration provider relationship
+ mock_provider = MagicMock()
+ mock_provider.uid = "987654321098"
+ mock_relationship = MagicMock()
+ mock_relationship.provider = mock_provider
+ integration.integrationproviderrelationship_set.first.return_value = (
+ mock_relationship
+ )
+
+ # Mock failed SecurityHub connection
+ mock_connection = SecurityHubConnection(
+ is_connected=False,
+ error=Exception("Invalid credentials or permissions"),
+ enabled_regions=set(),
+ disabled_regions=set(),
+ )
+ mock_security_hub_class.test_connection.return_value = mock_connection
+
+ result = prowler_integration_connection_test(integration)
+
+ assert result.is_connected is False
+ assert str(result.error) == "Invalid credentials or permissions"
+
+ # Verify all regions were completely cleared
+ assert integration.configuration["regions"] == {}
+ assert len(integration.configuration["regions"]) == 0
+
+ # Verify save was called to persist the cleared regions
+ integration.save.assert_called_once()
+
+ # Verify the test_connection was called with correct parameters
+ mock_security_hub_class.test_connection.assert_called_once_with(
+ aws_account_id="987654321098",
+ raise_on_exception=False,
+ aws_access_key_id="test_key",
+ aws_secret_access_key="test_secret",
+ )
+
def test_unsupported_integration_type(self):
"""Test unsupported integration type raises ValueError."""
integration = MagicMock()
@@ -674,15 +744,19 @@ class TestSecurityHubIntegrationUploads:
"us-west-2",
]
+ @patch("tasks.jobs.integrations.rls_transaction")
@patch("tasks.jobs.integrations.SecurityHub.test_connection")
@patch("tasks.jobs.integrations.initialize_prowler_provider")
def test_get_security_hub_client_from_integration_failure(
- self, mock_initialize_provider, mock_test_connection
+ self, mock_initialize_provider, mock_test_connection, mock_rls
):
- """Test SecurityHub client creation failure."""
+ """Test SecurityHub client creation failure resets regions."""
# Mock integration
mock_integration = MagicMock()
- mock_integration.configuration = {"send_only_fails": False}
+ mock_integration.configuration = {
+ "send_only_fails": False,
+ "regions": {"us-east-1": True, "us-west-2": False}, # Existing regions
+ }
mock_integration.credentials = {} # Empty credentials, use provider
# Mock tenant_id
@@ -710,6 +784,9 @@ class TestSecurityHubIntegrationUploads:
# Mock findings
mock_findings = [{"finding": "test"}]
+ # Mock RLS context manager
+ mock_rls.return_value.__enter__.return_value = None
+
connected, connection = get_security_hub_client_from_integration(
mock_integration, tenant_id, mock_findings
)
@@ -725,6 +802,93 @@ class TestSecurityHubIntegrationUploads:
aws_secret_access_key="test_secret_key",
)
+ # Verify regions were reset to empty when connection failed
+ assert mock_integration.configuration["regions"] == {}
+ mock_integration.save.assert_called_once()
+ # Verify RLS transaction was used for the reset
+ assert (
+ mock_rls.call_count == 2
+ ) # Once for getting provider, once for resetting regions
+
+ @patch("tasks.jobs.integrations.rls_transaction")
+ @patch("tasks.jobs.integrations.SecurityHub.test_connection")
+ def test_get_security_hub_client_from_integration_failure_clears_existing_regions(
+ self, mock_test_connection, mock_rls
+ ):
+ """Test that SecurityHub client creation failure clears existing regions configuration."""
+ # Mock integration with pre-existing regions configuration
+ mock_integration = MagicMock()
+ mock_integration.configuration = {
+ "send_only_fails": True,
+ "regions": {
+ "us-east-1": True,
+ "us-west-2": True,
+ "eu-west-1": False,
+ "ap-south-1": False,
+ }, # Pre-existing regions configuration
+ }
+ mock_integration.credentials = {
+ "aws_access_key_id": "test_key_id",
+ "aws_secret_access_key": "test_secret_key",
+ }
+
+ # Mock tenant_id
+ tenant_id = "550e8400-e29b-41d4-a716-446655440000"
+
+ # Mock provider relationship
+ mock_provider = MagicMock()
+ mock_provider.uid = "123456789012"
+ mock_provider.secret.secret = {
+ "aws_access_key_id": "provider_key",
+ "aws_secret_access_key": "provider_secret",
+ }
+ mock_relationship = MagicMock()
+ mock_relationship.provider = mock_provider
+ mock_integration.integrationproviderrelationship_set.first.return_value = (
+ mock_relationship
+ )
+
+ # Mock failed connection with specific error
+ mock_connection = MagicMock()
+ mock_connection.is_connected = False
+ mock_connection.error = "Access denied: SecurityHub not enabled in region"
+ mock_test_connection.return_value = mock_connection
+
+ # Mock findings
+ mock_findings = [{"finding": "test1"}, {"finding": "test2"}]
+
+ # Mock RLS context manager
+ mock_rls.return_value.__enter__.return_value = None
+
+ # Call the function
+ connected, connection = get_security_hub_client_from_integration(
+ mock_integration, tenant_id, mock_findings
+ )
+
+ # Assertions
+ assert connected is False
+ assert connection == mock_connection
+ assert connection.error == "Access denied: SecurityHub not enabled in region"
+
+ # Verify that regions configuration was completely cleared
+ assert mock_integration.configuration["regions"] == {}
+
+ # Verify save was called to persist the change
+ mock_integration.save.assert_called_once()
+
+ # Verify RLS transaction was used correctly
+ # Should be called twice: once for getting provider info, once for resetting regions
+ assert mock_rls.call_count == 2
+ mock_rls.assert_any_call(tenant_id)
+
+ # Verify test_connection was called with integration credentials (not provider's)
+ mock_test_connection.assert_called_once_with(
+ aws_account_id="123456789012",
+ raise_on_exception=False,
+ aws_access_key_id="test_key_id",
+ aws_secret_access_key="test_secret_key",
+ )
+
@patch("tasks.jobs.integrations.AwsProvider")
@patch("tasks.jobs.integrations.SecurityHub.test_connection")
@patch("tasks.jobs.integrations.initialize_prowler_provider")
From 24364bd73e9db890efcbfa3953afa552922d15d5 Mon Sep 17 00:00:00 2001
From: Tom
Date: Wed, 27 Aug 2025 13:44:34 +0100
Subject: [PATCH 05/37] feat(gcp): Add support for skipping APIs check (#8575)
Co-authored-by: Sergio Garcia
---
prowler/CHANGELOG.md | 2 +-
prowler/providers/common/provider.py | 1 +
prowler/providers/gcp/gcp_provider.py | 6 ++
.../providers/gcp/lib/arguments/arguments.py | 7 ++
prowler/providers/gcp/lib/service/service.py | 5 +-
tests/providers/gcp/gcp_provider_test.py | 87 +++++++++++++++++++
6 files changed, 106 insertions(+), 2 deletions(-)
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index 9ced1b7941..7323a56ad2 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -15,7 +15,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `eks_cluster_deletion_protection_enabled` check for AWS provider [(#8536)](https://github.com/prowler-cloud/prowler/pull/8536)
- ECS privilege escalation patterns (StartTask and RunTask) for AWS provider [(#8541)](https://github.com/prowler-cloud/prowler/pull/8541)
- Resource Explorer enumeration v2 API actions in `cloudtrail_threat_detection_enumeration` check [(#8557)](https://github.com/prowler-cloud/prowler/pull/8557)
-
+- GCP `--skip-api-check` command line flag [(#8575)](https://github.com/prowler-cloud/prowler/pull/8575)
### Changed
- Refine kisa isms-p compliance mapping [(#8479)](https://github.com/prowler-cloud/prowler/pull/8479)
diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py
index 1b2de71148..c09c5113d9 100644
--- a/prowler/providers/common/provider.py
+++ b/prowler/providers/common/provider.py
@@ -202,6 +202,7 @@ class Provider(ABC):
config_path=arguments.config_file,
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
+ skip_api_check=arguments.skip_api_check,
)
elif "kubernetes" in provider_class_name.lower():
provider_class(
diff --git a/prowler/providers/gcp/gcp_provider.py b/prowler/providers/gcp/gcp_provider.py
index 7cdbba4401..c9e7c25501 100644
--- a/prowler/providers/gcp/gcp_provider.py
+++ b/prowler/providers/gcp/gcp_provider.py
@@ -86,6 +86,7 @@ class GcpProvider(Provider):
client_secret: str = None,
refresh_token: str = None,
service_account_key: dict = None,
+ skip_api_check: bool = False,
):
"""
GCP Provider constructor
@@ -107,6 +108,7 @@ class GcpProvider(Provider):
client_secret: str
refresh_token: str
service_account_key: dict
+ skip_api_check: bool
Raises:
GCPNoAccesibleProjectsError if no project IDs can be accessed via Google Credentials
@@ -174,6 +176,10 @@ class GcpProvider(Provider):
gcp_config.DEFAULT_RETRY_ATTEMPTS = retries_max_attempts
logger.info(f"GCP retry attempts set to {retries_max_attempts}")
+ if skip_api_check:
+ logger.info("Skipping API active check for each service")
+ self.skip_api_check = skip_api_check
+
self._impersonated_service_account = impersonate_service_account
# Set the GCP credentials using the provided client_id, client_secret and refresh_token
gcp_credentials = None
diff --git a/prowler/providers/gcp/lib/arguments/arguments.py b/prowler/providers/gcp/lib/arguments/arguments.py
index 6866898caa..8172630132 100644
--- a/prowler/providers/gcp/lib/arguments/arguments.py
+++ b/prowler/providers/gcp/lib/arguments/arguments.py
@@ -57,3 +57,10 @@ def init_parser(self):
type=int,
help="Set the maximum attempts for the Google Cloud SDK retry config (Default: 3)",
)
+
+ gcp_config_subparser.add_argument(
+ "--skip-api-check",
+ action="store_true",
+ default=False,
+ help="Assume all APIs are active and skip the active API check for each service",
+ )
diff --git a/prowler/providers/gcp/lib/service/service.py b/prowler/providers/gcp/lib/service/service.py
index f32b29c2af..746952542f 100644
--- a/prowler/providers/gcp/lib/service/service.py
+++ b/prowler/providers/gcp/lib/service/service.py
@@ -29,7 +29,10 @@ class GCPService:
self.service, api_version, self.credentials
)
# Only project ids that have their API enabled will be scanned
- self.project_ids = self.__is_api_active__(provider.project_ids)
+ if provider.skip_api_check:
+ self.project_ids = provider.project_ids
+ else:
+ self.project_ids = self.__is_api_active__(provider.project_ids)
self.projects = provider.projects
self.default_project_id = provider.default_project_id
self.audit_config = provider.audit_config
diff --git a/tests/providers/gcp/gcp_provider_test.py b/tests/providers/gcp/gcp_provider_test.py
index e2196dddbf..63741ed647 100644
--- a/tests/providers/gcp/gcp_provider_test.py
+++ b/tests/providers/gcp/gcp_provider_test.py
@@ -987,3 +987,90 @@ class TestGCPProvider:
from prowler.providers.gcp.config import DEFAULT_RETRY_ATTEMPTS
assert DEFAULT_RETRY_ATTEMPTS == 3
+
+ def test_skip_api_check_argument(self):
+ """Test that skip_api_check argument is set correctly in GcpProvider"""
+
+ mocked_credentials = MagicMock()
+
+ mocked_credentials.refresh.return_value = None
+ mocked_credentials._service_account_email = "test-service-account-email"
+
+ arguments = Namespace()
+ arguments.project_id = []
+ arguments.excluded_project_id = []
+ arguments.organization_id = None
+ arguments.list_project_id = False
+ arguments.credentials_file = "test_credentials_file"
+ arguments.impersonate_service_account = ""
+ arguments.config_file = default_config_file_path
+ arguments.fixer_config = default_fixer_config_file_path
+ arguments.skip_api_check = True
+
+ projects = {
+ "test-project": GCPProject(
+ number="55555555",
+ id="project/55555555",
+ name="test-project",
+ labels={"test": "value"},
+ lifecycle_state="ACTIVE",
+ ),
+ }
+
+ mocked_service = MagicMock()
+
+ mocked_service.projects.list.return_value = MagicMock(
+ execute=MagicMock(return_value={"projects": projects})
+ )
+
+ mocked_is_api_active = MagicMock()
+ mocked_is_api_active.return_value = projects.keys()
+
+ with (
+ patch(
+ "prowler.providers.gcp.gcp_provider.GcpProvider.get_projects",
+ return_value=projects,
+ ),
+ patch(
+ "prowler.providers.gcp.gcp_provider.GcpProvider.update_projects_with_organizations",
+ return_value=None,
+ ),
+ patch(
+ "os.path.abspath",
+ return_value="test_credentials_file",
+ ),
+ patch(
+ "prowler.providers.gcp.gcp_provider.default",
+ return_value=(mocked_credentials, MagicMock()),
+ ),
+ patch(
+ "prowler.providers.gcp.gcp_provider.discovery.build",
+ return_value=mocked_service,
+ ),
+ patch(
+ "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__",
+ mocked_is_api_active,
+ ),
+ ):
+ gcp_provider = GcpProvider(
+ retries_max_attempts=None,
+ organization_id=arguments.organization_id,
+ project_ids=arguments.project_id,
+ excluded_project_ids=arguments.excluded_project_id,
+ credentials_file=arguments.credentials_file,
+ impersonate_service_account=arguments.impersonate_service_account,
+ list_project_ids=arguments.list_project_id,
+ config_path=arguments.config_file,
+ fixer_config=arguments.fixer_config,
+ client_id=None,
+ client_secret=None,
+ refresh_token=None,
+ skip_api_check=arguments.skip_api_check,
+ )
+
+ from prowler.providers.gcp.lib.service.service import GCPService
+
+ GCPService("testservice", gcp_provider)
+
+ assert gcp_provider.skip_api_check is True
+ mocked_is_api_active.assert_not_called()
From 43a14b89bc4311cb7b080a8a7521618a977eef5e Mon Sep 17 00:00:00 2001
From: Andoni Alonso <14891798+andoniaf@users.noreply.github.com>
Date: Wed, 27 Aug 2025 17:45:13 +0200
Subject: [PATCH 06/37] fix(github): provider always scans user instead of
organization when using provider UID (#8587)
---
api/CHANGELOG.md | 3 +++
api/src/backend/api/tests/test_utils.py | 4 ++++
api/src/backend/api/utils.py | 6 ++++++
docs/tutorials/github/getting-started-github.md | 4 ++--
prowler/CHANGELOG.md | 1 +
prowler/providers/github/github_provider.py | 2 +-
.../github/services/repository/repository_service.py | 3 ++-
7 files changed, 19 insertions(+), 4 deletions(-)
diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md
index adc80ed8cf..a68d0f39ba 100644
--- a/api/CHANGELOG.md
+++ b/api/CHANGELOG.md
@@ -9,6 +9,9 @@ All notable changes to the **Prowler API** are documented in this file.
- Integration with Amazon Security Hub, enabling sending findings to Security Hub [(#8365)](https://github.com/prowler-cloud/prowler/pull/8365)
- Generate ASFF output for AWS providers with SecurityHub integration enabled [(#8569)](https://github.com/prowler-cloud/prowler/pull/8569)
+### Fixed
+- GitHub provider always scans user instead of organization when using provider UID [(#8587)](https://github.com/prowler-cloud/prowler/pull/8587)
+
## [1.11.0] (Prowler 5.10.0)
### Added
diff --git a/api/src/backend/api/tests/test_utils.py b/api/src/backend/api/tests/test_utils.py
index e94504e902..cf58e41c0c 100644
--- a/api/src/backend/api/tests/test_utils.py
+++ b/api/src/backend/api/tests/test_utils.py
@@ -199,6 +199,10 @@ class TestGetProwlerProviderKwargs:
Provider.ProviderChoices.M365.value,
{},
),
+ (
+ Provider.ProviderChoices.GITHUB.value,
+ {"organizations": ["provider_uid"]},
+ ),
],
)
def test_get_prowler_provider_kwargs(self, provider_type, expected_extra_kwargs):
diff --git a/api/src/backend/api/utils.py b/api/src/backend/api/utils.py
index 1cc10674d7..b7c2f73ca3 100644
--- a/api/src/backend/api/utils.py
+++ b/api/src/backend/api/utils.py
@@ -120,6 +120,12 @@ def get_prowler_provider_kwargs(
}
elif provider.provider == Provider.ProviderChoices.KUBERNETES.value:
prowler_provider_kwargs = {**prowler_provider_kwargs, "context": provider.uid}
+ elif provider.provider == Provider.ProviderChoices.GITHUB.value:
+ if provider.uid:
+ prowler_provider_kwargs = {
+ **prowler_provider_kwargs,
+ "organizations": [provider.uid],
+ }
if mutelist_processor:
mutelist_content = mutelist_processor.configuration.get("Mutelist", {})
diff --git a/docs/tutorials/github/getting-started-github.md b/docs/tutorials/github/getting-started-github.md
index 48be3859bc..dca5ef584e 100644
--- a/docs/tutorials/github/getting-started-github.md
+++ b/docs/tutorials/github/getting-started-github.md
@@ -44,13 +44,13 @@ Personal Access Tokens provide the simplest GitHub authentication method, but it
To enable Prowler functionality, configure the following permissions:
- **Repository permissions:**
+ - **Administration**: Read-only access
- **Contents**: Read-only access
- **Metadata**: Read-only access
- **Pull requests**: Read-only access
- - **Security advisories**: Read-only access
- - **Statuses**: Read-only access
- **Organization permissions:**
+ - **Administration**: Read-only access
- **Members**: Read-only access
- **Account permissions:**
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index 7323a56ad2..400bf81639 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -23,6 +23,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
### Fixed
- Resource metadata error in `s3_bucket_shadow_resource_vulnerability` check [(#8572)](https://github.com/prowler-cloud/prowler/pull/8572)
+- GitHub App authentication through API fails with auth_method validation error [(#8587)](https://github.com/prowler-cloud/prowler/pull/8587)
---
diff --git a/prowler/providers/github/github_provider.py b/prowler/providers/github/github_provider.py
index 46dff4ab09..384c0b9de0 100644
--- a/prowler/providers/github/github_provider.py
+++ b/prowler/providers/github/github_provider.py
@@ -156,7 +156,7 @@ class GithubProvider(Provider):
self._auth_method = "Personal Access Token"
elif oauth_app_token:
self._auth_method = "OAuth App Token"
- elif github_app_id and github_app_key:
+ elif github_app_id and (github_app_key or github_app_key_content):
self._auth_method = "GitHub App Token"
elif environ.get("GITHUB_PERSONAL_ACCESS_TOKEN", ""):
self._auth_method = "Environment Variable for Personal Access Token"
diff --git a/prowler/providers/github/services/repository/repository_service.py b/prowler/providers/github/services/repository/repository_service.py
index 501bd72601..4ca18813c5 100644
--- a/prowler/providers/github/services/repository/repository_service.py
+++ b/prowler/providers/github/services/repository/repository_service.py
@@ -153,9 +153,10 @@ class Repository(GithubService):
self._handle_github_api_error(
error, "processing organization", org_name
)
- if (
+ elif (
isinstance(self.provider.identity, GithubAppIdentityInfo)
and self.provider.identity.installations
+ and not self.provider.repositories
):
logger.info(
f"Filtering for repositories in the organizations or accounts that the GitHub App is installed in: {', '.join(self.provider.identity.installations)}"
From fce43cea162cd62aa71f5dff69fcb854bef917ec Mon Sep 17 00:00:00 2001
From: Sergio Garcia
Date: Wed, 27 Aug 2025 17:57:07 +0200
Subject: [PATCH 07/37] chore: update changelog (#8593)
---
prowler/CHANGELOG.md | 8 +-------
1 file changed, 1 insertion(+), 7 deletions(-)
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index 400bf81639..78519335be 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -2,7 +2,7 @@
All notable changes to the **Prowler SDK** are documented in this file.
-## [v5.11.0] (Prowler UNRELEASED)
+## [v5.11.0] (Prowler v5.11.0)
### Added
- Certificate authentication for M365 provider [(#8404)](https://github.com/prowler-cloud/prowler/pull/8404)
@@ -24,12 +24,6 @@ All notable changes to the **Prowler SDK** are documented in this file.
### Fixed
- Resource metadata error in `s3_bucket_shadow_resource_vulnerability` check [(#8572)](https://github.com/prowler-cloud/prowler/pull/8572)
- GitHub App authentication through API fails with auth_method validation error [(#8587)](https://github.com/prowler-cloud/prowler/pull/8587)
-
----
-
-## [v5.10.3] (Prowler UNRELEASED)
-
-### Fixed
- AWS resource-arn filtering [(#8533)](https://github.com/prowler-cloud/prowler/pull/8533)
- GitHub App authentication for GitHub provider [(#8529)](https://github.com/prowler-cloud/prowler/pull/8529)
- List all accessible organizations in GitHub provider [(#8535)](https://github.com/prowler-cloud/prowler/pull/8535)
From 5038afeb26a3ace9a1000ac6a9c9cf720af70422 Mon Sep 17 00:00:00 2001
From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
Date: Wed, 27 Aug 2025 18:42:34 +0200
Subject: [PATCH 08/37] fix(security-hub): copy updated (#8594)
---
.../security-hub/security-hub-integration-form.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/ui/components/integrations/security-hub/security-hub-integration-form.tsx b/ui/components/integrations/security-hub/security-hub-integration-form.tsx
index d46ed9f3e6..4db090a766 100644
--- a/ui/components/integrations/security-hub/security-hub-integration-form.tsx
+++ b/ui/components/integrations/security-hub/security-hub-integration-form.tsx
@@ -348,7 +348,9 @@ export const SecurityHubIntegrationForm = ({
onValueChange={field.onChange}
size="sm"
>
- Send only Failed Findings
+
+ Send only findings with status FAIL
+
)}
From c69187f484c1af3b13cc7634277631887f7e0cc8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Adri=C3=A1n=20Jes=C3=BAs=20Pe=C3=B1a=20Rodr=C3=ADguez?=
Date: Thu, 28 Aug 2025 10:25:08 +0200
Subject: [PATCH 09/37] chore: prepare api changelog for 5.11 (#8596)
---
api/CHANGELOG.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md
index a68d0f39ba..ef66379241 100644
--- a/api/CHANGELOG.md
+++ b/api/CHANGELOG.md
@@ -2,7 +2,7 @@
All notable changes to the **Prowler API** are documented in this file.
-## [1.12.0] (Prowler 5.11.0 - UNRELEASED)
+## [1.12.0] (Prowler 5.11.0)
### Added
- Lighthouse support for OpenAI GPT-5 [(#8527)](https://github.com/prowler-cloud/prowler/pull/8527)
From f484b83f153bc821e69fe2d7d9c5b81993c2c0cc Mon Sep 17 00:00:00 2001
From: Sergio Garcia
Date: Thu, 28 Aug 2025 11:42:07 +0200
Subject: [PATCH 10/37] feat(azure): Add APIM threat detection for LLM jacking
attacks (#8571)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Rubén De la Torre Vico
---
docs/tutorials/configuration_file.md | 45 ++
poetry.lock | 58 ++-
prowler/CHANGELOG.md | 1 +
prowler/config/config.yaml | 39 ++
.../providers/azure/lib/service/service.py | 3 +
.../providers/azure/services/apim/__init__.py | 0
.../azure/services/apim/apim_client.py | 4 +
.../azure/services/apim/apim_service.py | 251 +++++++++
.../__init__.py | 0
...threat_detection_llm_jacking.metadata.json | 34 ++
.../apim_threat_detection_llm_jacking.py | 107 ++++
.../providers/azure/services/logs/__init__.py | 0
.../services/logs/loganalytics_client.py | 4 +
.../azure/services/logs/logs_service.py | 15 +
.../azure/services/logs/logsquery_client.py | 4 +
.../azure/services/monitor/monitor_service.py | 2 +
pyproject.toml | 3 +
tests/providers/azure/azure_provider_test.py | 24 +
.../providers/azure/services/apim/__init__.py | 0
.../azure/services/apim/apim_service_test.py | 318 ++++++++++++
.../__init__.py | 0
.../apim_threat_detection_llm_jacking_test.py | 491 ++++++++++++++++++
22 files changed, 1399 insertions(+), 4 deletions(-)
create mode 100644 prowler/providers/azure/services/apim/__init__.py
create mode 100644 prowler/providers/azure/services/apim/apim_client.py
create mode 100644 prowler/providers/azure/services/apim/apim_service.py
create mode 100644 prowler/providers/azure/services/apim/apim_threat_detection_llm_jacking/__init__.py
create mode 100644 prowler/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking.metadata.json
create mode 100644 prowler/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking.py
create mode 100644 prowler/providers/azure/services/logs/__init__.py
create mode 100644 prowler/providers/azure/services/logs/loganalytics_client.py
create mode 100644 prowler/providers/azure/services/logs/logs_service.py
create mode 100644 prowler/providers/azure/services/logs/logsquery_client.py
create mode 100644 tests/providers/azure/services/apim/__init__.py
create mode 100644 tests/providers/azure/services/apim/apim_service_test.py
create mode 100644 tests/providers/azure/services/apim/apim_threat_detection_llm_jacking/__init__.py
create mode 100644 tests/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking_test.py
diff --git a/docs/tutorials/configuration_file.md b/docs/tutorials/configuration_file.md
index b99e5064d0..bdd0254f41 100644
--- a/docs/tutorials/configuration_file.md
+++ b/docs/tutorials/configuration_file.md
@@ -83,6 +83,9 @@ The following list includes all the Azure checks with configurable variables tha
| `vm_sufficient_daily_backup_retention_period` | `vm_backup_min_daily_retention_days` | Integer |
| `vm_desired_sku_size` | `desired_vm_sku_sizes` | List of Strings |
| `defender_attack_path_notifications_properly_configured` | `defender_attack_path_minimal_risk_level` | String |
+| `apim_threat_detection_llm_jacking` | `apim_threat_detection_llm_jacking_threshold` | Float |
+| `apim_threat_detection_llm_jacking` | `apim_threat_detection_llm_jacking_minutes` | Integer |
+| `apim_threat_detection_llm_jacking` | `apim_threat_detection_llm_jacking_actions` | List of Strings |
## GCP
@@ -494,6 +497,48 @@ azure:
"Standard_DS3_v2",
"Standard_D4s_v3",
]
+ # Azure VM Backup Configuration
+ # azure.vm_sufficient_daily_backup_retention_period
+ vm_backup_min_daily_retention_days: 7
+
+ # Azure API Management Threat Detection Configuration
+ # azure.apim_threat_detection_llm_jacking
+ apim_threat_detection_llm_jacking_threshold: 0.1
+ apim_threat_detection_llm_jacking_minutes: 1440
+ apim_threat_detection_llm_jacking_actions:
+ [
+ # OpenAI API endpoints
+ "ImageGenerations_Create",
+ "ChatCompletions_Create",
+ "Completions_Create",
+ "Embeddings_Create",
+ "FineTuning_Jobs_Create",
+ "Models_List",
+
+ # Azure OpenAI endpoints
+ "Deployments_List",
+ "Deployments_Get",
+ "Deployments_Create",
+ "Deployments_Delete",
+
+ # Anthropic endpoints
+ "Messages_Create",
+ "Claude_Create",
+
+ # Google AI endpoints
+ "GenerateContent",
+ "GenerateText",
+ "GenerateImage",
+
+ # Meta AI endpoints
+ "Llama_Create",
+ "CodeLlama_Create",
+
+ # Other LLM endpoints
+ "Gemini_Generate",
+ "Claude_Generate",
+ "Llama_Generate"
+ ]
# GCP Configuration
gcp:
diff --git a/poetry.lock b/poetry.lock
index ab1e7d5e4c..9d4fb31e02 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand.
+# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
[[package]]
name = "about-time"
@@ -394,6 +394,24 @@ cryptography = ">=2.1.4"
isodate = ">=0.6.1"
typing-extensions = ">=4.0.1"
+[[package]]
+name = "azure-mgmt-apimanagement"
+version = "5.0.0"
+description = "Microsoft Azure API Management Client Library for Python"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "azure_mgmt_apimanagement-5.0.0-py3-none-any.whl", hash = "sha256:b88c42a392333b60722fb86f15d092dfc19a8d67510dccd15c217381dff4e6ec"},
+ {file = "azure_mgmt_apimanagement-5.0.0.tar.gz", hash = "sha256:0ab7fe17e70fe3154cd840ff47d19d7a4610217003eaa7c21acf3511a6e57999"},
+]
+
+[package.dependencies]
+azure-common = ">=1.1"
+azure-mgmt-core = ">=1.3.2"
+isodate = ">=0.6.1"
+typing-extensions = ">=4.6.0"
+
[[package]]
name = "azure-mgmt-applicationinsights"
version = "4.1.0"
@@ -551,6 +569,23 @@ azure-mgmt-core = ">=1.3.2"
isodate = ">=0.6.1"
typing-extensions = ">=4.6.0"
+[[package]]
+name = "azure-mgmt-loganalytics"
+version = "12.0.0"
+description = "Microsoft Azure Log Analytics Management Client Library for Python"
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+ {file = "azure-mgmt-loganalytics-12.0.0.zip", hash = "sha256:da128a7e0291be7fa2063848df92a9180cf5c16d42adc09d2bc2efd711536bfb"},
+ {file = "azure_mgmt_loganalytics-12.0.0-py2.py3-none-any.whl", hash = "sha256:75ac1d47dd81179905c40765be8834643d8994acff31056ddc1863017f3faa02"},
+]
+
+[package.dependencies]
+azure-common = ">=1.1,<2.0"
+azure-mgmt-core = ">=1.2.0,<2.0.0"
+msrest = ">=0.6.21"
+
[[package]]
name = "azure-mgmt-monitor"
version = "6.0.2"
@@ -761,6 +796,23 @@ azure-mgmt-core = ">=1.3.2"
isodate = ">=0.6.1"
typing-extensions = ">=4.6.0"
+[[package]]
+name = "azure-monitor-query"
+version = "2.0.0"
+description = "Microsoft Corporation Azure Monitor Query Client Library for Python"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "azure_monitor_query-2.0.0-py3-none-any.whl", hash = "sha256:8f52d581271d785e12f49cd5aaa144b8910fb843db2373855a7ef94c7fc462ea"},
+ {file = "azure_monitor_query-2.0.0.tar.gz", hash = "sha256:7b05f2fcac4fb67fc9f77a7d4c5d98a0f3099fb73b57c69ec1b080773994671b"},
+]
+
+[package.dependencies]
+azure-core = ">=1.30.0"
+isodate = ">=0.6.1"
+typing-extensions = ">=4.6.0"
+
[[package]]
name = "azure-storage-blob"
version = "12.24.1"
@@ -2352,8 +2404,6 @@ python-versions = "*"
groups = ["dev"]
files = [
{file = "jsonpath-ng-1.7.0.tar.gz", hash = "sha256:f6f5f7fd4e5ff79c785f1573b394043b39849fb2bb47bcead935d12b00beab3c"},
- {file = "jsonpath_ng-1.7.0-py2-none-any.whl", hash = "sha256:898c93fc173f0c336784a3fa63d7434297544b7198124a68f9a3ef9597b0ae6e"},
- {file = "jsonpath_ng-1.7.0-py3-none-any.whl", hash = "sha256:f3d7f9e848cba1b6da28c55b1c26ff915dc9e0b1ba7e752a53d6da8d5cbd00b6"},
]
[package.dependencies]
@@ -5841,4 +5891,4 @@ type = ["pytest-mypy"]
[metadata]
lock-version = "2.1"
python-versions = ">3.9.1,<3.13"
-content-hash = "fdd2cbdb6913d0dd8d05030ee41c0d36d3954e473783d43b730ec163e697ec15"
+content-hash = "aea38b0311bfabac00d4bf9ee5d2fa0a7f3e32dd2ee5c5d27eb54c69a80b35e9"
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index 78519335be..e54bc46d2f 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -15,6 +15,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `eks_cluster_deletion_protection_enabled` check for AWS provider [(#8536)](https://github.com/prowler-cloud/prowler/pull/8536)
- ECS privilege escalation patterns (StartTask and RunTask) for AWS provider [(#8541)](https://github.com/prowler-cloud/prowler/pull/8541)
- Resource Explorer enumeration v2 API actions in `cloudtrail_threat_detection_enumeration` check [(#8557)](https://github.com/prowler-cloud/prowler/pull/8557)
+- `apim_threat_detection_llm_jacking` check for Azure provider [(#8571)](https://github.com/prowler-cloud/prowler/pull/8571)
- GCP `--skip-api-check` command line flag [(#8575)](https://github.com/prowler-cloud/prowler/pull/8575)
### Changed
diff --git a/prowler/config/config.yaml b/prowler/config/config.yaml
index 7932131af6..33da81eaab 100644
--- a/prowler/config/config.yaml
+++ b/prowler/config/config.yaml
@@ -463,6 +463,45 @@ azure:
# azure.vm_sufficient_daily_backup_retention_period
vm_backup_min_daily_retention_days: 7
+ # Azure API Management Configuration
+ # azure.apim_threat_detection_llm_jacking
+ apim_threat_detection_llm_jacking_threshold: 0.1
+ apim_threat_detection_llm_jacking_minutes: 1440
+ apim_threat_detection_llm_jacking_actions:
+ [
+ # OpenAI API endpoints
+ "ImageGenerations_Create",
+ "ChatCompletions_Create",
+ "Completions_Create",
+ "Embeddings_Create",
+ "FineTuning_Jobs_Create",
+ "Models_List",
+
+ # Azure OpenAI endpoints
+ "Deployments_List",
+ "Deployments_Get",
+ "Deployments_Create",
+ "Deployments_Delete",
+
+ # Anthropic endpoints
+ "Messages_Create",
+ "Claude_Create",
+
+ # Google AI endpoints
+ "GenerateContent",
+ "GenerateText",
+ "GenerateImage",
+
+ # Meta AI endpoints
+ "Llama_Create",
+ "CodeLlama_Create",
+
+ # Other LLM endpoints
+ "Gemini_Generate",
+ "Claude_Generate",
+ "Llama_Generate"
+ ]
+
# GCP Configuration
gcp:
# GCP Compute Configuration
diff --git a/prowler/providers/azure/lib/service/service.py b/prowler/providers/azure/lib/service/service.py
index 83ed9031f4..b91fd51f56 100644
--- a/prowler/providers/azure/lib/service/service.py
+++ b/prowler/providers/azure/lib/service/service.py
@@ -25,6 +25,9 @@ class AzureService:
try:
if "GraphServiceClient" in str(service):
clients.update({identity.tenant_domain: service(credentials=session)})
+ elif "LogsQueryClient" in str(service):
+ for display_name, id in identity.subscriptions.items():
+ clients.update({display_name: service(credential=session)})
else:
for display_name, id in identity.subscriptions.items():
clients.update(
diff --git a/prowler/providers/azure/services/apim/__init__.py b/prowler/providers/azure/services/apim/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/azure/services/apim/apim_client.py b/prowler/providers/azure/services/apim/apim_client.py
new file mode 100644
index 0000000000..d435153f3a
--- /dev/null
+++ b/prowler/providers/azure/services/apim/apim_client.py
@@ -0,0 +1,4 @@
+from prowler.providers.azure.services.apim.apim_service import APIM
+from prowler.providers.common.provider import Provider
+
+apim_client = APIM(Provider.get_global_provider())
diff --git a/prowler/providers/azure/services/apim/apim_service.py b/prowler/providers/azure/services/apim/apim_service.py
new file mode 100644
index 0000000000..8c222cbe6d
--- /dev/null
+++ b/prowler/providers/azure/services/apim/apim_service.py
@@ -0,0 +1,251 @@
+from datetime import datetime, timedelta
+from typing import List, Optional
+
+from azure.mgmt.apimanagement import ApiManagementClient
+from pydantic.v1 import BaseModel
+
+from prowler.lib.logger import logger
+from prowler.providers.azure.azure_provider import AzureProvider
+from prowler.providers.azure.lib.service.service import AzureService
+from prowler.providers.azure.services.logs.loganalytics_client import (
+ loganalytics_client,
+)
+from prowler.providers.azure.services.logs.logsquery_client import logsquery_client
+from prowler.providers.azure.services.monitor.monitor_client import monitor_client
+
+
+class APIMInstance(BaseModel):
+ """APIM Instance model"""
+
+ id: str
+ name: str
+ location: str
+ log_analytics_workspace_id: Optional[str] = None
+
+
+class LogsQueryLogEntry(BaseModel):
+ """Represents a log entry from Azure Log Analytics query results."""
+
+ time_generated: datetime
+ operation_id: str
+ caller_ip_address: str
+ correlation_id: str
+
+
+class APIM(AzureService):
+ def __init__(self, provider: AzureProvider):
+ """Initialize the APIM service client.
+
+ Args:
+ provider: The Azure provider instance containing authentication and client configuration
+ """
+ super().__init__(ApiManagementClient, provider)
+ self.instances = self._get_instances()
+
+ def _get_workspace_customer_id(
+ self, subscription: str, workspace_arm_id: str
+ ) -> Optional[str]:
+ """Get the Customer ID (GUID) for a workspace from its full ARM ID.
+
+ This method extracts the resource group and workspace name from the ARM ID
+ and queries the Log Analytics client to retrieve the customer ID (GUID)
+ needed for workspace-specific queries.
+
+ Args:
+ subscription: The Azure subscription ID
+ workspace_arm_id: The full ARM ID of the Log Analytics workspace
+
+ Returns:
+ The customer ID (GUID) of the workspace if successful, None otherwise
+ """
+ try:
+ resource_group = workspace_arm_id.split("/")[4]
+ workspace_name = workspace_arm_id.split("/")[-1]
+
+ workspace = loganalytics_client.clients[subscription].workspaces.get(
+ resource_group_name=resource_group, workspace_name=workspace_name
+ )
+ return workspace.customer_id
+ except Exception as error:
+ logger.error(
+ f"Failed to get customer ID for workspace {workspace_arm_id}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+ return None
+
+ def _get_log_analytics_workspace_id(
+ self, instance_id: str, subscription: str
+ ) -> Optional[str]:
+ """Retrieve the Log Analytics workspace ARM ID from an APIM instance's diagnostic settings.
+
+ This method queries the Azure Monitor diagnostic settings for a specific APIM
+ instance to find the configured Log Analytics workspace. It specifically looks
+ for diagnostic settings that have GatewayLogs enabled, which are essential for
+ monitoring APIM API calls and operations.
+
+ Args:
+ instance_id: The ARM ID of the APIM instance
+ subscription: The Azure subscription ID
+
+ Returns:
+ The ARM ID of the Log Analytics workspace if diagnostic settings are found
+ and GatewayLogs are enabled, None otherwise
+ """
+ try:
+ diagnostic_settings = monitor_client.diagnostic_settings_with_uri(
+ subscription, instance_id, monitor_client.clients[subscription]
+ )
+ for setting in diagnostic_settings:
+ if setting.workspace_id and setting.logs:
+ for log_setting in setting.logs:
+ if (
+ log_setting.enabled
+ and log_setting.category == "GatewayLogs"
+ ):
+ logger.info(
+ f"Found enabled Log Analytics workspace for APIM instance {instance_id} with category {log_setting.category}"
+ )
+ return setting.workspace_id
+ except Exception as error:
+ logger.error(
+ f"Failed to get diagnostic settings for {instance_id}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+ return None
+
+ def _get_instances(self):
+ """Get all APIM instances and their configured Log Analytics workspace.
+
+ This method iterates through all accessible Azure subscriptions and retrieves
+ all APIM instances within each subscription. For each instance, it also
+ determines the associated Log Analytics workspace by checking diagnostic
+ settings. The method populates the instances dictionary with APIMInstance
+ objects containing all relevant metadata and configuration.
+
+ Returns:
+ A dictionary mapping subscription IDs to lists of APIMInstance objects.
+ Each APIMInstance contains the instance details and its associated
+ Log Analytics workspace ID if configured.
+ """
+ logger.info("APIM - Getting instances...")
+ instances = {}
+
+ for subscription, client in self.clients.items():
+ try:
+ instances.update({subscription: []})
+ apim_instances = client.api_management_service.list()
+
+ for instance in apim_instances:
+ workspace_id = self._get_log_analytics_workspace_id(
+ instance.id, subscription
+ )
+ instances[subscription].append(
+ APIMInstance(
+ id=instance.id,
+ name=instance.name,
+ location=instance.location,
+ log_analytics_workspace_id=workspace_id,
+ )
+ )
+ except Exception as error:
+ logger.error(
+ f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+
+ return instances
+
+ def query_logs(
+ self,
+ subscription: str,
+ query: str,
+ timespan: timedelta,
+ workspace_customer_id: str,
+ ) -> List[LogsQueryLogEntry]:
+ """Query a specific Log Analytics workspace using its Customer ID (GUID).
+
+ This method executes Kusto Query Language (KQL) queries against a specific
+ Log Analytics workspace. It's used to retrieve log data for analysis and
+ monitoring purposes. The method handles the response parsing and converts
+ the tabular results into a list of dictionaries for easy consumption.
+
+ Args:
+ subscription: The Azure subscription ID
+ query: The KQL query string to execute
+ timespan: The time range for the query as a timedelta
+ workspace_customer_id: The customer ID (GUID) of the Log Analytics workspace
+
+ Returns:
+ A list of dictionaries where each dictionary represents a row from the
+ query results. The keys are the column names from the query response.
+ Returns an empty list if the query fails or returns no results.
+ """
+ try:
+ response = logsquery_client.clients[subscription].query_workspace(
+ workspace_id=workspace_customer_id,
+ query=query,
+ timespan=timespan,
+ )
+
+ if response.tables:
+ columns = response.tables[0].columns
+ rows = response.tables[0].rows
+ result = []
+
+ for row in rows:
+ # Create a mapping from Azure column names to our snake_case field names
+ row_dict = dict(zip(columns, row))
+ mapped_dict = {
+ "time_generated": row_dict.get("TimeGenerated"),
+ "operation_id": row_dict.get("OperationId"),
+ "caller_ip_address": row_dict.get("CallerIpAddress"),
+ "correlation_id": row_dict.get("CorrelationId"),
+ }
+ result.append(LogsQueryLogEntry(**mapped_dict))
+
+ return result
+
+ except Exception as error:
+ logger.error(
+ f"Failed to query Log Analytics workspace with customer ID {workspace_customer_id}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+ return []
+
+ def get_llm_operations_logs(
+ self, subscription: str, instance: APIMInstance, minutes: int = 1440
+ ) -> List[LogsQueryLogEntry]:
+ """Get LLM-related operations from the APIM instance's specific Log Analytics workspace.
+
+ This method retrieves logs related to Large Language Model (LLM) operations
+ from a specific APIM instance. It queries the GatewayLogs table in the
+ associated Log Analytics workspace to find API calls and operations that
+ may be related to LLM services. The method automatically handles the
+ translation from workspace ARM ID to customer ID for querying.
+
+ Args:
+ subscription: The Azure subscription ID
+ instance: The APIMInstance object containing the instance details
+ minutes: The time range in minutes to look back (default: 1440 = 24 hours)
+
+ Returns:
+ A list of dictionaries containing log entries with fields like
+ time_generated, operation_id, caller_ip_address, and correlation_id.
+ Returns an empty list if no workspace is configured or if the query fails.
+ """
+ if not instance.log_analytics_workspace_id:
+ logger.warning(
+ f"APIM instance {instance.name} has no configured Log Analytics workspace."
+ )
+ return []
+
+ # Translate the workspace ARM ID to the Customer ID (GUID) before querying
+ workspace_customer_id = self._get_workspace_customer_id(
+ subscription, instance.log_analytics_workspace_id
+ )
+ if not workspace_customer_id:
+ return []
+
+ query = f"""
+ ApiManagementGatewayLogs
+ | where _ResourceId has '{instance.id}'
+ | project TimeGenerated, OperationId, CallerIpAddress, CorrelationId
+ """
+ timespan = timedelta(minutes=minutes)
+ return self.query_logs(subscription, query, timespan, workspace_customer_id)
diff --git a/prowler/providers/azure/services/apim/apim_threat_detection_llm_jacking/__init__.py b/prowler/providers/azure/services/apim/apim_threat_detection_llm_jacking/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking.metadata.json b/prowler/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking.metadata.json
new file mode 100644
index 0000000000..1673f9cf64
--- /dev/null
+++ b/prowler/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking.metadata.json
@@ -0,0 +1,34 @@
+{
+ "Provider": "azure",
+ "CheckID": "apim_threat_detection_llm_jacking",
+ "CheckTitle": "Ensure Azure API Management is protected against LLM Jacking attacks",
+ "CheckType": [],
+ "ServiceName": "apim",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
+ "ResourceType": "Azure API Management Instance",
+ "Description": "This check analyzes Azure API Management diagnostic logs in Log Analytics to detect potential LLM Jacking attacks by monitoring the frequency of LLM-related operations (ImageGenerations_Create, ChatCompletions_Create, Completions_Create) from individual IP addresses within a configurable time window.",
+ "Risk": "LLM Jacking attacks can lead to unauthorized access to AI models, potential data exfiltration, increased costs, and abuse of AI services. Attackers may use these endpoints to generate content, bypass rate limits, or access premium AI capabilities without proper authorization.",
+ "RelatedUrl": "https://learn.microsoft.com/en-us/azure/api-management/monitor-api-management",
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "To protect against LLM Jacking attacks: 1. Enable diagnostic logging for APIM instances and send logs to Log Analytics workspace 2. Configure appropriate thresholds for LLM operation frequency monitoring 3. Set up alerts for suspicious activity patterns 4. Implement rate limiting and IP allowlisting for sensitive AI endpoints 5. Regularly review and analyze APIM access logs for anomalies",
+ "Url": "https://learn.microsoft.com/en-us/azure/api-management/monitor-api-management"
+ }
+ },
+ "Categories": [
+ "threat-detection",
+ "monitoring",
+ "logging"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "This check requires: 1. APIM diagnostic logging to be enabled and configured to send logs to Log Analytics workspace 2. Log Analytics workspace ID and key to be configured in the audit configuration 3. Appropriate permissions to query Log Analytics workspace"
+}
diff --git a/prowler/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking.py b/prowler/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking.py
new file mode 100644
index 0000000000..e511b9859b
--- /dev/null
+++ b/prowler/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking.py
@@ -0,0 +1,107 @@
+from typing import List
+
+from prowler.lib.check.models import Check, Check_Report_Azure
+from prowler.providers.azure.services.apim.apim_client import apim_client
+from prowler.providers.azure.services.apim.apim_service import LogsQueryLogEntry
+
+
+class apim_threat_detection_llm_jacking(Check):
+ def execute(self):
+ findings = []
+
+ # Get configuration from audit config with defaults
+ threshold = float(
+ getattr(apim_client, "audit_config", {}).get(
+ "apim_threat_detection_llm_jacking_threshold", 0.1
+ )
+ )
+ threat_detection_minutes = getattr(apim_client, "audit_config", {}).get(
+ "apim_threat_detection_llm_jacking_minutes", 1440
+ )
+ monitored_actions = getattr(apim_client, "audit_config", {}).get(
+ "apim_threat_detection_llm_jacking_actions",
+ [
+ # OpenAI API endpoints
+ "ImageGenerations_Create",
+ "ChatCompletions_Create",
+ "Completions_Create",
+ "Embeddings_Create",
+ "FineTuning_Jobs_Create",
+ "Models_List",
+ # Azure OpenAI endpoints
+ "Deployments_List",
+ "Deployments_Get",
+ "Deployments_Create",
+ "Deployments_Delete",
+ # Anthropic endpoints
+ "Messages_Create",
+ "Claude_Create",
+ # Google AI endpoints
+ "GenerateContent",
+ "GenerateText",
+ "GenerateImage",
+ # Meta AI endpoints
+ "Llama_Create",
+ "CodeLlama_Create",
+ # Other LLM endpoints
+ "Gemini_Generate",
+ "Claude_Generate",
+ "Llama_Generate",
+ ],
+ )
+
+ # 1. Aggregate logs from all APIM instances first
+ all_llm_logs: List[LogsQueryLogEntry] = []
+ for subscription, instances in apim_client.instances.items():
+ for instance in instances:
+ if instance.log_analytics_workspace_id:
+ logs = apim_client.get_llm_operations_logs(
+ subscription, instance, threat_detection_minutes
+ )
+ all_llm_logs.extend(logs)
+
+ # 2. Perform a single, global analysis on all collected logs
+ potential_llm_jacking_attackers = {}
+ for log in all_llm_logs:
+ operation_name = log.operation_id
+ caller_ip = log.caller_ip_address
+
+ if operation_name in monitored_actions and caller_ip:
+ # Use IP address as the principal identifier
+ if caller_ip not in potential_llm_jacking_attackers:
+ potential_llm_jacking_attackers[caller_ip] = set()
+ potential_llm_jacking_attackers[caller_ip].add(operation_name)
+
+ # 3. Check each principal against the threshold and report failures
+ found_potential_llm_jacking_attackers = False
+ for (
+ principal_ip,
+ distinct_actions,
+ ) in potential_llm_jacking_attackers.items():
+ action_ratio = round(len(distinct_actions) / len(monitored_actions), 2)
+
+ if action_ratio > threshold:
+ found_potential_llm_jacking_attackers = True
+ # Build Identity resource for the report
+ resource = {
+ "name": principal_ip,
+ "id": principal_ip,
+ }
+ # Report against the subscription, identifying the offending principal (IP)
+ report = Check_Report_Azure(self.metadata(), resource=resource)
+ report.subscription = subscription
+ report.status = "FAIL"
+ report.status_extended = f"Potential LLM Jacking attack detected from IP address {principal_ip} with a threshold of {action_ratio}."
+ findings.append(report)
+
+ # 4. If no threats were found after checking all principals, create a single PASS report
+ if not found_potential_llm_jacking_attackers:
+ report = Check_Report_Azure(self.metadata(), resource={})
+ report.resource_name = "Azure API Management"
+ report.resource_id = "Azure API Management"
+ report.subscription = subscription
+ report.status = "PASS"
+ report.status_extended = f"No potential LLM Jacking attacks detected across all monitored APIM instances in the last {threat_detection_minutes} minutes."
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/azure/services/logs/__init__.py b/prowler/providers/azure/services/logs/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/azure/services/logs/loganalytics_client.py b/prowler/providers/azure/services/logs/loganalytics_client.py
new file mode 100644
index 0000000000..dc7ef16f68
--- /dev/null
+++ b/prowler/providers/azure/services/logs/loganalytics_client.py
@@ -0,0 +1,4 @@
+from prowler.providers.azure.services.logs.logs_service import LogAnalytics
+from prowler.providers.common.provider import Provider
+
+loganalytics_client = LogAnalytics(Provider.get_global_provider())
diff --git a/prowler/providers/azure/services/logs/logs_service.py b/prowler/providers/azure/services/logs/logs_service.py
new file mode 100644
index 0000000000..e0a2cf07e5
--- /dev/null
+++ b/prowler/providers/azure/services/logs/logs_service.py
@@ -0,0 +1,15 @@
+from azure.mgmt.loganalytics import LogAnalyticsManagementClient
+from azure.monitor.query import LogsQueryClient
+
+from prowler.providers.azure.azure_provider import AzureProvider
+from prowler.providers.azure.lib.service.service import AzureService
+
+
+class LogsQuery(AzureService):
+ def __init__(self, provider: AzureProvider):
+ super().__init__(LogsQueryClient, provider)
+
+
+class LogAnalytics(AzureService):
+ def __init__(self, provider: AzureProvider):
+ super().__init__(LogAnalyticsManagementClient, provider)
diff --git a/prowler/providers/azure/services/logs/logsquery_client.py b/prowler/providers/azure/services/logs/logsquery_client.py
new file mode 100644
index 0000000000..0aabde0c24
--- /dev/null
+++ b/prowler/providers/azure/services/logs/logsquery_client.py
@@ -0,0 +1,4 @@
+from prowler.providers.azure.services.logs.logs_service import LogsQuery
+from prowler.providers.common.provider import Provider
+
+logsquery_client = LogsQuery(Provider.get_global_provider())
diff --git a/prowler/providers/azure/services/monitor/monitor_service.py b/prowler/providers/azure/services/monitor/monitor_service.py
index b4542a7b14..948b0cceec 100644
--- a/prowler/providers/azure/services/monitor/monitor_service.py
+++ b/prowler/providers/azure/services/monitor/monitor_service.py
@@ -56,6 +56,7 @@ class Monitor(AzureService):
for log_settings in (getattr(setting, "logs", []) or [])
],
storage_account_id=setting.storage_account_id,
+ workspace_id=getattr(setting, "workspace_id", None),
)
)
except Exception as error:
@@ -112,6 +113,7 @@ class DiagnosticSetting:
storage_account_name: str
logs: List[LogSettings]
name: str
+ workspace_id: Optional[str] = None
@dataclass
diff --git a/pyproject.toml b/pyproject.toml
index 4308b55f91..738d9042d5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -35,6 +35,9 @@ dependencies = [
"azure-mgmt-storage==22.1.1",
"azure-mgmt-subscription==3.1.1",
"azure-mgmt-web==8.0.0",
+ "azure-mgmt-apimanagement==5.0.0",
+ "azure-mgmt-loganalytics==12.0.0",
+ "azure-monitor-query==2.0.0",
"azure-storage-blob==12.24.1",
"boto3==1.39.15",
"botocore==1.39.15",
diff --git a/tests/providers/azure/azure_provider_test.py b/tests/providers/azure/azure_provider_test.py
index 5796b15ada..a0c8a5e5d4 100644
--- a/tests/providers/azure/azure_provider_test.py
+++ b/tests/providers/azure/azure_provider_test.py
@@ -92,6 +92,30 @@ class TestAzureProvider:
"Standard_D4s_v3",
],
"defender_attack_path_minimal_risk_level": "High",
+ "apim_threat_detection_llm_jacking_threshold": 0.1,
+ "apim_threat_detection_llm_jacking_minutes": 1440,
+ "apim_threat_detection_llm_jacking_actions": [
+ "ImageGenerations_Create",
+ "ChatCompletions_Create",
+ "Completions_Create",
+ "Embeddings_Create",
+ "FineTuning_Jobs_Create",
+ "Models_List",
+ "Deployments_List",
+ "Deployments_Get",
+ "Deployments_Create",
+ "Deployments_Delete",
+ "Messages_Create",
+ "Claude_Create",
+ "GenerateContent",
+ "GenerateText",
+ "GenerateImage",
+ "Llama_Create",
+ "CodeLlama_Create",
+ "Gemini_Generate",
+ "Claude_Generate",
+ "Llama_Generate",
+ ],
}
def test_azure_provider_not_auth_methods(self):
diff --git a/tests/providers/azure/services/apim/__init__.py b/tests/providers/azure/services/apim/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/providers/azure/services/apim/apim_service_test.py b/tests/providers/azure/services/apim/apim_service_test.py
new file mode 100644
index 0000000000..3b1a061293
--- /dev/null
+++ b/tests/providers/azure/services/apim/apim_service_test.py
@@ -0,0 +1,318 @@
+from datetime import timedelta
+from unittest import TestCase, mock
+from unittest.mock import patch
+
+from azure.mgmt.loganalytics.models import Workspace
+from azure.mgmt.monitor.models import DiagnosticSettingsResource
+from azure.monitor.query import LogsQueryResult
+
+from tests.providers.azure.azure_fixtures import (
+ AZURE_SUBSCRIPTION_ID,
+ set_mocked_azure_provider,
+)
+
+# Define constants for reusable mock data
+APIM_INSTANCE_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg/providers/Microsoft.ApiManagement/service/apim1"
+APIM_INSTANCE_NAME = "apim1"
+LOCATION = "West US"
+RESOURCE_GROUP = "rg"
+WORKSPACE_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourcegroups/rg/providers/microsoft.operationalinsights/workspaces/loganalytics"
+WORKSPACE_CUSTOMER_ID = "12345678-1234-1234-1234-1234567890ab"
+
+
+def mock_apim_get_instances(_):
+ """Mock function to replace APIM._get_instances."""
+ from prowler.providers.azure.services.apim.apim_service import APIMInstance
+
+ return {
+ AZURE_SUBSCRIPTION_ID: [
+ APIMInstance(
+ id=APIM_INSTANCE_ID,
+ name=APIM_INSTANCE_NAME,
+ location=LOCATION,
+ log_analytics_workspace_id=WORKSPACE_ID,
+ )
+ ]
+ }
+
+
+class Test_APIM_Service(TestCase):
+ def test_get_client(self):
+ """Test that the APIM service client is created correctly."""
+ mock_provider = mock.MagicMock()
+ mock_provider.identity = mock.MagicMock()
+ with (
+ patch(
+ "prowler.providers.azure.azure_provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.azure.services.apim.apim_service.APIM._get_instances",
+ return_value={},
+ ),
+ ):
+ from prowler.providers.azure.services.apim.apim_service import APIM
+
+ apim = APIM(set_mocked_azure_provider())
+ self.assertEqual(
+ apim.clients[AZURE_SUBSCRIPTION_ID].__class__.__name__,
+ "ApiManagementClient",
+ )
+
+ def test_get_subscriptions(self):
+ """Test that subscriptions are retrieved correctly."""
+ mock_provider = mock.MagicMock()
+ mock_provider.identity = mock.MagicMock()
+ with (
+ patch(
+ "prowler.providers.azure.azure_provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.azure.services.apim.apim_service.APIM._get_instances",
+ return_value={},
+ ),
+ ):
+ from prowler.providers.azure.services.apim.apim_service import APIM
+
+ apim = APIM(set_mocked_azure_provider())
+ self.assertEqual(apim.subscriptions.__class__.__name__, "dict")
+
+ def test_get_instances(self):
+ """Test that APIM instances are retrieved and parsed correctly."""
+ mock_provider = mock.MagicMock()
+ mock_provider.identity = mock.MagicMock()
+ with (
+ patch(
+ "prowler.providers.azure.azure_provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.azure.services.apim.apim_service.APIM._get_instances",
+ new=mock_apim_get_instances,
+ ),
+ ):
+ from prowler.providers.azure.services.apim.apim_service import APIM
+
+ apim = APIM(set_mocked_azure_provider())
+ self.assertEqual(len(apim.instances), 1)
+ self.assertEqual(len(apim.instances[AZURE_SUBSCRIPTION_ID]), 1)
+ instance = apim.instances[AZURE_SUBSCRIPTION_ID][0]
+ self.assertEqual(instance.id, APIM_INSTANCE_ID)
+ self.assertEqual(instance.name, APIM_INSTANCE_NAME)
+ self.assertEqual(instance.location, LOCATION)
+ self.assertEqual(instance.log_analytics_workspace_id, WORKSPACE_ID)
+
+ def test_get_log_analytics_workspace_id_success(self):
+ """Test retrieving a Log Analytics workspace ID successfully."""
+ mock_provider = mock.MagicMock()
+ mock_provider.identity = mock.MagicMock()
+ with patch(
+ "prowler.providers.azure.azure_provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ):
+ from prowler.providers.azure.services.apim.apim_service import APIM
+
+ with (
+ patch(
+ "prowler.providers.azure.services.apim.apim_service.APIM._get_instances",
+ return_value={},
+ ),
+ patch(
+ "prowler.providers.azure.services.apim.apim_service.monitor_client"
+ ) as mock_monitor_client,
+ ):
+ apim = APIM(set_mocked_azure_provider())
+ mock_log_setting = mock.MagicMock(enabled=True, category="GatewayLogs")
+ mock_setting = DiagnosticSettingsResource(
+ workspace_id=WORKSPACE_ID, logs=[mock_log_setting]
+ )
+ mock_monitor_client.diagnostic_settings_with_uri.return_value = [
+ mock_setting
+ ]
+ workspace_id = apim._get_log_analytics_workspace_id(
+ APIM_INSTANCE_ID, AZURE_SUBSCRIPTION_ID
+ )
+ self.assertEqual(workspace_id, WORKSPACE_ID)
+
+ def test_get_log_analytics_workspace_id_not_enabled(self):
+ """Test that no workspace ID is returned if GatewayLogs are not enabled."""
+ mock_provider = mock.MagicMock()
+ mock_provider.identity = mock.MagicMock()
+ with patch(
+ "prowler.providers.azure.azure_provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ):
+ from prowler.providers.azure.services.apim.apim_service import APIM
+
+ with (
+ patch(
+ "prowler.providers.azure.services.apim.apim_service.APIM._get_instances",
+ return_value={},
+ ),
+ patch(
+ "prowler.providers.azure.services.apim.apim_service.monitor_client"
+ ) as mock_monitor_client,
+ ):
+ apim = APIM(set_mocked_azure_provider())
+ mock_log_setting = mock.MagicMock(enabled=False, category="GatewayLogs")
+ mock_setting = DiagnosticSettingsResource(
+ workspace_id=WORKSPACE_ID, logs=[mock_log_setting]
+ )
+ mock_monitor_client.diagnostic_settings_with_uri.return_value = [
+ mock_setting
+ ]
+ workspace_id = apim._get_log_analytics_workspace_id(
+ APIM_INSTANCE_ID, AZURE_SUBSCRIPTION_ID
+ )
+ self.assertIsNone(workspace_id)
+
+ def test_get_workspace_customer_id_success(self):
+ """Test retrieving a workspace customer ID successfully."""
+ mock_provider = mock.MagicMock()
+ mock_provider.identity = mock.MagicMock()
+ with patch(
+ "prowler.providers.azure.azure_provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ):
+ from prowler.providers.azure.services.apim.apim_service import APIM
+
+ with (
+ patch(
+ "prowler.providers.azure.services.apim.apim_service.APIM._get_instances",
+ return_value={},
+ ),
+ patch(
+ "prowler.providers.azure.services.apim.apim_service.loganalytics_client"
+ ) as mock_loganalytics_client,
+ ):
+ apim = APIM(set_mocked_azure_provider())
+ mock_workspace = Workspace(location=LOCATION)
+ # Set customer_id after creation since it's readonly
+ mock_workspace.customer_id = WORKSPACE_CUSTOMER_ID
+
+ # Properly mock the nested client structure
+ mock_client = mock.MagicMock()
+ mock_workspaces = mock.MagicMock()
+ mock_workspaces.get.return_value = mock_workspace
+ mock_client.workspaces = mock_workspaces
+ mock_loganalytics_client.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
+
+ customer_id = apim._get_workspace_customer_id(
+ AZURE_SUBSCRIPTION_ID, WORKSPACE_ID
+ )
+ self.assertEqual(customer_id, WORKSPACE_CUSTOMER_ID)
+
+ def test_query_logs_success(self):
+ """Test querying logs successfully."""
+ mock_provider = mock.MagicMock()
+ mock_provider.identity = mock.MagicMock()
+ with patch(
+ "prowler.providers.azure.azure_provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ):
+ from prowler.providers.azure.services.apim.apim_service import APIM
+
+ with (
+ patch(
+ "prowler.providers.azure.services.apim.apim_service.APIM._get_instances",
+ return_value={},
+ ),
+ patch(
+ "prowler.providers.azure.services.apim.apim_service.logsquery_client"
+ ) as mock_logsquery_client,
+ ):
+ apim = APIM(set_mocked_azure_provider())
+ # Create a mock table with the expected structure for LogsQueryLogEntry
+ mock_table = mock.MagicMock()
+ mock_table.columns = [
+ "TimeGenerated",
+ "OperationId",
+ "CallerIpAddress",
+ "CorrelationId",
+ ]
+ from datetime import datetime
+
+ mock_table.rows = [
+ [
+ datetime.fromisoformat("2024-01-01T10:00:00+00:00"),
+ "test-operation",
+ "192.168.1.100",
+ "test-correlation",
+ ]
+ ]
+
+ mock_response = LogsQueryResult(tables=[mock_table], status="Success")
+
+ # Properly mock the nested client structure
+ mock_client = mock.MagicMock()
+ mock_client.query_workspace.return_value = mock_response
+ mock_logsquery_client.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
+
+ result = apim.query_logs(
+ AZURE_SUBSCRIPTION_ID,
+ "query",
+ timedelta(minutes=60),
+ WORKSPACE_CUSTOMER_ID,
+ )
+ self.assertEqual(len(result), 1)
+ # The result should be LogsQueryLogEntry objects
+ from datetime import datetime
+
+ self.assertEqual(
+ result[0].time_generated,
+ datetime.fromisoformat("2024-01-01T10:00:00+00:00"),
+ )
+ self.assertEqual(result[0].operation_id, "test-operation")
+ self.assertEqual(result[0].caller_ip_address, "192.168.1.100")
+ self.assertEqual(result[0].correlation_id, "test-correlation")
+
+ def test_get_llm_operations_logs_no_workspace_id(self):
+ """Test getting logs when the APIM instance has no workspace configured."""
+ mock_provider = mock.MagicMock()
+ mock_provider.identity = mock.MagicMock()
+ with patch(
+ "prowler.providers.azure.azure_provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ):
+ from prowler.providers.azure.services.apim.apim_service import APIM
+
+ with patch(
+ "prowler.providers.azure.services.apim.apim_service.APIM._get_instances",
+ return_value={},
+ ):
+ apim = APIM(set_mocked_azure_provider())
+ instance = mock.MagicMock(
+ log_analytics_workspace_id=None, name="test-apim"
+ )
+ result = apim.get_llm_operations_logs(AZURE_SUBSCRIPTION_ID, instance)
+ self.assertEqual(result, [])
+
+ def test_get_llm_operations_logs_success(self):
+ """Test the successful retrieval of LLM operation logs."""
+ mock_provider = mock.MagicMock()
+ mock_provider.identity = mock.MagicMock()
+ with patch(
+ "prowler.providers.azure.azure_provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ):
+ from prowler.providers.azure.services.apim.apim_service import APIM
+
+ with (
+ patch(
+ "prowler.providers.azure.services.apim.apim_service.APIM._get_instances",
+ new=mock_apim_get_instances,
+ ),
+ patch(
+ "prowler.providers.azure.services.apim.apim_service.APIM.query_logs",
+ return_value=[{"log": "data"}],
+ ),
+ patch(
+ "prowler.providers.azure.services.apim.apim_service.APIM._get_workspace_customer_id",
+ return_value=WORKSPACE_CUSTOMER_ID,
+ ),
+ ):
+ apim = APIM(set_mocked_azure_provider())
+ instance = apim.instances[AZURE_SUBSCRIPTION_ID][0]
+ result = apim.get_llm_operations_logs(AZURE_SUBSCRIPTION_ID, instance)
+ self.assertEqual(result, [{"log": "data"}])
diff --git a/tests/providers/azure/services/apim/apim_threat_detection_llm_jacking/__init__.py b/tests/providers/azure/services/apim/apim_threat_detection_llm_jacking/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking_test.py b/tests/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking_test.py
new file mode 100644
index 0000000000..d37385f585
--- /dev/null
+++ b/tests/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking_test.py
@@ -0,0 +1,491 @@
+from datetime import datetime
+from unittest import mock
+
+from tests.providers.azure.azure_fixtures import (
+ AZURE_SUBSCRIPTION_ID,
+ set_mocked_azure_provider,
+)
+
+
+# Create a mock LogsQueryLogEntry class for testing
+class MockLogsQueryLogEntry:
+ def __init__(self, **kwargs):
+ for key, value in kwargs.items():
+ setattr(self, key, value)
+
+
+def mock_get_llm_operations_logs(subscription, instance, minutes):
+ """Mock LLM operations logs for testing - returns 2 operations"""
+ return [
+ MockLogsQueryLogEntry(
+ time_generated=datetime.fromisoformat("2024-01-01T10:00:00+00:00"),
+ operation_id="ChatCompletions_Create",
+ caller_ip_address="192.168.1.100",
+ correlation_id="test-correlation-id-1",
+ ),
+ MockLogsQueryLogEntry(
+ time_generated=datetime.fromisoformat("2024-01-01T10:01:00+00:00"),
+ operation_id="ImageGenerations_Create",
+ caller_ip_address="192.168.1.100",
+ correlation_id="test-correlation-id-2",
+ ),
+ ]
+
+
+def mock_get_llm_operations_logs_6_operations(subscription, instance, minutes):
+ """Mock LLM operations logs for testing - returns 6 operations"""
+ return [
+ MockLogsQueryLogEntry(
+ time_generated=datetime.fromisoformat("2024-01-01T10:00:00+00:00"),
+ operation_id="ChatCompletions_Create",
+ caller_ip_address="192.168.1.100",
+ correlation_id="test-correlation-id-1",
+ ),
+ MockLogsQueryLogEntry(
+ time_generated=datetime.fromisoformat("2024-01-01T10:01:00+00:00"),
+ operation_id="ImageGenerations_Create",
+ caller_ip_address="192.168.1.100",
+ correlation_id="test-correlation-id-2",
+ ),
+ MockLogsQueryLogEntry(
+ time_generated=datetime.fromisoformat("2024-01-01T10:02:00+00:00"),
+ operation_id="Completions_Create",
+ caller_ip_address="192.168.1.100",
+ correlation_id="test-correlation-id-3",
+ ),
+ MockLogsQueryLogEntry(
+ time_generated=datetime.fromisoformat("2024-01-01T10:03:00+00:00"),
+ operation_id="Embeddings_Create",
+ caller_ip_address="192.168.1.100",
+ correlation_id="test-correlation-id-4",
+ ),
+ MockLogsQueryLogEntry(
+ time_generated=datetime.fromisoformat("2024-01-01T10:04:00+00:00"),
+ operation_id="FineTuning_Jobs_Create",
+ caller_ip_address="192.168.1.100",
+ correlation_id="test-correlation-id-5",
+ ),
+ MockLogsQueryLogEntry(
+ time_generated=datetime.fromisoformat("2024-01-01T10:05:00+00:00"),
+ operation_id="Models_List",
+ caller_ip_address="192.168.1.100",
+ correlation_id="test-correlation-id-6",
+ ),
+ ]
+
+
+def mock_get_llm_operations_logs_2_operations(subscription, instance, minutes):
+ """Mock LLM operations logs for testing - returns 2 operations"""
+ return [
+ MockLogsQueryLogEntry(
+ time_generated=datetime.fromisoformat("2024-01-01T10:00:00+00:00"),
+ operation_id="ChatCompletions_Create",
+ caller_ip_address="192.168.1.100",
+ correlation_id="test-correlation-id-1",
+ ),
+ MockLogsQueryLogEntry(
+ time_generated=datetime.fromisoformat("2024-01-01T10:01:00+00:00"),
+ operation_id="ImageGenerations_Create",
+ caller_ip_address="192.168.1.100",
+ correlation_id="test-correlation-id-2",
+ ),
+ ]
+
+
+def mock_get_llm_operations_logs_attacker(subscription, instance, minutes):
+ """Mock LLM operations logs showing potential attack"""
+ return [
+ MockLogsQueryLogEntry(
+ time_generated=datetime.fromisoformat("2024-01-01T10:00:00+00:00"),
+ operation_id="ChatCompletions_Create",
+ caller_ip_address="10.0.0.50",
+ correlation_id="test-correlation-id-1",
+ ),
+ MockLogsQueryLogEntry(
+ time_generated=datetime.fromisoformat("2024-01-01T10:01:00+00:00"),
+ operation_id="ImageGenerations_Create",
+ caller_ip_address="10.0.0.50",
+ correlation_id="test-correlation-id-2",
+ ),
+ MockLogsQueryLogEntry(
+ time_generated=datetime.fromisoformat("2024-01-01T10:02:00+00:00"),
+ operation_id="Completions_Create",
+ caller_ip_address="10.0.0.50",
+ correlation_id="test-correlation-id-3",
+ ),
+ MockLogsQueryLogEntry(
+ time_generated=datetime.fromisoformat("2024-01-01T10:03:00+00:00"),
+ operation_id="Embeddings_Create",
+ caller_ip_address="10.0.0.50",
+ correlation_id="test-correlation-id-4",
+ ),
+ MockLogsQueryLogEntry(
+ time_generated=datetime.fromisoformat("2024-01-01T10:04:00+00:00"),
+ operation_id="FineTuning_Jobs_Create",
+ caller_ip_address="10.0.0.50",
+ correlation_id="test-correlation-id-5",
+ ),
+ MockLogsQueryLogEntry(
+ time_generated=datetime.fromisoformat("2024-01-01T10:05:00+00:00"),
+ operation_id="Models_List",
+ caller_ip_address="10.0.0.50",
+ correlation_id="test-correlation-id-6",
+ ),
+ ]
+
+
+def mock_get_llm_operations_logs_no_workspace(subscription, instance, minutes):
+ """Mock LLM operations logs for instance without workspace"""
+ return []
+
+
+class Test_apim_threat_detection_llm_jacking:
+ def test_no_apim_instances(self):
+ """Test when there are no APIM instances"""
+ apim_client = mock.MagicMock()
+ apim_client.instances = {}
+ apim_client.audit_config = {
+ "apim_threat_detection_llm_jacking_threshold": 0.1,
+ "apim_threat_detection_llm_jacking_minutes": 1440,
+ "apim_threat_detection_llm_jacking_actions": [
+ "ChatCompletions_Create",
+ "ImageGenerations_Create",
+ ],
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_azure_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.azure.services.apim.apim_threat_detection_llm_jacking.apim_threat_detection_llm_jacking.apim_client",
+ new=apim_client,
+ ),
+ ):
+ from prowler.providers.azure.services.apim.apim_threat_detection_llm_jacking.apim_threat_detection_llm_jacking import (
+ apim_threat_detection_llm_jacking,
+ )
+
+ check = apim_threat_detection_llm_jacking()
+ result = check.execute()
+
+ assert len(result) == 0
+
+ def test_no_potential_llm_jacking(self):
+ """Test when no potential LLM jacking is detected"""
+ apim_client = mock.MagicMock()
+ apim_client.instances = {
+ AZURE_SUBSCRIPTION_ID: [
+ mock.MagicMock(
+ id="/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.ApiManagement/service/test-apim",
+ name="test-apim",
+ log_analytics_workspace_id="/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.OperationalInsights/workspaces/test-workspace",
+ )
+ ]
+ }
+ apim_client.audit_config = {
+ "apim_threat_detection_llm_jacking_threshold": 0.9,
+ "apim_threat_detection_llm_jacking_minutes": 1440,
+ "apim_threat_detection_llm_jacking_actions": [
+ "ChatCompletions_Create",
+ "ImageGenerations_Create",
+ "Completions_Create",
+ "Embeddings_Create",
+ "FineTuning_Jobs_Create",
+ "Models_List",
+ "Deployments_List",
+ "Deployments_Get",
+ "Deployments_Create",
+ "Deployments_Delete",
+ "Messages_Create",
+ "Claude_Create",
+ "GenerateContent",
+ "GenerateText",
+ "GenerateImage",
+ "Llama_Create",
+ "CodeLlama_Create",
+ "Gemini_Generate",
+ "Claude_Generate",
+ "Llama_Generate",
+ ],
+ }
+ apim_client.get_llm_operations_logs = mock_get_llm_operations_logs_6_operations
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_azure_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.azure.services.apim.apim_threat_detection_llm_jacking.apim_threat_detection_llm_jacking.apim_client",
+ new=apim_client,
+ ),
+ ):
+ from prowler.providers.azure.services.apim.apim_threat_detection_llm_jacking.apim_threat_detection_llm_jacking import (
+ apim_threat_detection_llm_jacking,
+ )
+
+ check = apim_threat_detection_llm_jacking()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ "No potential LLM Jacking attacks detected" in result[0].status_extended
+ )
+ assert result[0].subscription == AZURE_SUBSCRIPTION_ID
+
+ def test_potential_llm_jacking_detected(self):
+ """Test when potential LLM jacking is detected"""
+ apim_client = mock.MagicMock()
+ apim_client.instances = {
+ AZURE_SUBSCRIPTION_ID: [
+ mock.MagicMock(
+ id="/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.ApiManagement/service/test-apim",
+ name="test-apim",
+ log_analytics_workspace_id="/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.OperationalInsights/workspaces/test-workspace",
+ )
+ ]
+ }
+ apim_client.audit_config = {
+ "apim_threat_detection_llm_jacking_threshold": 0.1,
+ "apim_threat_detection_llm_jacking_minutes": 1440,
+ "apim_threat_detection_llm_jacking_actions": [
+ "ChatCompletions_Create",
+ "ImageGenerations_Create",
+ "Completions_Create",
+ "Embeddings_Create",
+ "FineTuning_Jobs_Create",
+ "Models_List",
+ ],
+ }
+ apim_client.get_llm_operations_logs = mock_get_llm_operations_logs_attacker
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_azure_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.azure.services.apim.apim_threat_detection_llm_jacking.apim_threat_detection_llm_jacking.apim_client",
+ new=apim_client,
+ ),
+ ):
+ from prowler.providers.azure.services.apim.apim_threat_detection_llm_jacking.apim_threat_detection_llm_jacking import (
+ apim_threat_detection_llm_jacking,
+ )
+
+ check = apim_threat_detection_llm_jacking()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ "Potential LLM Jacking attack detected from IP address 10.0.0.50"
+ in result[0].status_extended
+ )
+ assert result[0].subscription == AZURE_SUBSCRIPTION_ID
+ assert result[0].resource["name"] == "10.0.0.50"
+ assert result[0].resource["id"] == "10.0.0.50"
+
+ def test_higher_threshold_no_detection(self):
+ """Test when threshold is higher and no attack is detected"""
+ apim_client = mock.MagicMock()
+ apim_client.instances = {
+ AZURE_SUBSCRIPTION_ID: [
+ mock.MagicMock(
+ id="/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.ApiManagement/service/test-apim",
+ name="test-apim",
+ log_analytics_workspace_id="/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.OperationalInsights/workspaces/test-workspace",
+ )
+ ]
+ }
+ apim_client.audit_config = {
+ "apim_threat_detection_llm_jacking_threshold": 0.9,
+ "apim_threat_detection_llm_jacking_minutes": 1440,
+ "apim_threat_detection_llm_jacking_actions": [
+ "ChatCompletions_Create",
+ "ImageGenerations_Create",
+ "Completions_Create",
+ "Embeddings_Create",
+ "FineTuning_Jobs_Create",
+ "Models_List",
+ "Deployments_List",
+ "Deployments_Get",
+ "Deployments_Create",
+ "Deployments_Delete",
+ "Messages_Create",
+ "Claude_Create",
+ "GenerateContent",
+ "GenerateText",
+ "GenerateImage",
+ "Llama_Create",
+ "CodeLlama_Create",
+ "Gemini_Generate",
+ "Claude_Generate",
+ "Llama_Generate",
+ ],
+ }
+ apim_client.get_llm_operations_logs = mock_get_llm_operations_logs_6_operations
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_azure_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.azure.services.apim.apim_threat_detection_llm_jacking.apim_threat_detection_llm_jacking.apim_client",
+ new=apim_client,
+ ),
+ ):
+ from prowler.providers.azure.services.apim.apim_threat_detection_llm_jacking.apim_threat_detection_llm_jacking import (
+ apim_threat_detection_llm_jacking,
+ )
+
+ check = apim_threat_detection_llm_jacking()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ "No potential LLM Jacking attacks detected" in result[0].status_extended
+ )
+ assert result[0].subscription == AZURE_SUBSCRIPTION_ID
+
+ def test_instance_without_workspace(self):
+ """Test when APIM instance has no Log Analytics workspace configured"""
+ apim_client = mock.MagicMock()
+ apim_client.instances = {
+ AZURE_SUBSCRIPTION_ID: [
+ mock.MagicMock(
+ id="/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.ApiManagement/service/test-apim",
+ name="test-apim",
+ log_analytics_workspace_id=None,
+ )
+ ]
+ }
+ apim_client.audit_config = {
+ "apim_threat_detection_llm_jacking_threshold": 0.9,
+ "apim_threat_detection_llm_jacking_minutes": 1440,
+ "apim_threat_detection_llm_jacking_actions": [
+ "ChatCompletions_Create",
+ "ImageGenerations_Create",
+ "Completions_Create",
+ "Embeddings_Create",
+ "FineTuning_Jobs_Create",
+ "Models_List",
+ "Deployments_List",
+ "Deployments_Get",
+ "Deployments_Create",
+ "Deployments_Delete",
+ "Messages_Create",
+ "Claude_Create",
+ "GenerateContent",
+ "GenerateText",
+ "GenerateImage",
+ "Llama_Create",
+ "CodeLlama_Create",
+ "Gemini_Generate",
+ "Claude_Generate",
+ "Llama_Generate",
+ ],
+ }
+ apim_client.get_llm_operations_logs = mock_get_llm_operations_logs_2_operations
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_azure_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.azure.services.apim.apim_threat_detection_llm_jacking.apim_threat_detection_llm_jacking.apim_client",
+ new=apim_client,
+ ),
+ ):
+ from prowler.providers.azure.services.apim.apim_threat_detection_llm_jacking.apim_threat_detection_llm_jacking import (
+ apim_threat_detection_llm_jacking,
+ )
+
+ check = apim_threat_detection_llm_jacking()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ "No potential LLM Jacking attacks detected" in result[0].status_extended
+ )
+ assert result[0].subscription == AZURE_SUBSCRIPTION_ID
+
+ def test_multiple_subscriptions(self):
+ """Test with multiple subscriptions"""
+ apim_client = mock.MagicMock()
+ apim_client.instances = {
+ AZURE_SUBSCRIPTION_ID: [
+ mock.MagicMock(
+ id="/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.ApiManagement/service/test-apim",
+ name="test-apim",
+ log_analytics_workspace_id="/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.OperationalInsights/workspaces/test-workspace",
+ )
+ ],
+ "another-subscription": [
+ mock.MagicMock(
+ id="/subscriptions/another-sub/resourceGroups/test-rg/providers/Microsoft.ApiManagement/service/another-apim",
+ name="another-apim",
+ log_analytics_workspace_id="/subscriptions/another-sub/resourceGroups/test-rg/providers/Microsoft.OperationalInsights/workspaces/another-workspace",
+ )
+ ],
+ }
+ apim_client.audit_config = {
+ "apim_threat_detection_llm_jacking_threshold": 0.9,
+ "apim_threat_detection_llm_jacking_minutes": 1440,
+ "apim_threat_detection_llm_jacking_actions": [
+ "ChatCompletions_Create",
+ "ImageGenerations_Create",
+ "Completions_Create",
+ "Embeddings_Create",
+ "FineTuning_Jobs_Create",
+ "Models_List",
+ "Deployments_List",
+ "Deployments_Get",
+ "Deployments_Create",
+ "Deployments_Delete",
+ "Messages_Create",
+ "Claude_Create",
+ "GenerateContent",
+ "GenerateText",
+ "GenerateImage",
+ "Llama_Create",
+ "CodeLlama_Create",
+ "Gemini_Generate",
+ "Claude_Generate",
+ "Llama_Generate",
+ ],
+ }
+ apim_client.get_llm_operations_logs = mock_get_llm_operations_logs_2_operations
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_azure_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.azure.services.apim.apim_threat_detection_llm_jacking.apim_threat_detection_llm_jacking.apim_client",
+ new=apim_client,
+ ),
+ ):
+ from prowler.providers.azure.services.apim.apim_threat_detection_llm_jacking.apim_threat_detection_llm_jacking import (
+ apim_threat_detection_llm_jacking,
+ )
+
+ check = apim_threat_detection_llm_jacking()
+ result = check.execute()
+
+ assert len(result) == 2
+ # Both subscriptions should have PASS results
+ for report in result:
+ assert report.status == "PASS"
+ assert (
+ "No potential LLM Jacking attacks detected"
+ in report.status_extended
+ )
From 7342a8901f2c5681a64f62ba7aee5aca83e701d3 Mon Sep 17 00:00:00 2001
From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
Date: Thu, 28 Aug 2025 11:43:24 +0200
Subject: [PATCH 11/37] chore: update CHANGELOG.md for Prowler v5.11.0 release
(#8597)
---
ui/CHANGELOG.md | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md
index 407e76abd1..1372359a34 100644
--- a/ui/CHANGELOG.md
+++ b/ui/CHANGELOG.md
@@ -2,7 +2,7 @@
All notable changes to the **Prowler UI** are documented in this file.
-## [1.11.0] (Prowler v5.11.0 - UNRELEASED)
+## [1.11.0] (Prowler v5.11.0)
### 🚀 Added
@@ -25,8 +25,6 @@ All notable changes to the **Prowler UI** are documented in this file.
- Auth callback route checking working as expected [(#8556)](https://github.com/prowler-cloud/prowler/pull/8556)
- DataTable column headers set to single-line [(#8480)](https://github.com/prowler-cloud/prowler/pull/8480)
-### ❌ Removed
-
---
## [1.10.2] (Prowler v5.10.3)
From 338bb74c0ceb66f9219abc62b04d7ace86376db4 Mon Sep 17 00:00:00 2001
From: Sergio Garcia
Date: Thu, 28 Aug 2025 12:03:35 +0200
Subject: [PATCH 12/37] fix(azure): query API management logs with not empty
operations (#8598)
---
prowler/providers/azure/services/apim/apim_service.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/prowler/providers/azure/services/apim/apim_service.py b/prowler/providers/azure/services/apim/apim_service.py
index 8c222cbe6d..793eb727c3 100644
--- a/prowler/providers/azure/services/apim/apim_service.py
+++ b/prowler/providers/azure/services/apim/apim_service.py
@@ -245,6 +245,7 @@ class APIM(AzureService):
query = f"""
ApiManagementGatewayLogs
| where _ResourceId has '{instance.id}'
+ | where isnotempty(OperationId)
| project TimeGenerated, OperationId, CallerIpAddress, CorrelationId
"""
timespan = timedelta(minutes=minutes)
From b4deda3c3fd860fb1c49aa26f7446f7c0b830a88 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?V=C3=ADctor=20Fern=C3=A1ndez=20Poyatos?=
Date: Thu, 28 Aug 2025 12:39:07 +0200
Subject: [PATCH 13/37] docs(api): fix API response samples (#8592)
---
api/src/backend/api/schema_hooks.py | 95 ++++
api/src/backend/api/specs/v1.yaml | 654 +++++++++++++++++---------
api/src/backend/api/v1/views.py | 52 +-
api/src/backend/config/django/base.py | 3 +
4 files changed, 560 insertions(+), 244 deletions(-)
create mode 100644 api/src/backend/api/schema_hooks.py
diff --git a/api/src/backend/api/schema_hooks.py b/api/src/backend/api/schema_hooks.py
new file mode 100644
index 0000000000..f94db59562
--- /dev/null
+++ b/api/src/backend/api/schema_hooks.py
@@ -0,0 +1,95 @@
+def _pick_task_response_component(components):
+ schemas = components.get("schemas", {}) or {}
+ for candidate in ("TaskResponse",):
+ if candidate in schemas:
+ return candidate
+ return None
+
+
+def _extract_task_example_from_components(components):
+ schemas = components.get("schemas", {}) or {}
+ candidate = "TaskResponse"
+ doc = schemas.get(candidate)
+ if isinstance(doc, dict) and "example" in doc:
+ return doc["example"]
+
+ res = schemas.get(candidate)
+ if isinstance(res, dict) and "example" in res:
+ example = res["example"]
+ return example if "data" in example else {"data": example}
+
+ # Fallback
+ return {
+ "data": {
+ "type": "tasks",
+ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
+ "attributes": {
+ "inserted_at": "2019-08-24T14:15:22Z",
+ "completed_at": "2019-08-24T14:15:22Z",
+ "name": "string",
+ "state": "available",
+ "result": None,
+ "task_args": None,
+ "metadata": None,
+ },
+ }
+ }
+
+
+def attach_task_202_examples(result, generator, request, public): # noqa: F841
+ if not isinstance(result, dict):
+ return result
+
+ components = result.get("components", {}) or {}
+ task_resp_component = _pick_task_response_component(components)
+ task_example = _extract_task_example_from_components(components)
+
+ paths = result.get("paths", {}) or {}
+ for path_item in paths.values():
+ if not isinstance(path_item, dict):
+ continue
+
+ for method_obj in path_item.values():
+ if not isinstance(method_obj, dict):
+ continue
+
+ responses = method_obj.get("responses", {}) or {}
+ resp_202 = responses.get("202")
+ if not isinstance(resp_202, dict):
+ continue
+
+ content = resp_202.get("content", {}) or {}
+ jsonapi = content.get("application/vnd.api+json")
+ if not isinstance(jsonapi, dict):
+ continue
+
+ # Inject example if missing
+ if "examples" not in jsonapi and "example" not in jsonapi:
+ jsonapi["examples"] = {
+ "Task queued": {
+ "summary": "Task queued",
+ "value": task_example,
+ }
+ }
+
+ # Rewrite schema $ref if needed
+ if task_resp_component:
+ schema = jsonapi.get("schema")
+ must_replace = False
+ if not isinstance(schema, dict):
+ must_replace = True
+ else:
+ ref = schema.get("$ref")
+ if not ref:
+ must_replace = True
+ else:
+ current = ref.split("/")[-1]
+ if current != task_resp_component:
+ must_replace = True
+
+ if must_replace:
+ jsonapi["schema"] = {
+ "$ref": f"#/components/schemas/{task_resp_component}"
+ }
+
+ return result
diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml
index cf64a00ffd..b09998234a 100644
--- a/api/src/backend/api/specs/v1.yaml
+++ b/api/src/backend/api/specs/v1.yaml
@@ -147,7 +147,22 @@ paths:
content:
application/vnd.api+json:
schema:
- $ref: '#/components/schemas/PaginatedTaskList'
+ $ref: '#/components/schemas/TaskResponse'
+ examples:
+ Task queued:
+ summary: Task queued
+ value:
+ data:
+ type: tasks
+ id: 497f6eca-6276-4993-bfeb-53cbbbba6f08
+ attributes:
+ inserted_at: '2019-08-24T14:15:22Z'
+ completed_at: '2019-08-24T14:15:22Z'
+ name: string
+ state: available
+ result: null
+ task_args: null
+ metadata: null
description: The task is in progress
'500':
description: Compliance overviews generation task failed
@@ -230,6 +245,25 @@ paths:
$ref: '#/components/schemas/OpenApiResponseResponse'
description: Compliance overviews metadata obtained successfully
'202':
+ content:
+ application/vnd.api+json:
+ schema:
+ $ref: '#/components/schemas/TaskResponse'
+ examples:
+ Task queued:
+ summary: Task queued
+ value:
+ data:
+ type: tasks
+ id: 497f6eca-6276-4993-bfeb-53cbbbba6f08
+ attributes:
+ inserted_at: '2019-08-24T14:15:22Z'
+ completed_at: '2019-08-24T14:15:22Z'
+ name: string
+ state: available
+ result: null
+ task_args: null
+ metadata: null
description: The task is in progress
'500':
description: Compliance overviews generation task failed
@@ -360,6 +394,25 @@ paths:
$ref: '#/components/schemas/PaginatedComplianceOverviewDetailList'
description: Compliance requirement details obtained successfully
'202':
+ content:
+ application/vnd.api+json:
+ schema:
+ $ref: '#/components/schemas/TaskResponse'
+ examples:
+ Task queued:
+ summary: Task queued
+ value:
+ data:
+ type: tasks
+ id: 497f6eca-6276-4993-bfeb-53cbbbba6f08
+ attributes:
+ inserted_at: '2019-08-24T14:15:22Z'
+ completed_at: '2019-08-24T14:15:22Z'
+ name: string
+ state: available
+ result: null
+ task_args: null
+ metadata: null
description: The task is in progress
'500':
description: Compliance overviews generation task failed
@@ -417,6 +470,7 @@ paths:
name: filter[delta]
schema:
type: string
+ x-spec-enum-id: fef6b0e2506af8bc
nullable: true
enum:
- changed
@@ -452,6 +506,7 @@ paths:
name: filter[impact]
schema:
type: string
+ x-spec-enum-id: c93e070ed135d9bf
enum:
- critical
- high
@@ -540,6 +595,7 @@ paths:
name: filter[provider_type]
schema:
type: string
+ x-spec-enum-id: 4c1e219dad1cc0e7
enum:
- aws
- azure
@@ -560,6 +616,7 @@ paths:
type: array
items:
type: string
+ x-spec-enum-id: 4c1e219dad1cc0e7
enum:
- aws
- azure
@@ -715,6 +772,7 @@ paths:
name: filter[severity]
schema:
type: string
+ x-spec-enum-id: c93e070ed135d9bf
enum:
- critical
- high
@@ -740,6 +798,7 @@ paths:
name: filter[status]
schema:
type: string
+ x-spec-enum-id: ce612ddbfa464789
enum:
- FAIL
- MANUAL
@@ -941,6 +1000,7 @@ paths:
name: filter[delta]
schema:
type: string
+ x-spec-enum-id: fef6b0e2506af8bc
nullable: true
enum:
- changed
@@ -976,6 +1036,7 @@ paths:
name: filter[impact]
schema:
type: string
+ x-spec-enum-id: c93e070ed135d9bf
enum:
- critical
- high
@@ -1061,6 +1122,7 @@ paths:
name: filter[provider_type]
schema:
type: string
+ x-spec-enum-id: 4c1e219dad1cc0e7
enum:
- aws
- azure
@@ -1081,6 +1143,7 @@ paths:
type: array
items:
type: string
+ x-spec-enum-id: 4c1e219dad1cc0e7
enum:
- aws
- azure
@@ -1236,6 +1299,7 @@ paths:
name: filter[severity]
schema:
type: string
+ x-spec-enum-id: c93e070ed135d9bf
enum:
- critical
- high
@@ -1261,6 +1325,7 @@ paths:
name: filter[status]
schema:
type: string
+ x-spec-enum-id: ce612ddbfa464789
enum:
- FAIL
- MANUAL
@@ -1392,6 +1457,7 @@ paths:
name: filter[delta]
schema:
type: string
+ x-spec-enum-id: fef6b0e2506af8bc
nullable: true
enum:
- changed
@@ -1427,6 +1493,7 @@ paths:
name: filter[impact]
schema:
type: string
+ x-spec-enum-id: c93e070ed135d9bf
enum:
- critical
- high
@@ -1490,6 +1557,7 @@ paths:
name: filter[provider_type]
schema:
type: string
+ x-spec-enum-id: 4c1e219dad1cc0e7
enum:
- aws
- azure
@@ -1510,6 +1578,7 @@ paths:
type: array
items:
type: string
+ x-spec-enum-id: 4c1e219dad1cc0e7
enum:
- aws
- azure
@@ -1650,6 +1719,7 @@ paths:
name: filter[severity]
schema:
type: string
+ x-spec-enum-id: c93e070ed135d9bf
enum:
- critical
- high
@@ -1675,6 +1745,7 @@ paths:
name: filter[status]
schema:
type: string
+ x-spec-enum-id: ce612ddbfa464789
enum:
- FAIL
- MANUAL
@@ -1794,6 +1865,7 @@ paths:
name: filter[delta]
schema:
type: string
+ x-spec-enum-id: fef6b0e2506af8bc
nullable: true
enum:
- changed
@@ -1829,6 +1901,7 @@ paths:
name: filter[impact]
schema:
type: string
+ x-spec-enum-id: c93e070ed135d9bf
enum:
- critical
- high
@@ -1917,6 +1990,7 @@ paths:
name: filter[provider_type]
schema:
type: string
+ x-spec-enum-id: 4c1e219dad1cc0e7
enum:
- aws
- azure
@@ -1937,6 +2011,7 @@ paths:
type: array
items:
type: string
+ x-spec-enum-id: 4c1e219dad1cc0e7
enum:
- aws
- azure
@@ -2092,6 +2167,7 @@ paths:
name: filter[severity]
schema:
type: string
+ x-spec-enum-id: c93e070ed135d9bf
enum:
- critical
- high
@@ -2117,6 +2193,7 @@ paths:
name: filter[status]
schema:
type: string
+ x-spec-enum-id: ce612ddbfa464789
enum:
- FAIL
- MANUAL
@@ -2234,6 +2311,7 @@ paths:
name: filter[delta]
schema:
type: string
+ x-spec-enum-id: fef6b0e2506af8bc
nullable: true
enum:
- changed
@@ -2269,6 +2347,7 @@ paths:
name: filter[impact]
schema:
type: string
+ x-spec-enum-id: c93e070ed135d9bf
enum:
- critical
- high
@@ -2332,6 +2411,7 @@ paths:
name: filter[provider_type]
schema:
type: string
+ x-spec-enum-id: 4c1e219dad1cc0e7
enum:
- aws
- azure
@@ -2352,6 +2432,7 @@ paths:
type: array
items:
type: string
+ x-spec-enum-id: 4c1e219dad1cc0e7
enum:
- aws
- azure
@@ -2492,6 +2573,7 @@ paths:
name: filter[severity]
schema:
type: string
+ x-spec-enum-id: c93e070ed135d9bf
enum:
- critical
- high
@@ -2517,6 +2599,7 @@ paths:
name: filter[status]
schema:
type: string
+ x-spec-enum-id: ce612ddbfa464789
enum:
- FAIL
- MANUAL
@@ -2633,6 +2716,7 @@ paths:
name: filter[integration_type]
schema:
type: string
+ x-spec-enum-id: 6cfd0ff9cf4d6dcc
enum:
- amazon_s3
- aws_security_hub
@@ -2649,6 +2733,7 @@ paths:
type: array
items:
type: string
+ x-spec-enum-id: 6cfd0ff9cf4d6dcc
enum:
- amazon_s3
- aws_security_hub
@@ -2893,7 +2978,22 @@ paths:
content:
application/vnd.api+json:
schema:
- $ref: '#/components/schemas/OpenApiResponseResponse'
+ $ref: '#/components/schemas/TaskResponse'
+ examples:
+ Task queued:
+ summary: Task queued
+ value:
+ data:
+ type: tasks
+ id: 497f6eca-6276-4993-bfeb-53cbbbba6f08
+ attributes:
+ inserted_at: '2019-08-24T14:15:22Z'
+ completed_at: '2019-08-24T14:15:22Z'
+ name: string
+ state: available
+ result: null
+ task_args: null
+ metadata: null
description: ''
/api/v1/invitations/accept:
post:
@@ -3096,7 +3196,22 @@ paths:
content:
application/vnd.api+json:
schema:
- $ref: '#/components/schemas/OpenApiResponseResponse'
+ $ref: '#/components/schemas/TaskResponse'
+ examples:
+ Task queued:
+ summary: Task queued
+ value:
+ data:
+ type: tasks
+ id: 497f6eca-6276-4993-bfeb-53cbbbba6f08
+ attributes:
+ inserted_at: '2019-08-24T14:15:22Z'
+ completed_at: '2019-08-24T14:15:22Z'
+ name: string
+ state: available
+ result: null
+ task_args: null
+ metadata: null
description: ''
/api/v1/overviews/findings:
get:
@@ -3161,6 +3276,7 @@ paths:
name: filter[provider_type]
schema:
type: string
+ x-spec-enum-id: 4c1e219dad1cc0e7
enum:
- aws
- azure
@@ -3181,6 +3297,7 @@ paths:
type: array
items:
type: string
+ x-spec-enum-id: 4c1e219dad1cc0e7
enum:
- aws
- azure
@@ -3326,6 +3443,7 @@ paths:
name: filter[provider_type]
schema:
type: string
+ x-spec-enum-id: 4c1e219dad1cc0e7
enum:
- aws
- azure
@@ -3346,6 +3464,7 @@ paths:
type: array
items:
type: string
+ x-spec-enum-id: 4c1e219dad1cc0e7
enum:
- aws
- azure
@@ -3507,6 +3626,7 @@ paths:
name: filter[provider_type]
schema:
type: string
+ x-spec-enum-id: 4c1e219dad1cc0e7
enum:
- aws
- azure
@@ -3527,6 +3647,7 @@ paths:
type: array
items:
type: string
+ x-spec-enum-id: 4c1e219dad1cc0e7
enum:
- aws
- azure
@@ -3625,6 +3746,7 @@ paths:
name: filter[processor_type]
schema:
type: string
+ x-spec-enum-id: 53cda4a99fe3d655
enum:
- mutelist
description: '* `mutelist` - Mutelist'
@@ -3634,6 +3756,7 @@ paths:
type: array
items:
type: string
+ x-spec-enum-id: 53cda4a99fe3d655
enum:
- mutelist
description: |-
@@ -4217,6 +4340,7 @@ paths:
name: filter[provider]
schema:
type: string
+ x-spec-enum-id: 4c1e219dad1cc0e7
enum:
- aws
- azure
@@ -4473,7 +4597,22 @@ paths:
content:
application/vnd.api+json:
schema:
- $ref: '#/components/schemas/OpenApiResponseResponse'
+ $ref: '#/components/schemas/TaskResponse'
+ examples:
+ Task queued:
+ summary: Task queued
+ value:
+ data:
+ type: tasks
+ id: 497f6eca-6276-4993-bfeb-53cbbbba6f08
+ attributes:
+ inserted_at: '2019-08-24T14:15:22Z'
+ completed_at: '2019-08-24T14:15:22Z'
+ name: string
+ state: available
+ result: null
+ task_args: null
+ metadata: null
description: ''
/api/v1/providers/{id}/connection:
post:
@@ -4498,7 +4637,22 @@ paths:
content:
application/vnd.api+json:
schema:
- $ref: '#/components/schemas/OpenApiResponseResponse'
+ $ref: '#/components/schemas/TaskResponse'
+ examples:
+ Task queued:
+ summary: Task queued
+ value:
+ data:
+ type: tasks
+ id: 497f6eca-6276-4993-bfeb-53cbbbba6f08
+ attributes:
+ inserted_at: '2019-08-24T14:15:22Z'
+ completed_at: '2019-08-24T14:15:22Z'
+ name: string
+ state: available
+ result: null
+ task_args: null
+ metadata: null
description: ''
/api/v1/providers/secrets:
get:
@@ -4800,6 +4954,7 @@ paths:
name: filter[provider_type]
schema:
type: string
+ x-spec-enum-id: 4c1e219dad1cc0e7
enum:
- aws
- azure
@@ -4820,6 +4975,7 @@ paths:
type: array
items:
type: string
+ x-spec-enum-id: 4c1e219dad1cc0e7
enum:
- aws
- azure
@@ -5163,6 +5319,7 @@ paths:
name: filter[provider_type]
schema:
type: string
+ x-spec-enum-id: 4c1e219dad1cc0e7
enum:
- aws
- azure
@@ -5183,6 +5340,7 @@ paths:
type: array
items:
type: string
+ x-spec-enum-id: 4c1e219dad1cc0e7
enum:
- aws
- azure
@@ -5427,6 +5585,7 @@ paths:
name: filter[provider_type]
schema:
type: string
+ x-spec-enum-id: 4c1e219dad1cc0e7
enum:
- aws
- azure
@@ -5447,6 +5606,7 @@ paths:
type: array
items:
type: string
+ x-spec-enum-id: 4c1e219dad1cc0e7
enum:
- aws
- azure
@@ -5697,6 +5857,7 @@ paths:
name: filter[provider_type]
schema:
type: string
+ x-spec-enum-id: 4c1e219dad1cc0e7
enum:
- aws
- azure
@@ -5717,6 +5878,7 @@ paths:
type: array
items:
type: string
+ x-spec-enum-id: 4c1e219dad1cc0e7
enum:
- aws
- azure
@@ -6527,6 +6689,7 @@ paths:
name: filter[provider_type]
schema:
type: string
+ x-spec-enum-id: 4c1e219dad1cc0e7
enum:
- aws
- azure
@@ -6547,6 +6710,7 @@ paths:
type: array
items:
type: string
+ x-spec-enum-id: 4c1e219dad1cc0e7
enum:
- aws
- azure
@@ -6607,6 +6771,7 @@ paths:
name: filter[state]
schema:
type: string
+ x-spec-enum-id: d38ba07264e1ed34
enum:
- available
- cancelled
@@ -6627,6 +6792,7 @@ paths:
type: array
items:
type: string
+ x-spec-enum-id: d38ba07264e1ed34
enum:
- available
- cancelled
@@ -6649,6 +6815,7 @@ paths:
name: filter[trigger]
schema:
type: string
+ x-spec-enum-id: 2e52c981d1c89bdf
enum:
- manual
- scheduled
@@ -6741,7 +6908,22 @@ paths:
content:
application/vnd.api+json:
schema:
- $ref: '#/components/schemas/OpenApiResponseResponse'
+ $ref: '#/components/schemas/TaskResponse'
+ examples:
+ Task queued:
+ summary: Task queued
+ value:
+ data:
+ type: tasks
+ id: 497f6eca-6276-4993-bfeb-53cbbbba6f08
+ attributes:
+ inserted_at: '2019-08-24T14:15:22Z'
+ completed_at: '2019-08-24T14:15:22Z'
+ name: string
+ state: available
+ result: null
+ task_args: null
+ metadata: null
description: ''
/api/v1/scans/{id}:
get:
@@ -6945,7 +7127,22 @@ paths:
content:
application/vnd.api+json:
schema:
- $ref: '#/components/schemas/OpenApiResponseResponse'
+ $ref: '#/components/schemas/TaskResponse'
+ examples:
+ Task queued:
+ summary: Task queued
+ value:
+ data:
+ type: tasks
+ id: 497f6eca-6276-4993-bfeb-53cbbbba6f08
+ attributes:
+ inserted_at: '2019-08-24T14:15:22Z'
+ completed_at: '2019-08-24T14:15:22Z'
+ name: string
+ state: available
+ result: null
+ task_args: null
+ metadata: null
description: ''
/api/v1/tasks:
get:
@@ -7110,7 +7307,22 @@ paths:
content:
application/vnd.api+json:
schema:
- $ref: '#/components/schemas/OpenApiResponseResponse'
+ $ref: '#/components/schemas/TaskResponse'
+ examples:
+ Task queued:
+ summary: Task queued
+ value:
+ data:
+ type: tasks
+ id: 497f6eca-6276-4993-bfeb-53cbbbba6f08
+ attributes:
+ inserted_at: '2019-08-24T14:15:22Z'
+ completed_at: '2019-08-24T14:15:22Z'
+ name: string
+ state: available
+ result: null
+ task_args: null
+ metadata: null
description: ''
/api/v1/tenants:
get:
@@ -7532,6 +7744,7 @@ paths:
name: filter[state]
schema:
type: string
+ x-spec-enum-id: 92a1c1fd12e13e25
enum:
- accepted
- expired
@@ -8217,6 +8430,7 @@ paths:
name: filter[role]
schema:
type: string
+ x-spec-enum-id: 12c359ee5dc51001
enum:
- member
- owner
@@ -8378,11 +8592,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/ComplianceOverviewTypeEnum'
+ 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:
+ - compliance-overviews
id: {}
attributes:
type: object
@@ -8417,11 +8632,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/ComplianceOverviewAttributesTypeEnum'
+ 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:
+ - compliance-requirements-attributes
id: {}
attributes:
type: object
@@ -8447,10 +8663,6 @@ components:
- version
- description
- attributes
- ComplianceOverviewAttributesTypeEnum:
- type: string
- enum:
- - compliance-requirements-attributes
ComplianceOverviewDetail:
type: object
required:
@@ -8459,11 +8671,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/ComplianceOverviewDetailTypeEnum'
+ 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:
+ - compliance-requirements-details
id: {}
attributes:
type: object
@@ -8486,16 +8699,13 @@ components:
* `FAIL` - Fail
* `PASS` - Pass
* `MANUAL` - Manual
+ x-spec-enum-id: ce612ddbfa464789
required:
- id
- framework
- version
- description
- status
- ComplianceOverviewDetailTypeEnum:
- type: string
- enum:
- - compliance-requirements-details
ComplianceOverviewMetadata:
type: object
required:
@@ -8504,11 +8714,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/ComplianceOverviewMetadataTypeEnum'
+ 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:
+ - compliance-overviews-metadata
id: {}
attributes:
type: object
@@ -8519,14 +8730,6 @@ components:
type: string
required:
- regions
- ComplianceOverviewMetadataTypeEnum:
- type: string
- enum:
- - compliance-overviews-metadata
- ComplianceOverviewTypeEnum:
- type: string
- enum:
- - compliance-overviews
Finding:
type: object
required:
@@ -8535,11 +8738,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/FindingTypeEnum'
+ 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:
+ - findings
id:
type: string
format: uuid
@@ -8558,6 +8762,7 @@ components:
description: |-
* `new` - New
* `changed` - Changed
+ x-spec-enum-id: fef6b0e2506af8bc
nullable: true
status:
enum:
@@ -8569,6 +8774,7 @@ components:
* `FAIL` - Fail
* `PASS` - Pass
* `MANUAL` - Manual
+ x-spec-enum-id: ce612ddbfa464789
status_extended:
type: string
nullable: true
@@ -8586,6 +8792,7 @@ components:
* `medium` - Medium
* `low` - Low
* `informational` - Informational
+ x-spec-enum-id: c93e070ed135d9bf
check_id:
type: string
maxLength: 100
@@ -8682,11 +8889,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/FindingDynamicFilterTypeEnum'
+ 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:
+ - finding-dynamic-filters
id: {}
attributes:
type: object
@@ -8709,10 +8917,6 @@ components:
$ref: '#/components/schemas/FindingDynamicFilter'
required:
- data
- FindingDynamicFilterTypeEnum:
- type: string
- enum:
- - finding-dynamic-filters
FindingMetadata:
type: object
required:
@@ -8721,11 +8925,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/FindingMetadataTypeEnum'
+ 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:
+ - findings-metadata
id: {}
attributes:
type: object
@@ -8753,10 +8958,6 @@ components:
$ref: '#/components/schemas/FindingMetadata'
required:
- data
- FindingMetadataTypeEnum:
- type: string
- enum:
- - findings-metadata
FindingResponse:
type: object
properties:
@@ -8764,10 +8965,6 @@ components:
$ref: '#/components/schemas/Finding'
required:
- data
- FindingTypeEnum:
- type: string
- enum:
- - findings
Integration:
type: object
required:
@@ -8776,11 +8973,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/TypeC82Enum'
+ 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:
+ - integrations
id:
type: string
format: uuid
@@ -8816,6 +9014,7 @@ components:
* `aws_security_hub` - AWS Security Hub
* `jira` - JIRA
* `slack` - Slack
+ x-spec-enum-id: 6cfd0ff9cf4d6dcc
configuration: {}
required:
- integration_type
@@ -8859,11 +9058,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/TypeC82Enum'
+ 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:
+ - integrations
attributes:
type: object
properties:
@@ -8898,6 +9098,7 @@ components:
* `aws_security_hub` - AWS Security Hub
* `jira` - JIRA
* `slack` - Slack
+ x-spec-enum-id: 6cfd0ff9cf4d6dcc
configuration:
oneOf:
- type: object
@@ -8924,8 +9125,8 @@ components:
send_only_fails:
type: boolean
default: false
- description: If true, only findings with status 'FAIL' will
- be sent to Security Hub.
+ description: If true, only findings with status 'FAIL' will be
+ sent to Security Hub.
archive_previous_findings:
type: boolean
default: false
@@ -9056,6 +9257,7 @@ components:
* `aws_security_hub` - AWS Security Hub
* `jira` - JIRA
* `slack` - Slack
+ x-spec-enum-id: 6cfd0ff9cf4d6dcc
configuration:
oneOf:
- type: object
@@ -9190,11 +9392,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/TypeC82Enum'
+ 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:
+ - integrations
id: {}
attributes:
type: object
@@ -9230,6 +9433,7 @@ components:
* `aws_security_hub` - AWS Security Hub
* `jira` - JIRA
* `slack` - Slack
+ x-spec-enum-id: 6cfd0ff9cf4d6dcc
readOnly: true
configuration:
oneOf:
@@ -9257,8 +9461,8 @@ components:
send_only_fails:
type: boolean
default: false
- description: If true, only findings with status 'FAIL' will
- be sent to Security Hub.
+ description: If true, only findings with status 'FAIL' will be
+ sent to Security Hub.
archive_previous_findings:
type: boolean
default: false
@@ -9350,11 +9554,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/TypeD4dEnum'
+ 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:
+ - invitations
id:
type: string
format: uuid
@@ -9385,6 +9590,7 @@ components:
* `accepted` - Invitation was accepted by a user
* `expired` - Invitation expired after the configured time
* `revoked` - Invitation was revoked by a user
+ x-spec-enum-id: 92a1c1fd12e13e25
token:
type: string
readOnly: true
@@ -9485,11 +9691,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/TypeD4dEnum'
+ 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:
+ - invitations
attributes:
type: object
properties:
@@ -9514,6 +9721,7 @@ components:
* `accepted` - Invitation was accepted by a user
* `expired` - Invitation expired after the configured time
* `revoked` - Invitation was revoked by a user
+ x-spec-enum-id: 92a1c1fd12e13e25
readOnly: true
token:
type: string
@@ -9620,6 +9828,7 @@ components:
* `accepted` - Invitation was accepted by a user
* `expired` - Invitation expired after the configured time
* `revoked` - Invitation was revoked by a user
+ x-spec-enum-id: 92a1c1fd12e13e25
readOnly: true
token:
type: string
@@ -9710,11 +9919,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/TypeD4dEnum'
+ 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:
+ - invitations
id:
type: string
format: uuid
@@ -9740,6 +9950,7 @@ components:
* `accepted` - Invitation was accepted by a user
* `expired` - Invitation expired after the configured time
* `revoked` - Invitation was revoked by a user
+ x-spec-enum-id: 92a1c1fd12e13e25
readOnly: true
token:
type: string
@@ -9790,11 +10001,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/Type4bfEnum'
+ 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:
+ - lighthouse-configurations
id:
type: string
format: uuid
@@ -9821,6 +10033,7 @@ components:
- gpt-5-mini-2025-08-07
- gpt-5-mini
type: string
+ x-spec-enum-id: bd2108eb25b2d472
description: |-
Must be one of the supported model names
@@ -9866,11 +10079,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/Type4bfEnum'
+ 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:
+ - lighthouse-configurations
attributes:
type: object
properties:
@@ -9895,6 +10109,7 @@ components:
- gpt-5-mini-2025-08-07
- gpt-5-mini
type: string
+ x-spec-enum-id: bd2108eb25b2d472
description: |-
Must be one of the supported model names
@@ -9975,6 +10190,7 @@ components:
- gpt-5-mini-2025-08-07
- gpt-5-mini
type: string
+ x-spec-enum-id: bd2108eb25b2d472
description: |-
Must be one of the supported model names
@@ -10031,11 +10247,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/Type4bfEnum'
+ 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:
+ - lighthouse-configurations
id:
type: string
format: uuid
@@ -10063,6 +10280,7 @@ components:
- gpt-5-mini-2025-08-07
- gpt-5-mini
type: string
+ x-spec-enum-id: bd2108eb25b2d472
description: |-
Must be one of the supported model names
@@ -10105,11 +10323,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/MembershipTypeEnum'
+ 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:
+ - memberships
attributes:
type: object
properties:
@@ -10121,6 +10340,7 @@ components:
description: |-
* `owner` - Owner
* `member` - Member
+ x-spec-enum-id: 12c359ee5dc51001
date_joined:
type: string
format: date-time
@@ -10187,10 +10407,6 @@ components:
$ref: '#/components/schemas/Membership'
required:
- data
- MembershipTypeEnum:
- type: string
- enum:
- - memberships
OpenApiResponseResponse:
type: object
properties:
@@ -10206,11 +10422,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/OverviewFindingTypeEnum'
+ 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:
+ - findings-overview
id: {}
attributes:
type: object
@@ -10265,10 +10482,6 @@ components:
$ref: '#/components/schemas/OverviewFinding'
required:
- data
- OverviewFindingTypeEnum:
- type: string
- enum:
- - findings-overview
OverviewProvider:
type: object
required:
@@ -10277,11 +10490,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/OverviewProviderTypeEnum'
+ 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:
+ - providers-overview
id: {}
attributes:
type: object
@@ -10315,10 +10529,6 @@ components:
$ref: '#/components/schemas/OverviewProvider'
required:
- data
- OverviewProviderTypeEnum:
- type: string
- enum:
- - providers-overview
OverviewService:
type: object
required:
@@ -10327,11 +10537,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/OverviewServiceTypeEnum'
+ 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:
+ - services-overview
id: {}
attributes:
type: object
@@ -10359,10 +10570,6 @@ components:
$ref: '#/components/schemas/OverviewService'
required:
- data
- OverviewServiceTypeEnum:
- type: string
- enum:
- - services-overview
OverviewSeverity:
type: object
required:
@@ -10371,11 +10578,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/OverviewSeverityTypeEnum'
+ 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:
+ - findings-severity-overview
id: {}
attributes:
type: object
@@ -10406,10 +10614,6 @@ components:
$ref: '#/components/schemas/OverviewSeverity'
required:
- data
- OverviewSeverityTypeEnum:
- type: string
- enum:
- - findings-severity-overview
PaginatedComplianceOverviewAttributesList:
type: object
properties:
@@ -10633,6 +10837,7 @@ components:
* `aws_security_hub` - AWS Security Hub
* `jira` - JIRA
* `slack` - Slack
+ x-spec-enum-id: 6cfd0ff9cf4d6dcc
readOnly: true
configuration:
oneOf:
@@ -10785,6 +10990,7 @@ components:
* `accepted` - Invitation was accepted by a user
* `expired` - Invitation expired after the configured time
* `revoked` - Invitation was revoked by a user
+ x-spec-enum-id: 92a1c1fd12e13e25
readOnly: true
token:
type: string
@@ -10868,6 +11074,7 @@ components:
- gpt-5-mini-2025-08-07
- gpt-5-mini
type: string
+ x-spec-enum-id: bd2108eb25b2d472
description: |-
Must be one of the supported model names
@@ -11174,6 +11381,7 @@ components:
* `static` - Key-value pairs
* `role` - Role assumption
* `service_account` - GCP Service Account Key
+ x-spec-enum-id: 20c74ebcd6671497
secret:
oneOf:
- type: object
@@ -11806,11 +12014,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/Type552Enum'
+ 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:
+ - processors
id:
type: string
format: uuid
@@ -11830,6 +12039,7 @@ components:
- mutelist
type: string
description: '* `mutelist` - Mutelist'
+ x-spec-enum-id: 53cda4a99fe3d655
configuration:
oneOf:
- type: object
@@ -11907,11 +12117,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/Type552Enum'
+ 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:
+ - processors
attributes:
type: object
properties:
@@ -11928,6 +12139,7 @@ components:
- mutelist
type: string
description: '* `mutelist` - Mutelist'
+ x-spec-enum-id: 53cda4a99fe3d655
configuration:
oneOf:
- type: object
@@ -12030,6 +12242,7 @@ components:
- mutelist
type: string
description: '* `mutelist` - Mutelist'
+ x-spec-enum-id: 53cda4a99fe3d655
configuration:
oneOf:
- type: object
@@ -12124,11 +12337,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/Type552Enum'
+ 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:
+ - processors
id: {}
attributes:
type: object
@@ -12225,11 +12439,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/Type227Enum'
+ 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:
+ - providers
id:
type: string
format: uuid
@@ -12260,6 +12475,7 @@ components:
* `kubernetes` - Kubernetes
* `m365` - M365
* `github` - GitHub
+ x-spec-enum-id: 4c1e219dad1cc0e7
uid:
type: string
title: Unique identifier for the provider, set by the provider
@@ -12347,11 +12563,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/Type227Enum'
+ 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:
+ - providers
attributes:
type: object
properties:
@@ -12376,6 +12593,7 @@ components:
* `kubernetes` - Kubernetes
* `m365` - M365
* `github` - GitHub
+ x-spec-enum-id: 4c1e219dad1cc0e7
uid:
type: string
title: Unique identifier for the provider, set by the provider
@@ -12423,6 +12641,7 @@ components:
* `kubernetes` - Kubernetes
* `m365` - M365
* `github` - GitHub
+ x-spec-enum-id: 4c1e219dad1cc0e7
uid:
type: string
minLength: 3
@@ -12447,11 +12666,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/Type34dEnum'
+ 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:
+ - provider-groups
id:
type: string
format: uuid
@@ -12537,11 +12757,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/Type34dEnum'
+ 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:
+ - provider-groups
attributes:
type: object
properties:
@@ -12828,11 +13049,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/Type049Enum'
+ 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:
+ - provider-secrets
id:
type: string
format: uuid
@@ -12862,6 +13084,7 @@ components:
* `static` - Key-value pairs
* `role` - Role assumption
* `service_account` - GCP Service Account Key
+ x-spec-enum-id: 20c74ebcd6671497
required:
- secret_type
relationships:
@@ -12900,11 +13123,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/Type049Enum'
+ 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:
+ - provider-secrets
attributes:
type: object
properties:
@@ -12931,6 +13155,7 @@ components:
* `static` - Key-value pairs
* `role` - Role assumption
* `service_account` - GCP Service Account Key
+ x-spec-enum-id: 20c74ebcd6671497
secret:
oneOf:
- type: object
@@ -13177,6 +13402,7 @@ components:
* `static` - Key-value pairs
* `role` - Role assumption
* `service_account` - GCP Service Account Key
+ x-spec-enum-id: 20c74ebcd6671497
secret:
oneOf:
- type: object
@@ -13406,11 +13632,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/Type049Enum'
+ 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:
+ - provider-secrets
id:
type: string
format: uuid
@@ -13440,6 +13667,7 @@ components:
* `static` - Key-value pairs
* `role` - Role assumption
* `service_account` - GCP Service Account Key
+ x-spec-enum-id: 20c74ebcd6671497
secret:
oneOf:
- type: object
@@ -13657,11 +13885,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/ResourceTypeEnum'
+ 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:
+ - resources
id:
type: string
format: uuid
@@ -13767,11 +13996,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/ResourceMetadataTypeEnum'
+ 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:
+ - resources-metadata
id: {}
attributes:
type: object
@@ -13799,10 +14029,6 @@ components:
$ref: '#/components/schemas/ResourceMetadata'
required:
- data
- ResourceMetadataTypeEnum:
- type: string
- enum:
- - resources-metadata
ResourceResponse:
type: object
properties:
@@ -13810,10 +14036,6 @@ components:
$ref: '#/components/schemas/Resource'
required:
- data
- ResourceTypeEnum:
- type: string
- enum:
- - resources
Role:
type: object
required:
@@ -13822,11 +14044,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/Type6bbEnum'
+ 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:
+ - roles
id:
type: string
format: uuid
@@ -13956,11 +14179,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/Type6bbEnum'
+ 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:
+ - roles
attributes:
type: object
properties:
@@ -14299,11 +14523,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/SAMLConfigurationTypeEnum'
+ 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:
+ - saml-configurations
id:
type: string
format: uuid
@@ -14378,10 +14603,6 @@ components:
$ref: '#/components/schemas/SAMLConfiguration'
required:
- data
- SAMLConfigurationTypeEnum:
- type: string
- enum:
- - saml-configurations
Scan:
type: object
required:
@@ -14390,11 +14611,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/TypeE53Enum'
+ 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:
+ - scans
id:
type: string
format: uuid
@@ -14414,6 +14636,7 @@ components:
description: |-
* `scheduled` - Scheduled
* `manual` - Manual
+ x-spec-enum-id: 2e52c981d1c89bdf
readOnly: true
state:
enum:
@@ -14431,6 +14654,7 @@ components:
* `completed` - Completed
* `failed` - Failed
* `cancelled` - Cancelled
+ x-spec-enum-id: d38ba07264e1ed34
readOnly: true
unique_resource_count:
type: integer
@@ -14614,11 +14838,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/TypeE53Enum'
+ 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:
+ - scans
id:
type: string
format: uuid
@@ -14678,11 +14903,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/TaskTypeEnum'
+ 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:
+ - tasks
id:
type: string
format: uuid
@@ -14723,10 +14949,6 @@ components:
$ref: '#/components/schemas/Task'
required:
- data
- TaskTypeEnum:
- type: string
- enum:
- - tasks
Tenant:
type: object
required:
@@ -14735,11 +14957,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/TenantTypeEnum'
+ 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:
+ - tenants
id:
type: string
format: uuid
@@ -14849,10 +15072,6 @@ components:
$ref: '#/components/schemas/Tenant'
required:
- data
- TenantTypeEnum:
- type: string
- enum:
- - tenants
Token:
type: object
required:
@@ -14860,11 +15079,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/TokenTypeEnum'
+ 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:
+ - tokens
attributes:
type: object
properties:
@@ -14896,11 +15116,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/TokenRefreshTypeEnum'
+ 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:
+ - tokens-refresh
attributes:
type: object
properties:
@@ -14948,10 +15169,6 @@ components:
$ref: '#/components/schemas/TokenRefresh'
required:
- data
- TokenRefreshTypeEnum:
- type: string
- enum:
- - tokens-refresh
TokenRequest:
type: object
properties:
@@ -15012,11 +15229,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/TokenSwitchTenantTypeEnum'
+ 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:
+ - tokens-switch-tenant
attributes:
type: object
properties:
@@ -15076,54 +15294,6 @@ components:
$ref: '#/components/schemas/TokenSwitchTenant'
required:
- data
- TokenSwitchTenantTypeEnum:
- type: string
- enum:
- - tokens-switch-tenant
- TokenTypeEnum:
- type: string
- enum:
- - tokens
- Type049Enum:
- type: string
- enum:
- - provider-secrets
- Type227Enum:
- type: string
- enum:
- - providers
- Type34dEnum:
- type: string
- enum:
- - provider-groups
- Type4bfEnum:
- type: string
- enum:
- - lighthouse-configurations
- Type552Enum:
- type: string
- enum:
- - processors
- Type6bbEnum:
- type: string
- enum:
- - roles
- Type8cdEnum:
- type: string
- enum:
- - users
- TypeC82Enum:
- type: string
- enum:
- - integrations
- TypeD4dEnum:
- type: string
- enum:
- - invitations
- TypeE53Enum:
- type: string
- enum:
- - scans
User:
type: object
required:
@@ -15132,11 +15302,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/Type8cdEnum'
+ 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
@@ -15230,11 +15401,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/Type8cdEnum'
+ 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
attributes:
type: object
properties:
@@ -15348,11 +15520,12 @@ components:
additionalProperties: false
properties:
type:
- allOf:
- - $ref: '#/components/schemas/Type8cdEnum'
+ 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
@@ -15397,30 +15570,45 @@ tags:
refreshing an existing token for authentication purposes.
- name: Tenant
description: Endpoints for managing tenants, along with their memberships.
+- name: Membership
+ description: Endpoints for managing tenant memberships, allowing retrieval and filtering
+ of user membership information within tenants.
- name: Invitation
description: Endpoints for tenant invitations management, allowing retrieval and
filtering of invitations, creating new invitations, accepting and revoking them.
+- name: Role
+ description: Endpoints for managing RBAC roles within tenants, allowing creation,
+ retrieval, updating, and deletion of role configurations and permissions.
- name: Provider
description: Endpoints for managing providers (AWS, GCP, Azure, etc...).
+- name: Provider Secret
+ description: Endpoints for managing provider secrets, allowing secure storage and
+ management of credentials for providers.
- name: Provider Group
description: Endpoints for managing provider groups.
+- name: Task
+ description: Endpoints for task management, allowing retrieval of task status and
+ revoking tasks that have not started.
- name: Scan
description: Endpoints for triggering manual scans and viewing scan results.
+- name: Schedule
+ description: Endpoints for managing scan schedules, allowing configuration of automated
+ scans with different scheduling options.
- name: Resource
description: Endpoints for managing resources discovered by scans, allowing retrieval
and filtering of resource information.
- name: Finding
description: Endpoints for managing findings, allowing retrieval and filtering of
findings that result from scans.
-- name: Overview
- description: Endpoints for retrieving aggregated summaries of resources from the
- system.
+- name: Processor
+ description: Endpoints for managing post-processors used to process Prowler findings,
+ including registration, configuration, and deletion of post-processing actions.
- name: Compliance Overview
description: Endpoints for checking the compliance overview, allowing filtering
by scan, provider or compliance framework ID.
-- name: Task
- description: Endpoints for task management, allowing retrieval of task status and
- revoking tasks that have not started.
+- name: Overview
+ description: Endpoints for retrieving aggregated summaries of resources from the
+ system.
- name: Integration
description: Endpoints for managing third-party integrations, including registration,
configuration, retrieval, and deletion of integrations such as S3, JIRA, or other
@@ -15429,6 +15617,6 @@ tags:
description: Endpoints for managing Lighthouse configurations, including creation,
retrieval, updating, and deletion of configurations such as OpenAI keys, models,
and business context.
-- name: Processor
- description: Endpoints for managing post-processors used to process Prowler findings,
- including registration, configuration, and deletion of post-processing actions.
+- name: SAML
+ description: Endpoints for Single Sign-On authentication management via SAML for
+ seamless user authentication.
diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py
index 28cbc3ab5f..59706c8bc1 100644
--- a/api/src/backend/api/v1/views.py
+++ b/api/src/backend/api/v1/views.py
@@ -308,23 +308,48 @@ class SchemaView(SpectacularAPIView):
"name": "Tenant",
"description": "Endpoints for managing tenants, along with their memberships.",
},
+ {
+ "name": "Membership",
+ "description": "Endpoints for managing tenant memberships, allowing retrieval and filtering of "
+ "user membership information within tenants.",
+ },
{
"name": "Invitation",
"description": "Endpoints for tenant invitations management, allowing retrieval and filtering of "
"invitations, creating new invitations, accepting and revoking them.",
},
+ {
+ "name": "Role",
+ "description": "Endpoints for managing RBAC roles within tenants, allowing creation, retrieval, "
+ "updating, and deletion of role configurations and permissions.",
+ },
{
"name": "Provider",
"description": "Endpoints for managing providers (AWS, GCP, Azure, etc...).",
},
+ {
+ "name": "Provider Secret",
+ "description": "Endpoints for managing provider secrets, allowing secure storage and management "
+ "of credentials for providers.",
+ },
{
"name": "Provider Group",
"description": "Endpoints for managing provider groups.",
},
+ {
+ "name": "Task",
+ "description": "Endpoints for task management, allowing retrieval of task status and "
+ "revoking tasks that have not started.",
+ },
{
"name": "Scan",
"description": "Endpoints for triggering manual scans and viewing scan results.",
},
+ {
+ "name": "Schedule",
+ "description": "Endpoints for managing scan schedules, allowing configuration of automated "
+ "scans with different scheduling options.",
+ },
{
"name": "Resource",
"description": "Endpoints for managing resources discovered by scans, allowing "
@@ -336,8 +361,9 @@ class SchemaView(SpectacularAPIView):
"findings that result from scans.",
},
{
- "name": "Overview",
- "description": "Endpoints for retrieving aggregated summaries of resources from the system.",
+ "name": "Processor",
+ "description": "Endpoints for managing post-processors used to process Prowler findings, including "
+ "registration, configuration, and deletion of post-processing actions.",
},
{
"name": "Compliance Overview",
@@ -345,9 +371,8 @@ class SchemaView(SpectacularAPIView):
" compliance framework ID.",
},
{
- "name": "Task",
- "description": "Endpoints for task management, allowing retrieval of task status and "
- "revoking tasks that have not started.",
+ "name": "Overview",
+ "description": "Endpoints for retrieving aggregated summaries of resources from the system.",
},
{
"name": "Integration",
@@ -357,12 +382,13 @@ class SchemaView(SpectacularAPIView):
{
"name": "Lighthouse",
"description": "Endpoints for managing Lighthouse configurations, including creation, retrieval, "
- "updating, and deletion of configurations such as OpenAI keys, models, and business context.",
+ "updating, and deletion of configurations such as OpenAI keys, models, and business "
+ "context.",
},
{
- "name": "Processor",
- "description": "Endpoints for managing post-processors used to process Prowler findings, including "
- "registration, configuration, and deletion of post-processing actions.",
+ "name": "SAML",
+ "description": "Endpoints for Single Sign-On authentication management via SAML for seamless user "
+ "authentication.",
},
]
return super().get(request, *args, **kwargs)
@@ -3033,7 +3059,9 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet):
description="Compliance overviews metadata obtained successfully",
response=ComplianceOverviewMetadataSerializer,
),
- 202: OpenApiResponse(description="The task is in progress"),
+ 202: OpenApiResponse(
+ description="The task is in progress", response=TaskSerializer
+ ),
500: OpenApiResponse(
description="Compliance overviews generation task failed"
),
@@ -3065,7 +3093,9 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet):
description="Compliance requirement details obtained successfully",
response=ComplianceOverviewDetailSerializer(many=True),
),
- 202: OpenApiResponse(description="The task is in progress"),
+ 202: OpenApiResponse(
+ description="The task is in progress", response=TaskSerializer
+ ),
500: OpenApiResponse(
description="Compliance overviews generation task failed"
),
diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py
index 514dcfc6ff..37d905ba58 100644
--- a/api/src/backend/config/django/base.py
+++ b/api/src/backend/config/django/base.py
@@ -116,6 +116,9 @@ SPECTACULAR_SETTINGS = {
"PREPROCESSING_HOOKS": [
"drf_spectacular_jsonapi.hooks.fix_nested_path_parameters",
],
+ "POSTPROCESSING_HOOKS": [
+ "api.schema_hooks.attach_task_202_examples",
+ ],
"TITLE": "API Reference - Prowler",
}
From b003fca37712bbdd90fa8313848cf57a1e447f4f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?V=C3=ADctor=20Fern=C3=A1ndez=20Poyatos?=
Date: Thu, 28 Aug 2025 12:55:46 +0200
Subject: [PATCH 14/37] fix(docs): remove empty sections (#8600)
---
api/src/backend/api/specs/v1.yaml | 6 ------
api/src/backend/api/v1/views.py | 10 ----------
2 files changed, 16 deletions(-)
diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml
index b09998234a..fa1520f60e 100644
--- a/api/src/backend/api/specs/v1.yaml
+++ b/api/src/backend/api/specs/v1.yaml
@@ -15570,9 +15570,6 @@ tags:
refreshing an existing token for authentication purposes.
- name: Tenant
description: Endpoints for managing tenants, along with their memberships.
-- name: Membership
- description: Endpoints for managing tenant memberships, allowing retrieval and filtering
- of user membership information within tenants.
- name: Invitation
description: Endpoints for tenant invitations management, allowing retrieval and
filtering of invitations, creating new invitations, accepting and revoking them.
@@ -15581,9 +15578,6 @@ tags:
retrieval, updating, and deletion of role configurations and permissions.
- name: Provider
description: Endpoints for managing providers (AWS, GCP, Azure, etc...).
-- name: Provider Secret
- description: Endpoints for managing provider secrets, allowing secure storage and
- management of credentials for providers.
- name: Provider Group
description: Endpoints for managing provider groups.
- name: Task
diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py
index 59706c8bc1..a75976c314 100644
--- a/api/src/backend/api/v1/views.py
+++ b/api/src/backend/api/v1/views.py
@@ -308,11 +308,6 @@ class SchemaView(SpectacularAPIView):
"name": "Tenant",
"description": "Endpoints for managing tenants, along with their memberships.",
},
- {
- "name": "Membership",
- "description": "Endpoints for managing tenant memberships, allowing retrieval and filtering of "
- "user membership information within tenants.",
- },
{
"name": "Invitation",
"description": "Endpoints for tenant invitations management, allowing retrieval and filtering of "
@@ -327,11 +322,6 @@ class SchemaView(SpectacularAPIView):
"name": "Provider",
"description": "Endpoints for managing providers (AWS, GCP, Azure, etc...).",
},
- {
- "name": "Provider Secret",
- "description": "Endpoints for managing provider secrets, allowing secure storage and management "
- "of credentials for providers.",
- },
{
"name": "Provider Group",
"description": "Endpoints for managing provider groups.",
From 358d4239c73f7f9476e8cb4d7123b5976f58553a Mon Sep 17 00:00:00 2001
From: Prowler Bot
Date: Thu, 28 Aug 2025 16:56:24 +0200
Subject: [PATCH 15/37] chore(release): Bump version to v5.12.0 (#8605)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
---
prowler/config/config.py | 2 +-
pyproject.toml | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/prowler/config/config.py b/prowler/config/config.py
index 46dd6f8c1c..6f596ee9ea 100644
--- a/prowler/config/config.py
+++ b/prowler/config/config.py
@@ -12,7 +12,7 @@ from prowler.lib.logger import logger
timestamp = datetime.today()
timestamp_utc = datetime.now(timezone.utc).replace(tzinfo=timezone.utc)
-prowler_version = "5.11.0"
+prowler_version = "5.12.0"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://prowler.com/wp-content/uploads/logo-html.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
diff --git a/pyproject.toml b/pyproject.toml
index 738d9042d5..d1381c298f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -74,7 +74,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
name = "prowler"
readme = "README.md"
requires-python = ">3.9.1,<3.13"
-version = "5.11.0"
+version = "5.12.0"
[project.scripts]
prowler = "prowler.__main__:prowler"
From 9e02da342b6288655c2b5af07247b0dbbbc0ce91 Mon Sep 17 00:00:00 2001
From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
Date: Thu, 28 Aug 2025 16:58:42 +0200
Subject: [PATCH 16/37] docs: Security Hub API and UI documentation (#8576)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Adrián Jesús Peña Rodríguez
Co-authored-by: Pepe Fagoaga
---
docs/tutorials/aws/securityhub.md | 2 +-
.../img/security-hub/create-integration.png | Bin 0 -> 404297 bytes
.../img/security-hub/integration-settings.png | Bin 0 -> 107320 bytes
.../img/security-hub/integrations-tab.png | Bin 0 -> 378347 bytes
.../prowler-app-security-hub-integration.md | 128 ++++++++++++++++++
mkdocs.yml | 4 +-
6 files changed, 132 insertions(+), 2 deletions(-)
create mode 100644 docs/tutorials/img/security-hub/create-integration.png
create mode 100644 docs/tutorials/img/security-hub/integration-settings.png
create mode 100644 docs/tutorials/img/security-hub/integrations-tab.png
create mode 100644 docs/tutorials/prowler-app-security-hub-integration.md
diff --git a/docs/tutorials/aws/securityhub.md b/docs/tutorials/aws/securityhub.md
index 1ece9e61e5..050896763b 100644
--- a/docs/tutorials/aws/securityhub.md
+++ b/docs/tutorials/aws/securityhub.md
@@ -132,7 +132,7 @@ prowler --security-hub --role arn:aws:iam::123456789012:role/ProwlerExecutionRol
```
???+ note
- The specified IAM role must have the necessary permissions to send findings to Security Hub. For details on the required permissions, refer to the IAM policy: [prowler-security-hub.json](https://github.com/prowler-cloud/prowler/blob/master/permissions/prowler-security-hub.json)
+ The specified IAM role must have the necessary permissions to send findings to Security Hub. For details on the required permissions, refer to the IAM policy: [prowler-additions-policy.json](https://github.com/prowler-cloud/prowler/blob/master/permissions/prowler-additions-policy.json)
## Sending Only Failed Findings to AWS Security Hub
diff --git a/docs/tutorials/img/security-hub/create-integration.png b/docs/tutorials/img/security-hub/create-integration.png
new file mode 100644
index 0000000000000000000000000000000000000000..145e68d2012ed762db1fadd438304203084f6222
GIT binary patch
literal 404297
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 |