diff --git a/.env b/.env
index 3a666b2e9c..3f4b16a4f6 100644
--- a/.env
+++ b/.env
@@ -158,7 +158,7 @@ SENTRY_RELEASE=local
# REO_DEV_CLIENT_ID=
#### Prowler release version ####
-NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.36.0
+NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.37.0
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
diff --git a/.github/workflows/api-container-checks.yml b/.github/workflows/api-container-checks.yml
index f8d5df5417..0e6609286f 100644
--- a/.github/workflows/api-container-checks.yml
+++ b/.github/workflows/api-container-checks.yml
@@ -113,6 +113,15 @@ jobs:
api/changelog.d/**
api/AGENTS.md
+ # api-container-build-push.yml resolves the SDK pin to the branch tip
+ # before building, so match it here and scan what ships. Push only: PRs
+ # stay deterministic against the committed lock.
+ - name: Refresh prowler SDK pin to current branch tip
+ if: steps.check-changes.outputs.any_changed == 'true' && github.event_name == 'push'
+ run: |
+ pip install --no-cache-dir "uv==0.11.14"
+ (cd api && uv lock --upgrade-package prowler)
+
- name: Set up Docker Buildx
if: steps.check-changes.outputs.any_changed == 'true'
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
diff --git a/.github/workflows/helm-chart-release.yml b/.github/workflows/helm-chart-release.yml
index e51125d222..9673bc8c5f 100644
--- a/.github/workflows/helm-chart-release.yml
+++ b/.github/workflows/helm-chart-release.yml
@@ -38,11 +38,14 @@ jobs:
- name: Set up Helm
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
- - name: Set appVersion from release tag
+ - name: Set chart version and appVersion from release tag
run: |
- RELEASE_TAG="${GITHUB_EVENT_RELEASE_TAG_NAME}"
- echo "Setting appVersion to ${RELEASE_TAG}"
- sed -i "s/^appVersion:.*/appVersion: \"${RELEASE_TAG}\"/" ${{ env.CHART_PATH }}/Chart.yaml
+ # Strip any leading "v" so the chart version is valid SemVer 2.
+ RELEASE_TAG="${GITHUB_EVENT_RELEASE_TAG_NAME#v}"
+ echo "Setting chart version and appVersion to ${RELEASE_TAG}"
+ # Publish an immutable chart version per release instead of the static
+ # 0.0.1 in source, so every release is a distinct, addressable artifact.
+ yq -i ".version = \"${RELEASE_TAG}\" | .appVersion = \"${RELEASE_TAG}\"" ${{ env.CHART_PATH }}/Chart.yaml
env:
GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
diff --git a/.trivyignore b/.trivyignore
index c0207c8374..da6ad95d85 100644
--- a/.trivyignore
+++ b/.trivyignore
@@ -35,6 +35,20 @@ CVE-2026-13221 pkg:perl-base exp:2026-08-15
CVE-2026-13221 pkg:perl-modules-5.36 exp:2026-08-15
CVE-2026-13221 pkg:libperl5.36 exp:2026-08-15
+# CVE-2026-57433 — Perl Storable signed integer overflow when deserializing a
+# crafted SX_HOOK record (retrieve_hook_common passes a wrapped negative count
+# to av_extend).
+# Packages: perl, perl-base, perl-modules-5.36, libperl5.36.
+# Why ignored: perl-base is part of Debian's "Essential: yes" set; it cannot be
+# removed without breaking dpkg. Prowler does not invoke perl at runtime and
+# never calls Storable's thaw/retrieve on attacker-controlled blobs, so the
+# vulnerable deserialization path is unreachable. Fixed upstream in
+# Storable 3.41; no Debian bookworm fix is available yet.
+CVE-2026-57433 pkg:perl exp:2026-08-15
+CVE-2026-57433 pkg:perl-base exp:2026-08-15
+CVE-2026-57433 pkg:perl-modules-5.36 exp:2026-08-15
+CVE-2026-57433 pkg:libperl5.36 exp:2026-08-15
+
# CVE-2025-7458 — SQLite integer overflow.
# Package: libsqlite3-0.
# Why ignored: transitive dependency of CPython's stdlib sqlite3 module. The
diff --git a/AGENTS.md b/AGENTS.md
index 763b3f10e5..997c4bbaff 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -62,6 +62,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Action | Skill |
|--------|-------|
| Add changelog entry for a PR or feature | `prowler-changelog` |
+| Adding ConfigRequirements guardrails to compliance requirements | `prowler-compliance` |
| Adding DRF pagination or permissions | `django-drf` |
| Adding a compliance output formatter (per-provider class + table dispatcher) | `prowler-compliance` |
| Adding indexes or constraints to database tables | `django-migration-psql` |
@@ -84,6 +85,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Creating ViewSets, serializers, or filters in api/ | `django-drf` |
| Creating Zod schemas | `zod-4` |
| Creating a git commit | `prowler-commit` |
+| Creating a universal (multi-provider) compliance framework | `prowler-compliance` |
| Creating new checks | `prowler-sdk-check` |
| Creating new skills | `skill-creator` |
| Creating or reviewing Django migrations | `django-migration-psql` |
diff --git a/README.md b/README.md
index 76e6534551..0d0fd1fdfe 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,10 @@
Prowler is the Open Cloud Security Platform trusted by thousands to automate security and compliance in any cloud environment. With thousands of ready-to-use checks and compliance frameworks, Prowler delivers real-time, customizable monitoring and seamless integrations, making cloud security simple, scalable, and cost-effective for organizations of any size.
-Secure ANY cloud at AI Speed at prowler.com
+The Agentic Cloud Defender
+
+
+Try Prowler Cloud
@@ -56,7 +59,7 @@ Prowler includes hundreds of built-in controls to ensure compliance with standar
## Prowler Cloud & Prowler Local Server
-[Prowler Cloud](https://cloud.prowler.com/) and Prowler Local Server, its self-hosted open-source version, are web applications that simplify running Prowler across your cloud provider accounts. They provide a user-friendly interface to visualize the results and streamline your security assessments.
+[Prowler Cloud](https://cloud.prowler.com/sign-up) and Prowler Local Server, its self-hosted open-source version, are web applications that simplify running Prowler across your cloud provider accounts. They provide a user-friendly interface to visualize the results and streamline your security assessments.


@@ -135,12 +138,13 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
| IaC | [See `trivy` docs.](https://trivy.dev/latest/docs/coverage/iac/) | N/A | N/A | N/A | Official | UI, API, CLI |
| MongoDB Atlas | 10 | 3 | 1 | 8 | Official | UI, API, CLI |
| LLM | [See `promptfoo` docs.](https://www.promptfoo.dev/docs/red-team/plugins/) | N/A | N/A | N/A | Official | CLI |
-| Image | N/A | N/A | N/A | N/A | Official | CLI, API |
+| Image | N/A | N/A | N/A | N/A | Official | UI, API, CLI |
| Google Workspace | 65 | 11 | 3 | 6 | Official | UI, API, CLI |
| OpenStack | 34 | 5 | 1 | 9 | Official | UI, API, CLI |
| Vercel | 26 | 6 | 1 | 8 | Official | UI, API, CLI |
| Okta | 29 | 8 | 2 | 2 | Official | UI, API, CLI |
| Linode [Contact us](https://prowler.com/contact) | 10 | 3 | 1 | 4 | Unofficial | CLI |
+| Huawei Cloud [Contact us](https://prowler.com/contact) | 25 | 10 | 1 | 6 | Unofficial | CLI |
| E2E Networks [Contact us](https://prowler.com/contact) | 27 | 6 | 0 | 2 | Unofficial | CLI |
| Scaleway [Contact us](https://prowler.com/contact) | 1 | 1 | 1 | 1 | Unofficial | CLI |
| StackIT [Contact us](https://prowler.com/contact) | 7 | 2 | 1 | 3 | Unofficial | CLI |
diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md
index 1b26c2b014..bf196f6328 100644
--- a/api/CHANGELOG.md
+++ b/api/CHANGELOG.md
@@ -4,6 +4,28 @@ All notable changes to the **Prowler API** are documented in this file.
+## [1.37.0] (Prowler v5.36.0)
+
+### 🔄 Changed
+
+- OCI provider secrets no longer require `region`; legacy `region` input is accepted for backwards compatibility but ignored before storing or scanning [(#11741)](https://github.com/prowler-cloud/prowler/pull/11741)
+- Compliance overview ingest now runs in a single transaction per scan with a configurable `COPY` batch size (`DJANGO_COMPLIANCE_COPY_BATCH_SIZE`, default 2000), reducing write pressure on the database [(#11875)](https://github.com/prowler-cloud/prowler/pull/11875)
+
+### 🐞 Fixed
+
+- Scan findings now recover resources missing from the in-memory cache after resource pre-resolution, preventing valid findings from being skipped [(#12002)](https://github.com/prowler-cloud/prowler/pull/12002)
+- Tenant-wide integrations that are not attached to any provider, such as Jira, are now visible and manageable by roles with `manage_integrations` and without unlimited visibility [(#12060)](https://github.com/prowler-cloud/prowler/pull/12060)
+- Output generation now removes the scan's temporary output directory before writing, so a re-run of the task for the same scan (e.g. broker redelivery after a worker is killed mid-run) no longer appends to the previous run's files and duplicates finding rows in the exported CSV and other outputs [(#12097)](https://github.com/prowler-cloud/prowler/pull/12097)
+
+### 🔐 Security
+
+- Integration responses no longer disclose providers outside the visibility of the role, including the resources sideloaded through `?include=providers` [(#12060)](https://github.com/prowler-cloud/prowler/pull/12060)
+- Integration connection checks, Jira issue type lookups and Jira dispatches now resolve the integration through the provider visibility of the role instead of the whole tenant [(#12060)](https://github.com/prowler-cloud/prowler/pull/12060)
+- Roles without unlimited visibility can no longer attach an integration to providers they cannot see, nor edit or delete an integration bound to them [(#12060)](https://github.com/prowler-cloud/prowler/pull/12060)
+- Kubernetes kubeconfig validation now rejects legacy `auth-provider.config.cmd-path` command authentication in Prowler Cloud/API [(#12091)](https://github.com/prowler-cloud/prowler/pull/12091)
+
+---
+
## [1.36.0] (Prowler v5.35.0)
### 🐞 Fixed
diff --git a/api/Dockerfile b/api/Dockerfile
index 8d6923bbfc..ec8237d44a 100644
--- a/api/Dockerfile
+++ b/api/Dockerfile
@@ -102,7 +102,9 @@ ENV PATH="/home/prowler/.local/bin:$PATH"
RUN uv sync --locked --no-install-project && \
rm -rf ~/.cache/uv
-RUN .venv/bin/python .venv/lib/python3.12/site-packages/prowler/providers/m365/lib/powershell/m365_powershell.py
+# Invoked as a module so the base image's Python minor version is not baked
+# into a site-packages path.
+RUN .venv/bin/python -m prowler.providers.m365.lib.powershell.m365_powershell
USER root
diff --git a/api/changelog.d/compliance-overview-single-transaction.changed.md b/api/changelog.d/compliance-overview-single-transaction.changed.md
deleted file mode 100644
index 0e7a6714b3..0000000000
--- a/api/changelog.d/compliance-overview-single-transaction.changed.md
+++ /dev/null
@@ -1 +0,0 @@
-Compliance overview ingest now runs in a single transaction per scan with a configurable `COPY` batch size (`DJANGO_COMPLIANCE_COPY_BATCH_SIZE`, default 2000), reducing write pressure on the database
diff --git a/api/changelog.d/oci-regionless-api-legacy-region.changed.md b/api/changelog.d/oci-regionless-api-legacy-region.changed.md
deleted file mode 100644
index 087b027c88..0000000000
--- a/api/changelog.d/oci-regionless-api-legacy-region.changed.md
+++ /dev/null
@@ -1 +0,0 @@
-OCI provider secrets no longer require `region`; legacy `region` input is accepted for backwards compatibility but ignored before storing or scanning
diff --git a/api/pyproject.toml b/api/pyproject.toml
index 607a8c7348..7eec4009ac 100644
--- a/api/pyproject.toml
+++ b/api/pyproject.toml
@@ -71,7 +71,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
-version = "1.37.0"
+version = "1.38.0"
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
# target-version tracks this project's lowest supported Python.
diff --git a/api/src/backend/api/base_views.py b/api/src/backend/api/base_views.py
index e8dd728cb9..7a2c9c61c4 100644
--- a/api/src/backend/api/base_views.py
+++ b/api/src/backend/api/base_views.py
@@ -3,9 +3,10 @@ from api.db_router import MainRouter, reset_read_db_alias, set_read_db_alias
from api.db_utils import POSTGRES_USER_VAR, rls_transaction
from api.filters import CustomDjangoFilterBackend
from api.models import Role, UserRoleRelationship
-from api.rbac.permissions import HasPermissions
+from api.rbac.permissions import HasPermissions, get_role
from django.conf import settings
from django.db import transaction
+from django.utils.functional import cached_property
from rest_framework import permissions
from rest_framework.exceptions import NotAuthenticated
from rest_framework.filters import SearchFilter
@@ -100,6 +101,11 @@ class BaseRLSViewSet(BaseViewSet):
context["tenant_id"] = self.request.tenant_id
return context
+ @cached_property
+ def user_role(self):
+ """Role of the requesting user in the active tenant, resolved once per request."""
+ return get_role(self.request.user, self.request.tenant_id)
+
class BaseTenantViewset(BaseViewSet):
def dispatch(self, request, *args, **kwargs):
diff --git a/api/src/backend/api/rbac/permissions.py b/api/src/backend/api/rbac/permissions.py
index 3458346a5f..e2c209a990 100644
--- a/api/src/backend/api/rbac/permissions.py
+++ b/api/src/backend/api/rbac/permissions.py
@@ -1,8 +1,8 @@
from enum import Enum
from api.db_router import MainRouter
-from api.models import Provider, Role, User
-from django.db.models import QuerySet
+from api.models import Integration, Provider, Role, User
+from django.db.models import Q, QuerySet
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import BasePermission
@@ -83,3 +83,32 @@ def get_providers(role: Role) -> QuerySet[Provider]:
return Provider.objects.filter(
tenant_id=tenant_id, provider_groups__in=provider_groups
).distinct()
+
+
+def get_integrations(
+ role: Role, providers: QuerySet[Provider] | None = None
+) -> QuerySet[Integration]:
+ """
+ Return a distinct queryset of Integrations visible to the given role.
+
+ Integrations with no providers attached are tenant-wide, as is always the case for
+ Jira, and stay visible regardless of the provider visibility of the role. Integrations
+ attached to providers are only visible when the role can access at least one of them.
+
+ Args:
+ role: A Role instance.
+ providers: Optional queryset of the providers accessible by the role, to reuse
+ an already resolved `get_providers(role)` result within the same request.
+
+ Returns:
+ A QuerySet of Integration objects visible to the role.
+ """
+ queryset = Integration.objects.filter(tenant_id=role.tenant_id)
+ if role.unlimited_visibility:
+ return queryset
+
+ if providers is None:
+ providers = get_providers(role)
+ return queryset.filter(
+ Q(providers__isnull=True) | Q(providers__in=providers)
+ ).distinct()
diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml
index 420ebb27cf..82b42853d1 100644
--- a/api/src/backend/api/specs/v1.yaml
+++ b/api/src/backend/api/specs/v1.yaml
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
- version: 1.37.0
+ version: 1.38.0
description: |-
Prowler API specification.
@@ -6629,8 +6629,10 @@ paths:
/api/v1/integrations:
get:
operationId: api_v1_integrations_list
- description: Retrieve a list of all configured integrations with options for
- filtering by various criteria.
+ description: |-
+ Retrieve a list of all configured integrations with options for filtering by various criteria.
+
+ Integrations attached to one or more providers are only returned when the role can access at least one of those providers, and each integration lists only the providers visible to the role. Integrations not attached to any provider, such as Jira, are tenant-wide and are returned for every role.
summary: List all integrations
parameters:
- in: query
@@ -6781,7 +6783,8 @@ paths:
post:
operationId: api_v1_integrations_create
description: Register a new integration with the system, providing necessary
- configuration details.
+ configuration details. Only providers visible to the role can be attached
+ to the integration.
summary: Create a new integration
tags:
- Integration
@@ -6810,7 +6813,7 @@ paths:
post:
operationId: api_v1_integrations_jira_dispatches_create
description: |-
- Send a set of filtered findings to the given integration. At least one finding filter must be provided.
+ Send a set of filtered findings to the given integration. At least one finding filter must be provided. Jira integrations are tenant-wide and do not require unlimited visibility, while the findings sent are limited to the providers the role can access.
## Known Limitations
@@ -6883,7 +6886,8 @@ paths:
get:
operationId: api_v1_integrations_jira_issue_types_retrieve
description: Fetch the available issue types from Jira for a given project key
- and update the integration configuration.
+ and update the integration configuration. Jira integrations are tenant-wide
+ and do not require unlimited visibility.
summary: Get available issue types for a Jira project
parameters:
- in: query
@@ -6924,7 +6928,8 @@ paths:
get:
operationId: api_v1_integrations_retrieve
description: Fetch detailed information about a specific integration by its
- ID.
+ ID. Integrations outside the provider visibility of the role are reported
+ the same way as one that does not exist.
summary: Retrieve integration details
parameters:
- in: query
@@ -6978,7 +6983,8 @@ paths:
patch:
operationId: api_v1_integrations_partial_update
description: Modify certain fields of an existing integration without affecting
- other settings.
+ other settings. Integrations attached to providers outside the visibility
+ of the role cannot be modified by it.
summary: Partially update an integration
parameters:
- in: path
@@ -7013,7 +7019,8 @@ paths:
description: ''
delete:
operationId: api_v1_integrations_destroy
- description: Remove an integration from the system by its ID.
+ description: Remove an integration from the system by its ID. Integrations attached
+ to providers outside the visibility of the role cannot be deleted by it.
summary: Delete an integration
parameters:
- in: path
@@ -7033,7 +7040,9 @@ paths:
/api/v1/integrations/{id}/connection:
post:
operationId: api_v1_integrations_connection_create
- description: Try to verify integration connection
+ description: Try to verify integration connection. Integrations outside the
+ provider visibility of the role are reported the same way as one that does
+ not exist.
summary: Check integration connection
parameters:
- in: path
diff --git a/api/src/backend/api/tests/test_rbac.py b/api/src/backend/api/tests/test_rbac.py
index ed177138b2..92d19bd3c0 100644
--- a/api/src/backend/api/tests/test_rbac.py
+++ b/api/src/backend/api/tests/test_rbac.py
@@ -3,6 +3,8 @@ from unittest.mock import ANY, Mock, patch
import pytest
from api.models import (
+ Integration,
+ IntegrationProviderRelationship,
Membership,
ProviderGroup,
ProviderGroupMembership,
@@ -681,6 +683,363 @@ class TestLimitedVisibility:
response.json()["data"]["relationships"]["providers"]["meta"]["count"] == 1
)
+ @pytest.fixture
+ def jira_integration(self, tenants_fixture):
+ # Jira is a tenant-wide integration: it is not attached to any provider
+ return Integration.objects.create(
+ tenant_id=tenants_fixture[0].id,
+ enabled=True,
+ connected=True,
+ integration_type=Integration.IntegrationChoices.JIRA,
+ configuration={"projects": {"TEST": "Test project"}},
+ credentials={
+ "domain": "test",
+ "user_mail": "a@b.com",
+ "api_token": "token",
+ },
+ )
+
+ @pytest.fixture
+ def out_of_scope_integration(self, tenants_fixture, provider_factory):
+ tenant_id = tenants_fixture[0].id
+ integration = Integration.objects.create(
+ tenant_id=tenant_id,
+ enabled=True,
+ connected=True,
+ integration_type=Integration.IntegrationChoices.AMAZON_S3,
+ configuration={
+ "bucket_name": "bucket",
+ "output_directory": "output",
+ },
+ credentials={"aws_access_key_id": "key"},
+ )
+ IntegrationProviderRelationship.objects.create(
+ tenant_id=tenant_id,
+ integration=integration,
+ provider=provider_factory(),
+ )
+ return integration
+
+ def test_integrations_list_includes_tenant_wide_integration(
+ self,
+ authenticated_client_rbac_limited,
+ integrations_fixture,
+ jira_integration,
+ aws_provider_pair,
+ ):
+ # Integration 2 is attached to both providers, so make both visible to the role
+ # to assert the provider join does not duplicate it in the listing
+ ProviderGroupMembership.objects.create(
+ tenant_id=aws_provider_pair[1].tenant_id,
+ provider=aws_provider_pair[1],
+ provider_group=ProviderGroup.objects.get(name="limited_visibility_group"),
+ )
+
+ response = authenticated_client_rbac_limited.get(reverse("integration-list"))
+
+ assert response.status_code == status.HTTP_200_OK
+ integration_ids = [item["id"] for item in response.json()["data"]]
+ # The tenant-wide Jira integration is visible without unlimited visibility
+ assert str(jira_integration.id) in integration_ids
+ # Integrations attached to more than one visible provider are not duplicated
+ assert integration_ids.count(str(integrations_fixture[1].id)) == 1
+ assert response.json()["meta"]["pagination"]["count"] == len(integration_ids)
+
+ def test_integrations_list_without_provider_groups_keeps_tenant_wide_integration(
+ self, authenticated_client_rbac_limited, integrations_fixture, jira_integration
+ ):
+ # A role with no provider group at all sees no provider, but still needs Jira
+ RoleProviderGroupRelationship.objects.all().delete()
+
+ response = authenticated_client_rbac_limited.get(reverse("integration-list"))
+
+ assert response.status_code == status.HTTP_200_OK
+ integration_ids = [item["id"] for item in response.json()["data"]]
+ assert integration_ids == [str(jira_integration.id)]
+
+ def test_integrations_include_providers_hides_out_of_scope_providers(
+ self, authenticated_client_rbac_limited, integrations_fixture, aws_provider_pair
+ ):
+ # Integration 2 is related to provider1 (visible) and provider2 (not visible)
+ hidden_provider = aws_provider_pair[1]
+
+ response = authenticated_client_rbac_limited.get(
+ reverse("integration-list"), {"include": "providers"}
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ included_ids = {item["id"] for item in response.json().get("included", [])}
+ assert str(aws_provider_pair[0].id) in included_ids
+ # Sideloaded resources must not disclose the provider the role cannot see
+ assert str(hidden_provider.id) not in included_ids
+
+ def test_integrations_list_with_sparse_fields(
+ self, authenticated_client_rbac_limited, integrations_fixture
+ ):
+ response = authenticated_client_rbac_limited.get(
+ reverse("integration-list"), {"fields[integrations]": "enabled"}
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert all(
+ list(item["attributes"].keys()) == ["enabled"]
+ for item in response.json()["data"]
+ )
+
+ def test_integrations_list_excludes_out_of_scope_integration(
+ self, authenticated_client_rbac_limited, out_of_scope_integration
+ ):
+ response = authenticated_client_rbac_limited.get(reverse("integration-list"))
+
+ assert response.status_code == status.HTTP_200_OK
+ integration_ids = [item["id"] for item in response.json()["data"]]
+ assert str(out_of_scope_integration.id) not in integration_ids
+
+ def test_integration_detail_out_of_scope_returns_404(
+ self, authenticated_client_rbac_limited, out_of_scope_integration
+ ):
+ response = authenticated_client_rbac_limited.get(
+ reverse("integration-detail", kwargs={"pk": out_of_scope_integration.id})
+ )
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_integration_connection_out_of_scope_returns_404(
+ self, authenticated_client_rbac_limited, out_of_scope_integration
+ ):
+ response = authenticated_client_rbac_limited.post(
+ reverse(
+ "integration-connection", kwargs={"pk": out_of_scope_integration.id}
+ )
+ )
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_integration_update_allowed_when_fully_visible(
+ self, authenticated_client_rbac_limited, integrations_fixture, jira_integration
+ ):
+ # Integration 1 is only related to provider1, which the role can access
+ integration = integrations_fixture[0]
+ payload = {
+ "data": {
+ "type": "integrations",
+ "id": str(integration.id),
+ "attributes": {
+ "enabled": False,
+ # integration_type is `amazon_s3`
+ "credentials": {"aws_access_key_id": "new_value"},
+ "configuration": {
+ "bucket_name": "new_bucket_name",
+ "output_directory": "new_output_directory",
+ },
+ },
+ }
+ }
+
+ response = authenticated_client_rbac_limited.patch(
+ reverse("integration-detail", kwargs={"pk": integration.id}),
+ data=json.dumps(payload),
+ content_type="application/vnd.api+json",
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ integration.refresh_from_db()
+ assert integration.enabled is False
+
+ # Tenant-wide integrations have no provider restricting the role
+ payload = {
+ "data": {
+ "type": "integrations",
+ "id": str(jira_integration.id),
+ "attributes": {"enabled": False},
+ }
+ }
+
+ response = authenticated_client_rbac_limited.patch(
+ reverse("integration-detail", kwargs={"pk": jira_integration.id}),
+ data=json.dumps(payload),
+ content_type="application/vnd.api+json",
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ jira_integration.refresh_from_db()
+ assert jira_integration.enabled is False
+
+ def test_integration_create_rejects_out_of_scope_provider(
+ self, authenticated_client_rbac_limited, aws_provider_pair
+ ):
+ # provider2 is not in any provider group assigned to the role
+ payload = {
+ "data": {
+ "type": "integrations",
+ "attributes": {
+ "integration_type": "amazon_s3",
+ "configuration": {
+ "bucket_name": "attacker_bucket",
+ "output_directory": "output",
+ },
+ "credentials": {"aws_access_key_id": "key"},
+ },
+ "relationships": {
+ "providers": {
+ "data": [
+ {"type": "providers", "id": str(aws_provider_pair[1].id)}
+ ]
+ }
+ },
+ }
+ }
+
+ response = authenticated_client_rbac_limited.post(
+ reverse("integration-list"),
+ data=json.dumps(payload),
+ content_type="application/vnd.api+json",
+ )
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert not Integration.objects.filter(
+ integrationproviderrelationship__provider=aws_provider_pair[1],
+ configuration__bucket_name="attacker_bucket",
+ ).exists()
+
+ @pytest.mark.parametrize("submitted_providers", [True, False])
+ def test_integration_update_denied_when_shared_with_hidden_provider(
+ self,
+ authenticated_client_rbac_limited,
+ integrations_fixture,
+ aws_provider_pair,
+ submitted_providers,
+ ):
+ # Integration 2 is related to provider1 (visible) and provider2 (not visible).
+ # Editing it would reach beyond the visibility of the role, just like deleting
+ # it, so both are rejected consistently
+ integration = integrations_fixture[1]
+ visible_provider, hidden_provider = aws_provider_pair
+ payload = {
+ "data": {
+ "type": "integrations",
+ "id": str(integration.id),
+ "attributes": {
+ "enabled": False,
+ # integration_type is `amazon_s3`
+ "credentials": {"aws_access_key_id": "new_value"},
+ "configuration": {
+ "bucket_name": "new_bucket_name",
+ "output_directory": "new_output_directory",
+ },
+ },
+ }
+ }
+ if submitted_providers:
+ payload["data"]["relationships"] = {
+ "providers": {
+ "data": [{"type": "providers", "id": str(visible_provider.id)}]
+ }
+ }
+
+ response = authenticated_client_rbac_limited.patch(
+ reverse("integration-detail", kwargs={"pk": integration.id}),
+ data=json.dumps(payload),
+ content_type="application/vnd.api+json",
+ )
+
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+ integration.refresh_from_db()
+ assert integration.enabled is True
+ assert integration.providers.filter(id=hidden_provider.id).exists()
+ assert integration.providers.filter(id=visible_provider.id).exists()
+
+ def test_integration_delete_denied_when_shared_with_hidden_provider(
+ self, authenticated_client_rbac_limited, integrations_fixture
+ ):
+ # Integration 2 is related to provider1 (visible) and provider2 (not visible)
+ integration = integrations_fixture[1]
+
+ response = authenticated_client_rbac_limited.delete(
+ reverse("integration-detail", kwargs={"pk": integration.id})
+ )
+
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+ assert Integration.objects.filter(id=integration.id).exists()
+
+ def test_integration_delete_allowed_when_fully_visible(
+ self, authenticated_client_rbac_limited, integrations_fixture, jira_integration
+ ):
+ # Integration 1 is only related to provider1, which the role can access
+ integration = integrations_fixture[0]
+
+ response = authenticated_client_rbac_limited.delete(
+ reverse("integration-detail", kwargs={"pk": integration.id})
+ )
+
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+ assert not Integration.objects.filter(id=integration.id).exists()
+
+ # Tenant-wide integrations have no provider restricting the role
+ response = authenticated_client_rbac_limited.delete(
+ reverse("integration-detail", kwargs={"pk": jira_integration.id})
+ )
+
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+
+ def test_jira_issue_types_allowed_without_unlimited_visibility(
+ self, authenticated_client_rbac_limited, jira_integration
+ ):
+ with patch("api.v1.views.initialize_prowler_integration") as mock_jira:
+ mock_jira.return_value.get_available_issue_types.return_value = ["Task"]
+ response = authenticated_client_rbac_limited.get(
+ reverse(
+ "integration-jira-issue-types",
+ kwargs={"integration_pk": jira_integration.id},
+ ),
+ {"project_key": "TEST"},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json()["data"]["attributes"]["issue_types"] == ["Task"]
+
+ def test_jira_issue_types_out_of_scope_returns_404(
+ self, authenticated_client_rbac_limited, out_of_scope_integration
+ ):
+ response = authenticated_client_rbac_limited.get(
+ reverse(
+ "integration-jira-issue-types",
+ kwargs={"integration_pk": out_of_scope_integration.id},
+ ),
+ {"project_key": "TEST"},
+ )
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_jira_dispatches_out_of_scope_returns_404(
+ self, authenticated_client_rbac_limited, out_of_scope_integration
+ ):
+ response = authenticated_client_rbac_limited.post(
+ reverse(
+ "integration-jira-dispatches",
+ kwargs={"integration_pk": out_of_scope_integration.id},
+ ),
+ data=json.dumps({}),
+ content_type="application/vnd.api+json",
+ )
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_jira_dispatches_allowed_without_unlimited_visibility(
+ self, authenticated_client_rbac_limited, jira_integration
+ ):
+ response = authenticated_client_rbac_limited.post(
+ reverse(
+ "integration-jira-dispatches",
+ kwargs={"integration_pk": jira_integration.id},
+ ),
+ data=json.dumps({}),
+ content_type="application/vnd.api+json",
+ )
+
+ # The integration is reachable: the request fails on payload validation, not RBAC
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
@pytest.mark.usefixtures("scan_summaries_fixture")
def test_overviews_providers(
self,
diff --git a/api/src/backend/api/tests/test_serializers.py b/api/src/backend/api/tests/test_serializers.py
index 8e77d63604..0d5b517f97 100644
--- a/api/src/backend/api/tests/test_serializers.py
+++ b/api/src/backend/api/tests/test_serializers.py
@@ -309,6 +309,36 @@ current-context: test-context
assert not serializer.is_valid()
assert "kubeconfig_content" in serializer.errors
+ def test_kubeconfig_with_auth_provider_cmd_path_is_rejected(self):
+ kubeconfig_content = """
+apiVersion: v1
+kind: Config
+clusters:
+ - name: test-cluster
+ cluster:
+ server: https://kubernetes.example.test
+users:
+ - name: test-user
+ user:
+ auth-provider:
+ name: gcp
+ config:
+ cmd-path: /bin/sh
+contexts:
+ - name: test-context
+ context:
+ cluster: test-cluster
+ user: test-user
+current-context: test-context
+"""
+
+ serializer = KubernetesProviderSecret(
+ data={"kubeconfig_content": kubeconfig_content}
+ )
+
+ assert not serializer.is_valid()
+ assert "kubeconfig_content" in serializer.errors
+
def test_malformed_kubeconfig_is_rejected(self):
serializer = KubernetesProviderSecret(
data={"kubeconfig_content": "apiVersion: ["}
diff --git a/api/src/backend/api/v1/serializer_utils/integrations.py b/api/src/backend/api/v1/serializer_utils/integrations.py
index ac876d1d5b..aa941b6c2a 100644
--- a/api/src/backend/api/v1/serializer_utils/integrations.py
+++ b/api/src/backend/api/v1/serializer_utils/integrations.py
@@ -1,7 +1,9 @@
import os
import re
+from api.models import Integration, IntegrationProviderRelationship, Provider
from api.v1.serializer_utils.base import BaseValidateSerializer
+from django.db import transaction
from drf_spectacular.utils import extend_schema_field
from rest_framework_json_api import serializers
@@ -10,6 +12,24 @@ ATLASSIAN_SITE_NAME_REGEX = re.compile(
)
+def replace_integration_providers(
+ integration: Integration, providers: list[Provider], tenant_id: str
+) -> None:
+ """Replace the provider relationships of an integration with the given set."""
+ # Atomic on its own, so callers without an ambient transaction cannot leave the
+ # integration with no relationships if the recreation fails halfway
+ with transaction.atomic():
+ IntegrationProviderRelationship.objects.filter(integration=integration).delete()
+ IntegrationProviderRelationship.objects.bulk_create(
+ [
+ IntegrationProviderRelationship(
+ integration=integration, provider=provider, tenant_id=tenant_id
+ )
+ for provider in providers
+ ]
+ )
+
+
class S3ConfigSerializer(BaseValidateSerializer):
bucket_name = serializers.CharField()
output_directory = serializers.CharField(allow_blank=True)
diff --git a/api/src/backend/api/v1/serializer_utils/providers.py b/api/src/backend/api/v1/serializer_utils/providers.py
index 49b593049f..0d80b47c0a 100644
--- a/api/src/backend/api/v1/serializer_utils/providers.py
+++ b/api/src/backend/api/v1/serializer_utils/providers.py
@@ -214,7 +214,7 @@ from rest_framework_json_api import serializers
"kubeconfig_content": {
"type": "string",
"description": "The content of the Kubernetes kubeconfig file, encoded as a string. "
- "Kubeconfig exec authentication is not supported in Prowler Cloud for security reasons.",
+ "Kubeconfig command-based authentication is not supported in Prowler Cloud for security reasons.",
}
},
"required": ["kubeconfig_content"],
diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py
index 750174e7a8..9292a0c633 100644
--- a/api/src/backend/api/v1/serializers.py
+++ b/api/src/backend/api/v1/serializers.py
@@ -47,6 +47,7 @@ from api.v1.serializer_utils.integrations import (
JiraCredentialSerializer,
S3ConfigSerializer,
SecurityHubConfigSerializer,
+ replace_integration_providers,
)
from api.v1.serializer_utils.lighthouse import (
BedrockCredentialsSerializer,
@@ -1568,14 +1569,14 @@ class FindingMetadataSerializer(BaseSerializerV1):
# Provider secrets
-KUBERNETES_KUBECONFIG_EXEC_ERROR = (
- "Kubernetes kubeconfig exec authentication is not supported in Prowler Cloud "
- "for security reasons."
+KUBERNETES_KUBECONFIG_UNSUPPORTED_COMMAND_AUTH_ERROR = (
+ "Kubernetes kubeconfig command-based authentication is not supported in "
+ "Prowler Cloud for security reasons."
)
KUBERNETES_KUBECONFIG_INVALID_ERROR = "Invalid Kubernetes kubeconfig content."
-def kubeconfig_contains_exec_auth(kubeconfig: dict) -> bool:
+def kubeconfig_contains_unsupported_command_auth(kubeconfig: dict) -> bool:
users = kubeconfig.get("users", [])
if not isinstance(users, list):
raise ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR)
@@ -1591,6 +1592,17 @@ def kubeconfig_contains_exec_auth(kubeconfig: dict) -> bool:
if "exec" in user:
return True
+ auth_provider = user.get("auth-provider", {})
+ if not isinstance(auth_provider, dict):
+ continue
+
+ auth_provider_config = auth_provider.get("config", {})
+ if not isinstance(auth_provider_config, dict):
+ continue
+
+ if "cmd-path" in auth_provider_config:
+ return True
+
return False
@@ -1787,8 +1799,10 @@ class KubernetesProviderSecret(serializers.Serializer):
if not isinstance(kubeconfig, dict):
raise serializers.ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR)
- if kubeconfig_contains_exec_auth(kubeconfig):
- raise serializers.ValidationError(KUBERNETES_KUBECONFIG_EXEC_ERROR)
+ if kubeconfig_contains_unsupported_command_auth(kubeconfig):
+ raise serializers.ValidationError(
+ KUBERNETES_KUBECONFIG_UNSUPPORTED_COMMAND_AUTH_ERROR
+ )
return kubeconfig_content
@@ -2743,6 +2757,37 @@ class ScheduleDailyCreateSerializer(BaseSerializerV1):
# Integrations
+class IntegrationProviderVisibilityMixin:
+ """
+ Keep the `providers` relationship within the provider visibility of the role.
+
+ The view injects `allowed_providers` in the serializer context: `None` when the role
+ has unlimited visibility, and the queryset of visible providers otherwise. Roles with
+ limited visibility can neither attach providers they cannot see nor discover, through
+ the serialized output, the ones already attached.
+ """
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ allowed_providers = self.context.get("allowed_providers")
+ if allowed_providers is not None:
+ self.fields["providers"].child_relation.queryset = allowed_providers
+
+ def hide_restricted_providers(self, representation: dict) -> dict:
+ allowed_providers = self.context.get("allowed_providers")
+ # `providers` is missing when the request asks for a subset of the fields
+ if allowed_providers is None or "providers" not in representation:
+ return representation
+
+ allowed_provider_ids = {str(provider.id) for provider in allowed_providers}
+ representation["providers"] = [
+ provider
+ for provider in representation["providers"]
+ if provider["id"] in allowed_provider_ids
+ ]
+ return representation
+
+
class BaseWriteIntegrationSerializer(BaseWriteSerializer):
def validate(self, attrs):
integration_type = attrs.get("integration_type")
@@ -2875,7 +2920,7 @@ class BaseWriteIntegrationSerializer(BaseWriteSerializer):
)
-class IntegrationSerializer(RLSSerializer):
+class IntegrationSerializer(IntegrationProviderVisibilityMixin, RLSSerializer):
"""
Serializer for the Integration model.
"""
@@ -2904,15 +2949,9 @@ class IntegrationSerializer(RLSSerializer):
}
def to_representation(self, instance):
- representation = super().to_representation(instance)
- allowed_providers = self.context.get("allowed_providers")
- if allowed_providers:
- allowed_provider_ids = {str(provider.id) for provider in allowed_providers}
- representation["providers"] = [
- provider
- for provider in representation["providers"]
- if provider["id"] in allowed_provider_ids
- ]
+ representation = self.hide_restricted_providers(
+ super().to_representation(instance)
+ )
if instance.integration_type == Integration.IntegrationChoices.JIRA:
representation["configuration"].update(
{"domain": instance.credentials.get("domain")}
@@ -2920,7 +2959,9 @@ class IntegrationSerializer(RLSSerializer):
return representation
-class IntegrationCreateSerializer(BaseWriteIntegrationSerializer):
+class IntegrationCreateSerializer(
+ IntegrationProviderVisibilityMixin, BaseWriteIntegrationSerializer
+):
credentials = IntegrationCredentialField(write_only=True)
configuration = IntegrationConfigField()
providers = serializers.ResourceRelatedField(
@@ -2971,22 +3012,18 @@ class IntegrationCreateSerializer(BaseWriteIntegrationSerializer):
tenant_id = self.context.get("tenant_id")
providers = validated_data.pop("providers", [])
- integration = Integration.objects.create(tenant_id=tenant_id, **validated_data)
-
- through_model_instances = [
- IntegrationProviderRelationship(
- integration=integration,
- provider=provider,
- tenant_id=tenant_id,
+ with transaction.atomic():
+ integration = Integration.objects.create(
+ tenant_id=tenant_id, **validated_data
)
- for provider in providers
- ]
- IntegrationProviderRelationship.objects.bulk_create(through_model_instances)
+ replace_integration_providers(integration, providers, tenant_id)
return integration
-class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer):
+class IntegrationUpdateSerializer(
+ IntegrationProviderVisibilityMixin, BaseWriteIntegrationSerializer
+):
credentials = IntegrationCredentialField(write_only=True, required=False)
configuration = IntegrationConfigField(required=False)
providers = serializers.ResourceRelatedField(
@@ -3031,15 +3068,13 @@ class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer):
def update(self, instance, validated_data):
tenant_id = self.context.get("tenant_id")
- if validated_data.get("providers") is not None:
- instance.providers.clear()
- new_relationships = [
- IntegrationProviderRelationship(
- integration=instance, provider=provider, tenant_id=tenant_id
- )
- for provider in validated_data["providers"]
- ]
- IntegrationProviderRelationship.objects.bulk_create(new_relationships)
+ # Relationships are replaced here, so they are kept out of the default
+ # `ModelSerializer.update()`, which would otherwise reset them all. The view
+ # rejects updates on integrations shared with providers hidden to the role, so
+ # every existing relationship is visible to the requester at this point
+ providers = validated_data.pop("providers", None)
+ if providers is not None:
+ replace_integration_providers(instance, providers, tenant_id)
# Preserve regions field for Security Hub integrations
if instance.integration_type == Integration.IntegrationChoices.AWS_SECURITY_HUB:
@@ -3051,7 +3086,9 @@ class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer):
return super().update(instance, validated_data)
def to_representation(self, instance):
- representation = super().to_representation(instance)
+ representation = self.hide_restricted_providers(
+ super().to_representation(instance)
+ )
# Ensure JIRA integrations show updated domain in configuration from credentials
if instance.integration_type == Integration.IntegrationChoices.JIRA:
representation["configuration"].update(
diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py
index e392505818..db7b76f01a 100644
--- a/api/src/backend/api/v1/views.py
+++ b/api/src/backend/api/v1/views.py
@@ -124,7 +124,12 @@ from api.models import (
UserRoleRelationship,
)
from api.pagination import ComplianceOverviewPagination
-from api.rbac.permissions import Permissions, get_providers, get_role
+from api.rbac.permissions import (
+ Permissions,
+ get_integrations,
+ get_providers,
+ get_role,
+)
from api.renderers import APIJSONRenderer, PlainTextRenderer
from api.rls import Tenant
from api.utils import (
@@ -281,6 +286,7 @@ from django.shortcuts import redirect
from django.urls import reverse
from django.utils.dateparse import parse_date
from django.utils.decorators import method_decorator
+from django.utils.functional import cached_property
from django.views.decorators.cache import cache_control
from django_celery_beat.models import PeriodicTask
from drf_spectacular.settings import spectacular_settings
@@ -6652,27 +6658,34 @@ class ScheduleViewSet(BaseRLSViewSet):
list=extend_schema(
tags=["Integration"],
summary="List all integrations",
- description="Retrieve a list of all configured integrations with options for filtering by various criteria.",
+ description="Retrieve a list of all configured integrations with options for filtering by various criteria.\n\n"
+ "Integrations attached to one or more providers are only returned when the role can access at least one of "
+ "those providers, and each integration lists only the providers visible to the role. Integrations not "
+ "attached to any provider, such as Jira, are tenant-wide and are returned for every role.",
),
retrieve=extend_schema(
tags=["Integration"],
summary="Retrieve integration details",
- description="Fetch detailed information about a specific integration by its ID.",
+ description="Fetch detailed information about a specific integration by its ID. Integrations outside the "
+ "provider visibility of the role are reported the same way as one that does not exist.",
),
create=extend_schema(
tags=["Integration"],
summary="Create a new integration",
- description="Register a new integration with the system, providing necessary configuration details.",
+ description="Register a new integration with the system, providing necessary configuration details. Only "
+ "providers visible to the role can be attached to the integration.",
),
partial_update=extend_schema(
tags=["Integration"],
summary="Partially update an integration",
- description="Modify certain fields of an existing integration without affecting other settings.",
+ description="Modify certain fields of an existing integration without affecting other settings. Integrations "
+ "attached to providers outside the visibility of the role cannot be modified by it.",
),
destroy=extend_schema(
tags=["Integration"],
summary="Delete an integration",
- description="Remove an integration from the system by its ID.",
+ description="Remove an integration from the system by its ID. Integrations attached to providers outside "
+ "the visibility of the role cannot be deleted by it.",
),
)
@method_decorator(CACHE_DECORATOR, name="list")
@@ -6685,18 +6698,27 @@ class IntegrationViewSet(BaseRLSViewSet):
ordering = ["integration_type", "-inserted_at"]
# RBAC required permissions
required_permissions = [Permissions.MANAGE_INTEGRATIONS]
- allowed_providers = None
+
+ @cached_property
+ def allowed_providers(self):
+ """
+ Providers the role can access, or None when it has unlimited visibility.
+
+ Resolved per request and independently of the action, so that writes are scoped
+ as tightly as reads.
+ """
+ if self.user_role.unlimited_visibility:
+ return None
+ return get_providers(self.user_role)
def get_queryset(self):
- user_roles = get_role(self.request.user, self.request.tenant_id)
- if user_roles.unlimited_visibility:
- # User has unlimited visibility, return all integrations
- queryset = Integration.objects.filter(tenant_id=self.request.tenant_id)
- else:
- # User lacks permission, filter providers based on provider groups associated with the role
- allowed_providers = get_providers(user_roles)
- queryset = Integration.objects.filter(providers__in=allowed_providers)
- self.allowed_providers = allowed_providers
+ queryset = get_integrations(self.user_role, providers=self.allowed_providers)
+ if self.allowed_providers is not None and self.action in ("list", "retrieve"):
+ # Restrict the relationship itself, so that the providers hidden to the role
+ # are left out of the sideloaded resources of `?include=providers` too
+ queryset = queryset.prefetch_related(
+ Prefetch("providers", queryset=self.allowed_providers)
+ )
return queryset
def get_serializer_class(self):
@@ -6711,16 +6733,33 @@ class IntegrationViewSet(BaseRLSViewSet):
context["allowed_providers"] = self.allowed_providers
return context
+ def get_object(self):
+ instance = super().get_object()
+ # Writes on an integration shared with providers hidden to the role would reach
+ # beyond its visibility, so both editing and deleting are rejected consistently
+ if (
+ self.action in ("partial_update", "destroy")
+ and self.allowed_providers is not None
+ and instance.providers.exclude(
+ id__in=self.allowed_providers.values("id")
+ ).exists()
+ ):
+ raise PermissionDenied(
+ "The integration is attached to providers outside the visibility of your role."
+ )
+ return instance
+
@extend_schema(
tags=["Integration"],
summary="Check integration connection",
- description="Try to verify integration connection",
+ description="Try to verify integration connection. Integrations outside the provider visibility of the role "
+ "are reported the same way as one that does not exist.",
request=None,
responses={202: OpenApiResponse(response=TaskSerializer)},
)
@action(detail=True, methods=["post"], url_name="connection")
def connection(self, request, pk=None):
- get_object_or_404(Integration, pk=pk)
+ get_object_or_404(self.get_queryset(), pk=pk)
with transaction.atomic():
task = check_integration_connection_task.delay(
integration_id=pk, tenant_id=self.request.tenant_id
@@ -6743,7 +6782,8 @@ class IntegrationViewSet(BaseRLSViewSet):
tags=["Integration"],
summary="Send findings to a Jira integration",
description="Send a set of filtered findings to the given integration. At least one finding filter must be "
- "provided.\n\n"
+ "provided. Jira integrations are tenant-wide and do not require unlimited visibility, while the findings "
+ "sent are limited to the providers the role can access.\n\n"
"## Known Limitations\n\n"
"### Issue Types with Required Custom Fields\n\n"
"Certain Jira issue types (such as Epic) may require mandatory custom fields that Prowler does not "
@@ -6787,24 +6827,37 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
return []
return super().get_filter_backends()
- def get_queryset(self):
- tenant_id = self.request.tenant_id
- user_roles = get_role(self.request.user, self.request.tenant_id)
- if user_roles.unlimited_visibility:
- # User has unlimited visibility, return all findings
- queryset = Finding.all_objects.filter(tenant_id=tenant_id)
- else:
- # User lacks permission, filter findings based on provider groups associated with the role
- queryset = Finding.all_objects.filter(
- scan__provider__in=get_providers(user_roles)
- )
+ @cached_property
+ def allowed_providers(self):
+ """
+ Providers the role can access, or None when it has unlimited visibility.
- return queryset
+ Resolved once per request and shared between the findings queryset and the
+ integration lookup.
+ """
+ if self.user_role.unlimited_visibility:
+ return None
+ return get_providers(self.user_role)
+
+ def get_queryset(self):
+ if self.allowed_providers is None:
+ # User has unlimited visibility, return all findings
+ return Finding.all_objects.filter(tenant_id=self.request.tenant_id)
+ # Findings are limited to the providers the role can access
+ return Finding.all_objects.filter(scan__provider__in=self.allowed_providers)
+
+ def get_integration(self, integration_pk):
+ """Retrieve the integration, honoring the provider visibility of the user's role."""
+ return get_object_or_404(
+ get_integrations(self.user_role, providers=self.allowed_providers),
+ pk=integration_pk,
+ )
@extend_schema(
tags=["Integration"],
summary="Get available issue types for a Jira project",
- description="Fetch the available issue types from Jira for a given project key and update the integration configuration.",
+ description="Fetch the available issue types from Jira for a given project key and update the integration "
+ "configuration. Jira integrations are tenant-wide and do not require unlimited visibility.",
parameters=[
OpenApiParameter(
name="project_key",
@@ -6817,7 +6870,7 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
)
@action(detail=False, methods=["get"], url_name="issue-types")
def issue_types(self, request, integration_pk=None):
- integration = get_object_or_404(Integration, pk=integration_pk)
+ integration = self.get_integration(integration_pk)
project_key = request.query_params.get("project_key")
if not project_key:
@@ -6862,23 +6915,23 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
@action(detail=False, methods=["post"], url_name="dispatches")
def dispatches(self, request, integration_pk=None):
- get_object_or_404(Integration, pk=integration_pk)
+ self.get_integration(integration_pk)
serializer = self.get_serializer(
data=request.data, context={"integration_id": integration_pk}
)
serializer.is_valid(raise_exception=True)
- if self.filter_queryset(self.get_queryset()).count() == 0:
- raise ValidationError(
- {"findings": "No findings match the provided filters"}
- )
-
finding_ids = [
str(finding_id)
for finding_id in self.filter_queryset(self.get_queryset()).values_list(
"id", flat=True
)
]
+ if not finding_ids:
+ raise ValidationError(
+ {"findings": "No findings match the provided filters"}
+ )
+
project_key = serializer.validated_data["project_key"]
issue_type = serializer.validated_data["issue_type"]
diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py
index 36dc806ff3..0a8db605c0 100644
--- a/api/src/backend/tasks/jobs/scan.py
+++ b/api/src/backend/tasks/jobs/scan.py
@@ -1,3 +1,4 @@
+import copy
import csv
import io
import json
@@ -695,6 +696,45 @@ def _process_finding_micro_batch(
scan_resource_groups_cache: Dict tracking resource group counts {(resource_group, severity): {"total", "failed", "new_failed"}}.
group_resources_cache: Dict tracking unique resources per group {resource_group: set(resource_uids)}.
"""
+
+ def build_resource_defaults_from_finding(finding: ProwlerFinding) -> dict[str, Any]:
+ check_metadata = finding.get_metadata()
+ group = check_metadata.get("resourcegroup") or None
+ return {
+ "tenant_id": tenant_id,
+ "provider": provider_instance,
+ "uid": finding.resource_uid,
+ "region": finding.region,
+ "service": finding.service_name,
+ "type": finding.resource_type,
+ "name": finding.resource_name,
+ "groups": [group] if group else None,
+ }
+
+ def recover_resource_after_cache_miss(finding: ProwlerFinding) -> Resource:
+ resource_uid = finding.resource_uid
+ resource_instance = Resource.objects.filter(
+ tenant_id=tenant_id,
+ provider_id=provider_instance.id,
+ uid=resource_uid,
+ ).first()
+ if resource_instance is None:
+ try:
+ with transaction.atomic():
+ resource_instance = Resource.objects.create(
+ **build_resource_defaults_from_finding(finding)
+ )
+ except IntegrityError:
+ resource_instance = Resource.objects.filter(
+ tenant_id=tenant_id,
+ provider_id=provider_instance.id,
+ uid=resource_uid,
+ ).first()
+ if resource_instance is None:
+ raise
+
+ return cache_resource(resource_uid, resource_instance)
+
# Accumulate objects for bulk operations
findings_to_create = []
dirty_resources = {}
@@ -733,7 +773,103 @@ def _process_finding_micro_batch(
# All DB writes for this micro-batch run inside ONE rls_transaction,
# with deadlock-retry at micro-batch granularity instead of per-finding.
+ missing_cache_value = object()
for attempt in range(CELERY_DEADLOCK_ATTEMPTS):
+ resource_cache_originals: dict[str, Resource | object] = {}
+ failed_count_originals: dict[str, int | None] = {}
+ resource_field_originals: dict[str, dict[str, Any]] = {}
+ tag_cache_original = dict(tag_cache)
+ scan_resource_cache_original = set(scan_resource_cache)
+ scan_categories_cache_original = {
+ key: value.copy() for key, value in scan_categories_cache.items()
+ }
+ scan_resource_groups_cache_original = {
+ key: value.copy() for key, value in scan_resource_groups_cache.items()
+ }
+ group_resources_cache_original = {
+ key: set(value) for key, value in group_resources_cache.items()
+ }
+
+ def cache_resource(resource_uid: str, resource_instance: Resource) -> Resource:
+ if resource_uid not in resource_cache_originals:
+ resource_cache_originals[resource_uid] = resource_cache.get(
+ resource_uid, missing_cache_value
+ )
+ resource_cache[resource_uid] = resource_instance
+ if resource_uid not in resource_failed_findings_cache:
+ failed_count_originals[resource_uid] = None
+ resource_failed_findings_cache[resource_uid] = 0
+ return resource_instance
+
+ def snapshot_failed_count(resource_uid: str) -> None:
+ if resource_uid not in failed_count_originals:
+ failed_count_originals[resource_uid] = (
+ resource_failed_findings_cache.get(resource_uid)
+ )
+
+ def snapshot_resource_fields(
+ resource_uid: str, resource_instance: Resource
+ ) -> None:
+ if resource_uid in resource_field_originals:
+ return
+ resource_field_originals[resource_uid] = {
+ field: copy.deepcopy(getattr(resource_instance, field))
+ for field in (
+ "name",
+ "metadata",
+ "details",
+ "partition",
+ "region",
+ "service",
+ "type",
+ "groups",
+ "updated_at",
+ )
+ }
+
+ def restore_attempt_caches() -> None:
+ for resource_uid, original_fields in resource_field_originals.items():
+ resource_instance = resource_cache.get(resource_uid)
+ if resource_instance is None:
+ continue
+ for field, value in original_fields.items():
+ setattr(resource_instance, field, value)
+ for resource_uid, original_resource in resource_cache_originals.items():
+ if original_resource is missing_cache_value:
+ resource_cache.pop(resource_uid, None)
+ else:
+ resource_cache[resource_uid] = original_resource
+ for resource_uid, original_count in failed_count_originals.items():
+ if original_count is None:
+ resource_failed_findings_cache.pop(resource_uid, None)
+ else:
+ resource_failed_findings_cache[resource_uid] = original_count
+ tag_cache.clear()
+ tag_cache.update(tag_cache_original)
+ scan_resource_cache.clear()
+ scan_resource_cache.update(scan_resource_cache_original)
+ scan_categories_cache.clear()
+ scan_categories_cache.update(
+ {
+ key: value.copy()
+ for key, value in scan_categories_cache_original.items()
+ }
+ )
+ scan_resource_groups_cache.clear()
+ scan_resource_groups_cache.update(
+ {
+ key: value.copy()
+ for key, value in scan_resource_groups_cache_original.items()
+ }
+ )
+ group_resources_cache.clear()
+ group_resources_cache.update(
+ {
+ key: set(value)
+ for key, value in group_resources_cache_original.items()
+ }
+ )
+
try:
with rls_transaction(tenant_id):
# 1) Pre-resolve Resources in bulk
@@ -768,19 +904,8 @@ def _process_finding_micro_batch(
resources_to_create = []
for uid in missing_uids:
f = first_finding_per_uid[uid]
- check_metadata = f.get_metadata()
- group = check_metadata.get("resourcegroup") or None
resources_to_create.append(
- Resource(
- tenant_id=tenant_id,
- provider=provider_instance,
- uid=uid,
- region=f.region,
- service=f.service_name,
- type=f.resource_type,
- name=f.resource_name,
- groups=[group] if group else None,
- )
+ Resource(**build_resource_defaults_from_finding(f))
)
Resource.objects.bulk_create(
resources_to_create,
@@ -801,8 +926,7 @@ def _process_finding_micro_batch(
}
)
for uid, r in existing_resources.items():
- resource_cache[uid] = r
- resource_failed_findings_cache.setdefault(uid, 0)
+ cache_resource(uid, r)
# 2) Pre-resolve ResourceTags in bulk
batch_tag_kv: set[tuple[str, str]] = set()
@@ -848,47 +972,50 @@ def _process_finding_micro_batch(
resource_uid = finding.resource_uid
resource_instance = resource_cache.get(resource_uid)
if resource_instance is None:
- # Should be unreachable after the pre-resolve step. Defensive log.
- logger.error(
- f"Resource {resource_uid} missing from cache after pre-resolve "
- f"on scan {scan_instance.id}; skipping finding."
- )
- continue
+ resource_instance = recover_resource_after_cache_miss(finding)
# Detect resource field changes (defer save until end-of-batch bulk_update).
check_metadata = finding.get_metadata()
group = check_metadata.get("resourcegroup") or None
updated = False
if finding.region and resource_instance.region != finding.region:
+ snapshot_resource_fields(resource_uid, resource_instance)
resource_instance.region = finding.region
updated = True
if (
finding.resource_name
and resource_instance.name != finding.resource_name
):
+ snapshot_resource_fields(resource_uid, resource_instance)
resource_instance.name = finding.resource_name
updated = True
if resource_instance.service != finding.service_name:
+ snapshot_resource_fields(resource_uid, resource_instance)
resource_instance.service = finding.service_name
updated = True
if resource_instance.type != finding.resource_type:
+ snapshot_resource_fields(resource_uid, resource_instance)
resource_instance.type = finding.resource_type
updated = True
if resource_instance.metadata != finding.resource_metadata:
+ snapshot_resource_fields(resource_uid, resource_instance)
resource_instance.metadata = json.dumps(
finding.resource_metadata, cls=CustomEncoder
)
updated = True
if resource_instance.details != finding.resource_details:
+ snapshot_resource_fields(resource_uid, resource_instance)
resource_instance.details = finding.resource_details
updated = True
if resource_instance.partition != finding.partition:
+ snapshot_resource_fields(resource_uid, resource_instance)
resource_instance.partition = finding.partition
updated = True
if group and (
not resource_instance.groups
or group not in resource_instance.groups
):
+ snapshot_resource_fields(resource_uid, resource_instance)
resource_instance.groups = (resource_instance.groups or []) + [
group
]
@@ -950,6 +1077,7 @@ def _process_finding_micro_batch(
muted_reason = mute_rules_cache[finding_uid]
if status == FindingStatus.FAIL and not is_muted:
+ snapshot_failed_count(resource_uid)
resource_failed_findings_cache[resource_uid] += 1
check_metadata["compliance"] = finding.compliance
@@ -1107,6 +1235,7 @@ def _process_finding_micro_batch(
if r is None:
continue
# Manually bump updated_at since bulk_update bypasses auto_now.
+ snapshot_resource_fields(uid, r)
r.updated_at = now_utc
resources_to_bulk_update.append(r)
if resources_to_bulk_update:
@@ -1128,6 +1257,7 @@ def _process_finding_micro_batch(
# Successful execution: leave deadlock retry loop.
break
except (OperationalError, IntegrityError) as db_err:
+ restore_attempt_caches()
if attempt < CELERY_DEADLOCK_ATTEMPTS - 1:
logger.warning(
f"{'Deadlock error' if isinstance(db_err, OperationalError) else 'Integrity error'} "
diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py
index a91ff85c01..e9101ff1bb 100644
--- a/api/src/backend/tasks/tasks.py
+++ b/api/src/backend/tasks/tasks.py
@@ -797,12 +797,34 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
if name not in frameworks_bulk and universal_bulk[name].outputs
}
frameworks_avail = get_compliance_frameworks(provider_type)
+ # Idempotency: a previous run of this task for the same scan may have left
+ # output files behind (e.g. broker redelivery after a worker was killed
+ # mid-run with task_acks_late, or a successful run on a deployment without
+ # S3 where the tmp dir is not removed). Output writers open files in append
+ # mode with a deterministic path (derived from scan.started_at), so reusing
+ # them would append every finding row again and duplicate the CSV/output
+ # rows. Start from a clean slate before (re)generating.
+ scan_tmp_dir = _scan_tmp_output_directory(tenant_id, scan_id)
+ if os.path.exists(scan_tmp_dir):
+ rmtree(scan_tmp_dir, ignore_errors=True)
+ # The writers below open output files in append mode with deterministic
+ # paths (derived from scan.started_at). Any stale file that survives the
+ # cleanup would get every finding row appended again, which is the exact
+ # duplication this guards against. Continuing is therefore unsafe: abort
+ # so `ScanReportRLSTask.on_failure` removes the tmp dir and the retry
+ # starts from a clean slate instead of publishing duplicated rows.
+ if os.path.exists(scan_tmp_dir):
+ raise RuntimeError(
+ "Could not remove stale output directory for scan "
+ f"{scan_id} before generating outputs; aborting to avoid "
+ "duplicated rows in appended outputs."
+ )
+
out_dir, comp_dir = _generate_output_directory(
DJANGO_TMP_OUTPUT_DIRECTORY, provider_uid, tenant_id, scan_id
)
# Removed on success here and on failure by ScanReportRLSTask.on_failure,
# so partial artifacts do not accumulate and fill the disk (ENOSPC).
- scan_tmp_dir = _scan_tmp_output_directory(tenant_id, scan_id)
def get_writer(writer_map, name, factory, is_last):
"""
diff --git a/api/src/backend/tasks/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py
index cad5e3d343..8027588398 100644
--- a/api/src/backend/tasks/tests/test_scan.py
+++ b/api/src/backend/tasks/tests/test_scan.py
@@ -2,6 +2,7 @@ import csv
import json
import re
import uuid
+from collections.abc import MutableMapping
from contextlib import contextmanager
from datetime import UTC, datetime
from io import StringIO
@@ -15,13 +16,16 @@ from api.models import (
MuteRule,
Provider,
Resource,
+ ResourceFindingMapping,
ResourceScanSummary,
+ ResourceTag,
+ ResourceTagMapping,
Scan,
ScanSummary,
StateChoices,
StatusChoices,
)
-from django.db import IntegrityError, OperationalError
+from django.db import IntegrityError, OperationalError, transaction
from prowler.lib.check.models import Severity
from prowler.lib.outputs.finding import Status
from tasks.jobs.scan import (
@@ -53,6 +57,12 @@ def noop_rls_transaction(*args, **kwargs):
yield
+@contextmanager
+def atomic_rls_transaction(*args, **kwargs):
+ with transaction.atomic():
+ yield
+
+
class FakeFinding:
def __init__(self, **attrs):
self.metadata = attrs.pop("metadata", {})
@@ -71,6 +81,32 @@ class FakeFinding:
return self.metadata
+class CacheMissAfterPreResolve(MutableMapping):
+ def __init__(self, missing_uid):
+ self._cache = {}
+ self.missing_uid = missing_uid
+
+ def __contains__(self, key):
+ if key == self.missing_uid:
+ return True
+ return key in self._cache
+
+ def __getitem__(self, key):
+ return self._cache[key]
+
+ def __setitem__(self, key, value):
+ self._cache[key] = value
+
+ def __delitem__(self, key):
+ del self._cache[key]
+
+ def __iter__(self):
+ return iter(self._cache)
+
+ def __len__(self):
+ return len(self._cache)
+
+
@pytest.mark.django_db
class TestPerformScan:
def test_perform_prowler_scan_success(
@@ -1055,8 +1091,12 @@ class TestPerformScan:
perform_prowler_scan(tenant_id, scan_id, provider_id, [])
# Verify findings are muted with correct reason
- fail_finding_db = Finding.objects.get(uid=finding_uid_1)
- pass_finding_db = Finding.objects.get(uid=finding_uid_2)
+ fail_finding_db = Finding.objects.get(
+ tenant_id=tenant.id, scan_id=scan.id, uid=finding_uid_1
+ )
+ pass_finding_db = Finding.objects.get(
+ tenant_id=tenant.id, scan_id=scan.id, uid=finding_uid_2
+ )
assert fail_finding_db.muted
assert fail_finding_db.muted_reason == mute_rule_reason
@@ -1067,7 +1107,9 @@ class TestPerformScan:
assert pass_finding_db.muted_at is not None
# Verify failed_findings_count is 0 for muted FAIL finding
- resource_1 = Resource.objects.get(uid="resource_uid_1")
+ resource_1 = Resource.objects.get(
+ tenant_id=tenant.id, provider_id=provider.id, uid="resource_uid_1"
+ )
assert resource_1.failed_findings_count == 0
def test_perform_prowler_scan_with_inactive_mute_rules(
@@ -1147,13 +1189,17 @@ class TestPerformScan:
perform_prowler_scan(tenant_id, scan_id, provider_id, [])
# Verify finding is NOT muted
- finding_db = Finding.objects.get(uid=finding_uid)
+ finding_db = Finding.objects.get(
+ tenant_id=tenant.id, scan_id=scan.id, uid=finding_uid
+ )
assert not finding_db.muted
assert finding_db.muted_reason is None
assert finding_db.muted_at is None
# Verify failed_findings_count increments for FAIL finding
- resource = Resource.objects.get(uid="resource_uid_inactive")
+ resource = Resource.objects.get(
+ tenant_id=tenant.id, provider_id=provider.id, uid="resource_uid_inactive"
+ )
assert resource.failed_findings_count == 1
def test_perform_prowler_scan_mutelist_overrides_mute_rules(
@@ -1233,13 +1279,17 @@ class TestPerformScan:
perform_prowler_scan(tenant_id, scan_id, provider_id, [])
# Verify mutelist reason takes precedence
- finding_db = Finding.objects.get(uid=finding_uid)
+ finding_db = Finding.objects.get(
+ tenant_id=tenant.id, scan_id=scan.id, uid=finding_uid
+ )
assert finding_db.muted
assert finding_db.muted_reason == "Muted by mutelist"
assert finding_db.muted_at is not None
# Verify failed_findings_count is 0
- resource = Resource.objects.get(uid="resource_both")
+ resource = Resource.objects.get(
+ tenant_id=tenant.id, provider_id=provider.id, uid="resource_both"
+ )
assert resource.failed_findings_count == 0
def test_perform_prowler_scan_mute_rules_multiple_findings(
@@ -1331,14 +1381,20 @@ class TestPerformScan:
# Verify all findings are muted with same reason
for uid in finding_uids:
- finding_db = Finding.objects.get(uid=uid)
+ finding_db = Finding.objects.get(
+ tenant_id=tenant.id, scan_id=scan.id, uid=uid
+ )
assert finding_db.muted
assert finding_db.muted_reason == mute_rule_reason
assert finding_db.muted_at is not None
# Verify all resources have failed_findings_count = 0
for i in range(len(finding_uids)):
- resource = Resource.objects.get(uid=f"resource_bulk_{i}")
+ resource = Resource.objects.get(
+ tenant_id=tenant.id,
+ provider_id=provider.id,
+ uid=f"resource_bulk_{i}",
+ )
assert resource.failed_findings_count == 0
def test_perform_prowler_scan_mute_rules_error_handling(
@@ -1416,12 +1472,18 @@ class TestPerformScan:
assert scan.state == StateChoices.COMPLETED
# Verify finding is not muted (mute_rules_cache was empty dict)
- finding_db = Finding.objects.get(uid="finding_error_handling")
+ finding_db = Finding.objects.get(
+ tenant_id=tenant.id,
+ scan_id=scan.id,
+ uid="finding_error_handling",
+ )
assert not finding_db.muted
assert finding_db.muted_reason is None
# Verify failed_findings_count increments
- resource = Resource.objects.get(uid="resource_error")
+ resource = Resource.objects.get(
+ tenant_id=tenant.id, provider_id=provider.id, uid="resource_error"
+ )
assert resource.failed_findings_count == 1
def test_perform_prowler_scan_muted_at_timestamp(
@@ -1503,7 +1565,9 @@ class TestPerformScan:
after_scan = datetime.now(UTC)
# Verify muted_at is within the scan time window
- finding_db = Finding.objects.get(uid=finding_uid)
+ finding_db = Finding.objects.get(
+ tenant_id=tenant.id, scan_id=scan.id, uid=finding_uid
+ )
assert finding_db.muted
assert finding_db.muted_at is not None
assert before_scan <= finding_db.muted_at <= after_scan
@@ -1514,6 +1578,548 @@ class TestPerformScan:
@pytest.mark.django_db
class TestProcessFindingMicroBatch:
+ def _process_one_finding_micro_batch(
+ self,
+ tenant,
+ scan,
+ provider,
+ finding,
+ resource_cache=None,
+ resource_failed_findings_cache=None,
+ ):
+ resource_cache = resource_cache if resource_cache is not None else {}
+ resource_failed_findings_cache = (
+ resource_failed_findings_cache
+ if resource_failed_findings_cache is not None
+ else {}
+ )
+ caches = {
+ "resource_cache": resource_cache,
+ "tag_cache": {},
+ "last_status_cache": {},
+ "resource_failed_findings_cache": resource_failed_findings_cache,
+ "unique_resources": set(),
+ "scan_resource_cache": set(),
+ "mute_rules_cache": {},
+ "scan_categories_cache": {},
+ "scan_resource_groups_cache": {},
+ "group_resources_cache": {},
+ }
+
+ with (
+ patch("tasks.jobs.scan.rls_transaction", new=noop_rls_transaction),
+ patch("api.db_utils.rls_transaction", new=noop_rls_transaction),
+ ):
+ _process_finding_micro_batch(
+ str(tenant.id),
+ [finding],
+ scan,
+ provider,
+ caches["resource_cache"],
+ caches["tag_cache"],
+ caches["last_status_cache"],
+ caches["resource_failed_findings_cache"],
+ caches["unique_resources"],
+ caches["scan_resource_cache"],
+ caches["mute_rules_cache"],
+ caches["scan_categories_cache"],
+ caches["scan_resource_groups_cache"],
+ caches["group_resources_cache"],
+ )
+
+ return caches
+
+ def test_process_finding_micro_batch_fallback_creates_resource_after_cache_miss(
+ self, tenants_fixture, scans_fixture
+ ):
+ tenant = tenants_fixture[0]
+ scan = scans_fixture[0]
+ provider = scan.provider
+ resource_uid = "arn:aws:accessanalyzer:us-east-1:123456789012:analyzer/unknown"
+
+ finding = FakeFinding(
+ uid="finding-cache-miss-create",
+ status=StatusChoices.FAIL,
+ status_extended="missing analyzer",
+ severity=Severity.medium,
+ check_id="accessanalyzer_enabled",
+ resource_uid=resource_uid,
+ resource_name="analyzer/unknown",
+ region="us-east-1",
+ service_name="accessanalyzer",
+ resource_type="analyzer",
+ resource_tags={},
+ resource_metadata={},
+ resource_details={},
+ partition="aws",
+ raw={},
+ compliance={},
+ metadata={"resourcegroup": "identity"},
+ muted=False,
+ )
+
+ caches = self._process_one_finding_micro_batch(
+ tenant,
+ scan,
+ provider,
+ finding,
+ resource_cache=CacheMissAfterPreResolve(resource_uid),
+ )
+
+ resource = Resource.objects.get(
+ tenant_id=tenant.id, provider_id=provider.id, uid=resource_uid
+ )
+ created_finding = Finding.objects.get(
+ tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
+ )
+
+ assert created_finding.scan_id == scan.id
+ assert resource.provider_id == provider.id
+ assert resource.region == finding.region
+ assert resource.service == finding.service_name
+ assert resource.type == finding.resource_type
+ assert resource.name == finding.resource_name
+ assert resource.groups == ["identity"]
+ assert resource.findings.filter(
+ tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
+ ).exists()
+ assert caches["resource_cache"][resource_uid].id == resource.id
+ assert caches["resource_failed_findings_cache"][resource_uid] == 1
+
+ def test_process_finding_micro_batch_fallback_recovers_existing_resource_after_cache_miss(
+ self, tenants_fixture, scans_fixture
+ ):
+ tenant = tenants_fixture[0]
+ scan = scans_fixture[0]
+ provider = scan.provider
+ resource_uid = "arn:aws:guardduty:us-east-1:123456789012:detector/unknown"
+ existing_resource = Resource.objects.create(
+ tenant_id=tenant.id,
+ provider=provider,
+ uid=resource_uid,
+ name="detector/unknown",
+ region="us-east-1",
+ service="guardduty",
+ type="detector",
+ )
+
+ finding = FakeFinding(
+ uid="finding-cache-miss-existing",
+ status=StatusChoices.FAIL,
+ status_extended="missing detector",
+ severity=Severity.high,
+ check_id="guardduty_enabled",
+ resource_uid=resource_uid,
+ resource_name=existing_resource.name,
+ region=existing_resource.region,
+ service_name=existing_resource.service,
+ resource_type=existing_resource.type,
+ resource_tags={},
+ resource_metadata={},
+ resource_details={},
+ partition="aws",
+ raw={},
+ compliance={},
+ metadata={},
+ muted=False,
+ )
+
+ caches = self._process_one_finding_micro_batch(
+ tenant,
+ scan,
+ provider,
+ finding,
+ resource_cache=CacheMissAfterPreResolve(resource_uid),
+ )
+
+ assert (
+ Resource.objects.filter(
+ tenant_id=tenant.id, provider_id=provider.id, uid=resource_uid
+ ).count()
+ == 1
+ )
+ created_finding = Finding.objects.get(
+ tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
+ )
+ existing_resource.refresh_from_db()
+
+ assert created_finding.scan_id == scan.id
+ assert existing_resource.findings.filter(
+ tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
+ ).exists()
+ assert caches["resource_cache"][resource_uid].id == existing_resource.id
+ assert caches["resource_failed_findings_cache"][resource_uid] == 1
+
+ def test_process_finding_micro_batch_fallback_recovers_after_create_race(
+ self, tenants_fixture, scans_fixture
+ ):
+ tenant = tenants_fixture[0]
+ scan = scans_fixture[0]
+ provider = scan.provider
+ resource_uid = "arn:aws:securityhub:us-east-1:123456789012:hub/unknown"
+ raced_resource = Resource.objects.create(
+ tenant_id=tenant.id,
+ provider=provider,
+ uid=resource_uid,
+ name="hub/unknown",
+ region="us-east-1",
+ service="securityhub",
+ type="hub",
+ )
+
+ finding = FakeFinding(
+ uid="finding-cache-miss-failure",
+ status=StatusChoices.FAIL,
+ status_extended="missing hub",
+ severity=Severity.high,
+ check_id="securityhub_enabled",
+ resource_uid=resource_uid,
+ resource_name="hub/unknown",
+ region="us-east-1",
+ service_name="securityhub",
+ resource_type="hub",
+ resource_tags={},
+ resource_metadata={},
+ resource_details={},
+ partition="aws",
+ raw={},
+ compliance={},
+ metadata={},
+ muted=False,
+ )
+
+ resource_filter_result = MagicMock()
+ resource_filter_result.first.side_effect = [None, raced_resource]
+
+ with (
+ patch.object(
+ Resource.objects,
+ "filter",
+ return_value=resource_filter_result,
+ ),
+ patch.object(
+ Resource.objects,
+ "create",
+ side_effect=IntegrityError("duplicate resource"),
+ ),
+ ):
+ caches = self._process_one_finding_micro_batch(
+ tenant,
+ scan,
+ provider,
+ finding,
+ resource_cache=CacheMissAfterPreResolve(resource_uid),
+ )
+
+ assert (
+ Resource.objects.filter(
+ tenant_id=tenant.id, provider_id=provider.id, uid=resource_uid
+ ).count()
+ == 1
+ )
+ created_finding = Finding.objects.get(
+ tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
+ )
+ raced_resource.refresh_from_db()
+
+ assert created_finding.scan_id == scan.id
+ assert raced_resource.findings.filter(
+ tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
+ ).exists()
+ assert caches["resource_cache"][resource_uid].id == raced_resource.id
+ assert caches["resource_failed_findings_cache"][resource_uid] == 1
+
+ def test_process_finding_micro_batch_cache_miss_retry_drops_rolled_back_resource(
+ self, tenants_fixture, scans_fixture
+ ):
+ tenant = tenants_fixture[0]
+ scan = scans_fixture[0]
+ provider = scan.provider
+ resource_uid = "generic-resource-cache-miss-retry"
+ cached_resource = Resource.objects.create(
+ tenant_id=tenant.id,
+ provider=provider,
+ uid="generic-cached-resource-retry",
+ name="old-cached-resource",
+ region="us-west-2",
+ service="old-service",
+ type="old-type",
+ )
+ finding = FakeFinding(
+ uid="finding-cache-miss-retry-clean-resource-cache",
+ status=StatusChoices.FAIL,
+ status_extended="missing resource",
+ severity=Severity.high,
+ check_id="generic_resource_check",
+ resource_uid=resource_uid,
+ resource_name="generic-resource",
+ region="us-east-1",
+ service_name="generic-service",
+ resource_type="generic-type",
+ resource_tags={"team": "platform"},
+ resource_metadata={"owner": "security"},
+ resource_details={"id": "generic-resource"},
+ partition="aws",
+ raw={},
+ compliance={},
+ metadata={"categories": ["security"], "resourcegroup": "identity"},
+ muted=False,
+ )
+ cached_resource_finding = FakeFinding(
+ uid="finding-cache-miss-retry-restores-dirty-resource",
+ status=StatusChoices.FAIL,
+ status_extended="cached resource changed",
+ severity=Severity.high,
+ check_id="generic_cached_resource_check",
+ resource_uid=cached_resource.uid,
+ resource_name="new-cached-resource",
+ region="eu-west-1",
+ service_name="new-service",
+ resource_type="new-type",
+ resource_tags={},
+ resource_metadata={"owner": "platform"},
+ resource_details={"id": "cached-resource"},
+ partition="aws",
+ raw={},
+ compliance={},
+ metadata={"categories": ["security"], "resourcegroup": "identity"},
+ muted=False,
+ )
+ resource_cache = CacheMissAfterPreResolve(resource_uid)
+ resource_cache[cached_resource.uid] = cached_resource
+ tag_cache = {}
+ resource_failed_findings_cache = {cached_resource.uid: 0}
+ scan_resource_cache: set[tuple[str, str, str, str]] = set()
+ scan_categories_cache: dict[tuple[str, str], dict[str, int]] = {}
+ scan_resource_groups_cache: dict[tuple[str, str], dict[str, int]] = {}
+ group_resources_cache: dict[str, set] = {}
+ original_bulk_create = ResourceFindingMapping.objects.bulk_create
+ original_tag_mapping_bulk_create = ResourceTagMapping.objects.bulk_create
+ mapping_bulk_create_calls = []
+ tag_mapping_bulk_create_calls = []
+
+ def fail_once_then_bulk_create(objects, *args, **kwargs):
+ mapping_bulk_create_calls.append([str(obj.resource_id) for obj in objects])
+ if len(mapping_bulk_create_calls) == 1:
+ raise IntegrityError("rollback after fallback resource creation")
+ return original_bulk_create(objects, *args, **kwargs)
+
+ def track_tag_mappings_bulk_create(objects, *args, **kwargs):
+ tag_mapping_bulk_create_calls.append([str(obj.tag_id) for obj in objects])
+ return original_tag_mapping_bulk_create(objects, *args, **kwargs)
+
+ with (
+ patch("tasks.jobs.scan.CELERY_DEADLOCK_ATTEMPTS", 2),
+ patch("tasks.jobs.scan.rls_transaction", new=atomic_rls_transaction),
+ patch("api.db_utils.rls_transaction", new=atomic_rls_transaction),
+ patch.object(
+ ResourceTagMapping.objects,
+ "bulk_create",
+ side_effect=track_tag_mappings_bulk_create,
+ ),
+ patch.object(
+ ResourceFindingMapping.objects,
+ "bulk_create",
+ side_effect=fail_once_then_bulk_create,
+ ),
+ ):
+ _process_finding_micro_batch(
+ str(tenant.id),
+ [finding, cached_resource_finding],
+ scan,
+ provider,
+ resource_cache,
+ tag_cache,
+ {},
+ resource_failed_findings_cache,
+ set(),
+ scan_resource_cache,
+ {},
+ scan_categories_cache,
+ scan_resource_groups_cache,
+ group_resources_cache,
+ )
+
+ resource = Resource.objects.get(
+ tenant_id=tenant.id,
+ provider_id=provider.id,
+ uid=resource_uid,
+ )
+ created_finding = Finding.objects.get(
+ tenant_id=tenant.id,
+ scan_id=scan.id,
+ uid=finding.uid,
+ )
+ cached_resource.refresh_from_db()
+
+ assert len(mapping_bulk_create_calls) == 2
+ assert mapping_bulk_create_calls[0] != mapping_bulk_create_calls[1]
+ assert len(tag_mapping_bulk_create_calls) == 2
+ assert tag_mapping_bulk_create_calls[0] != tag_mapping_bulk_create_calls[1]
+ assert created_finding.scan_id == scan.id
+ assert resource.findings.filter(
+ tenant_id=tenant.id,
+ scan_id=scan.id,
+ uid=finding.uid,
+ ).exists()
+ assert cached_resource.findings.filter(
+ tenant_id=tenant.id,
+ scan_id=scan.id,
+ uid=cached_resource_finding.uid,
+ ).exists()
+ assert cached_resource.name == cached_resource_finding.resource_name
+ assert cached_resource.region == cached_resource_finding.region
+ assert cached_resource.service == cached_resource_finding.service_name
+ assert cached_resource.type == cached_resource_finding.resource_type
+ assert resource_cache[resource_uid].id == resource.id
+ assert resource_failed_findings_cache[resource_uid] == 1
+ assert resource_failed_findings_cache[cached_resource.uid] == 1
+ assert scan_resource_cache == {
+ (
+ str(resource.id),
+ finding.service_name,
+ finding.region,
+ finding.resource_type,
+ ),
+ (
+ str(cached_resource.id),
+ cached_resource_finding.service_name,
+ cached_resource_finding.region,
+ cached_resource_finding.resource_type,
+ ),
+ }
+ assert (
+ tag_cache[("team", "platform")].id
+ == ResourceTag.objects.get(
+ tenant_id=tenant.id,
+ key="team",
+ value="platform",
+ ).id
+ )
+ assert scan_categories_cache == {
+ ("security", "high"): {"total": 2, "failed": 2, "new_failed": 2}
+ }
+ assert scan_resource_groups_cache == {
+ ("identity", "high"): {"total": 2, "failed": 2, "new_failed": 2}
+ }
+ assert group_resources_cache == {
+ "identity": {resource_uid, cached_resource.uid}
+ }
+
+ def test_process_finding_micro_batch_propagates_retryable_cache_miss_db_errors(
+ self, tenants_fixture, scans_fixture
+ ):
+ tenant = tenants_fixture[0]
+ scan = scans_fixture[0]
+ provider = scan.provider
+ resource_uid = "arn:aws:securityhub:us-east-1:123456789012:hub/retryable"
+
+ finding = FakeFinding(
+ uid="finding-cache-miss-retryable-error",
+ status=StatusChoices.FAIL,
+ status_extended="missing hub",
+ severity=Severity.high,
+ check_id="securityhub_enabled",
+ resource_uid=resource_uid,
+ resource_name="hub/retryable",
+ region="us-east-1",
+ service_name="securityhub",
+ resource_type="hub",
+ resource_tags={},
+ resource_metadata={},
+ resource_details={},
+ partition="aws",
+ raw={},
+ compliance={},
+ metadata={},
+ muted=False,
+ )
+
+ with (
+ patch("tasks.jobs.scan.CELERY_DEADLOCK_ATTEMPTS", 1),
+ patch.object(
+ Resource.objects,
+ "create",
+ side_effect=OperationalError("deadlock detected"),
+ ),
+ ):
+ with pytest.raises(OperationalError, match="deadlock detected"):
+ self._process_one_finding_micro_batch(
+ tenant,
+ scan,
+ provider,
+ finding,
+ resource_cache=CacheMissAfterPreResolve(resource_uid),
+ )
+
+ assert not Finding.objects.filter(
+ tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
+ ).exists()
+
+ def test_process_finding_micro_batch_propagates_unrecovered_cache_miss_integrity_error(
+ self, tenants_fixture, scans_fixture
+ ):
+ tenant = tenants_fixture[0]
+ scan = scans_fixture[0]
+ provider = scan.provider
+ resource_uid = "arn:aws:securityhub:us-east-1:123456789012:hub/unrecovered"
+
+ finding = FakeFinding(
+ uid="finding-cache-miss-unrecovered-integrity-error",
+ status=StatusChoices.FAIL,
+ status_extended="missing hub",
+ severity=Severity.high,
+ check_id="securityhub_enabled",
+ resource_uid=resource_uid,
+ resource_name="hub/unrecovered",
+ region="us-east-1",
+ service_name="securityhub",
+ resource_type="hub",
+ resource_tags={},
+ resource_metadata={},
+ resource_details={},
+ partition="aws",
+ raw={},
+ compliance={},
+ metadata={},
+ muted=False,
+ )
+
+ original_resource_filter = Resource.objects.filter
+ resource_filter_result = MagicMock()
+ resource_filter_result.first.side_effect = [None, None]
+
+ def resource_filter_side_effect(*args, **kwargs):
+ if kwargs.get("uid") == resource_uid:
+ return resource_filter_result
+ return original_resource_filter(*args, **kwargs)
+
+ with (
+ patch("tasks.jobs.scan.CELERY_DEADLOCK_ATTEMPTS", 1),
+ patch.object(
+ Resource.objects,
+ "filter",
+ side_effect=resource_filter_side_effect,
+ ),
+ patch.object(
+ Resource.objects,
+ "create",
+ side_effect=IntegrityError("constraint violation"),
+ ),
+ ):
+ with pytest.raises(IntegrityError, match="constraint violation"):
+ self._process_one_finding_micro_batch(
+ tenant,
+ scan,
+ provider,
+ finding,
+ resource_cache=CacheMissAfterPreResolve(resource_uid),
+ )
+
+ assert not Finding.objects.filter(
+ tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
+ ).exists()
+
def test_process_finding_micro_batch_creates_records_and_updates_caches(
self, tenants_fixture, scans_fixture
):
@@ -1574,8 +2180,12 @@ class TestProcessFindingMicroBatch:
group_resources_cache,
)
- created_finding = Finding.objects.get(uid=finding.uid)
- resource = Resource.objects.get(uid=finding.resource_uid)
+ created_finding = Finding.objects.get(
+ tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
+ )
+ resource = Resource.objects.get(
+ tenant_id=tenant.id, provider_id=provider.id, uid=finding.resource_uid
+ )
assert created_finding.scan_id == scan.id
assert created_finding.status == StatusChoices.PASS
@@ -1603,7 +2213,9 @@ class TestProcessFindingMicroBatch:
assert set(resource.tags.values_list("key", "value")) == set(
finding.resource_tags.items()
)
- assert resource.findings.filter(uid=finding.uid).exists()
+ assert resource.findings.filter(
+ tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
+ ).exists()
assert resource_cache[finding.resource_uid].id == resource.id
assert resource_failed_findings_cache[finding.resource_uid] == 0
@@ -1692,7 +2304,9 @@ class TestProcessFindingMicroBatch:
)
existing_resource.refresh_from_db()
- created_finding = Finding.objects.get(uid=finding.uid)
+ created_finding = Finding.objects.get(
+ tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
+ )
assert created_finding.delta == Finding.DeltaChoices.CHANGED
assert created_finding.status == StatusChoices.FAIL
@@ -1726,7 +2340,9 @@ class TestProcessFindingMicroBatch:
assert set(existing_resource.tags.values_list("key", "value")) == {
("team", "devsec")
}
- assert existing_resource.findings.filter(uid=finding.uid).exists()
+ assert existing_resource.findings.filter(
+ tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
+ ).exists()
assert resource_cache[finding.resource_uid].region == finding.region
assert resource_cache[finding.resource_uid].service == finding.service_name
@@ -1892,10 +2508,14 @@ class TestProcessFindingMicroBatch:
)
# Verify the long UID finding was NOT created
- assert not Finding.objects.filter(uid=long_uid).exists()
+ assert not Finding.objects.filter(
+ tenant_id=tenant.id, scan_id=scan.id, uid=long_uid
+ ).exists()
# Verify the normal finding WAS created
- assert Finding.objects.filter(uid=normal_finding.uid).exists()
+ assert Finding.objects.filter(
+ tenant_id=tenant.id, scan_id=scan.id, uid=normal_finding.uid
+ ).exists()
# Verify logging was called for skipped finding
assert mock_logger.warning.called
@@ -2020,8 +2640,12 @@ class TestProcessFindingMicroBatch:
"new_failed": 1,
}
- created_finding1 = Finding.objects.get(uid="finding-cat-1")
- created_finding2 = Finding.objects.get(uid="finding-cat-2")
+ created_finding1 = Finding.objects.get(
+ tenant_id=tenant.id, scan_id=scan.id, uid="finding-cat-1"
+ )
+ created_finding2 = Finding.objects.get(
+ tenant_id=tenant.id, scan_id=scan.id, uid="finding-cat-2"
+ )
assert set(created_finding1.categories) == {"gen-ai", "security"}
assert set(created_finding2.categories) == {"security", "iam"}
diff --git a/api/src/backend/tasks/tests/test_tasks.py b/api/src/backend/tasks/tests/test_tasks.py
index 476444cb00..8c846be805 100644
--- a/api/src/backend/tasks/tests/test_tasks.py
+++ b/api/src/backend/tasks/tests/test_tasks.py
@@ -420,6 +420,124 @@ class TestGenerateOutputs:
assert result == {"upload": False}
mock_scan_update.return_value.update.assert_called_once()
+ def test_generate_outputs_removes_previous_run_artifacts(self):
+ """Regression for PROWLER-2266.
+
+ Output writers open files in append mode with a deterministic path
+ (derived from scan.started_at). If this task runs again for the same
+ scan (e.g. broker redelivery after a worker is killed mid-run with
+ task_acks_late), reusing the leftover files appends every finding row
+ again, duplicating rows in the CSV/output while the API console keeps
+ showing a single finding. The task must start from a clean slate by
+ removing the scan's tmp output directory before (re)generating.
+ """
+ import tempfile
+ from pathlib import Path
+
+ with tempfile.TemporaryDirectory() as tmp_root:
+ # Simulate artifacts left behind by a previous run of the same scan.
+ scan_tmp_dir = Path(tmp_root) / self.tenant_id / self.scan_id
+ scan_tmp_dir.mkdir(parents=True)
+ stale_artifact = scan_tmp_dir / "prowler-output-aws-20260723120000.csv"
+ stale_artifact.write_text("HEADER\nold-finding-row\n")
+
+ with (
+ patch("tasks.tasks.DJANGO_TMP_OUTPUT_DIRECTORY", tmp_root),
+ patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter,
+ patch("tasks.tasks.Provider.objects.get"),
+ patch("tasks.tasks.initialize_prowler_provider"),
+ patch("tasks.tasks.Compliance.get_bulk"),
+ patch("tasks.tasks.get_compliance_frameworks"),
+ patch("tasks.tasks.get_prowler_provider_compliance", return_value={}),
+ patch("tasks.tasks.Finding.all_objects.filter") as mock_findings,
+ patch(
+ "tasks.tasks._generate_output_directory",
+ return_value=("/tmp/test/out", "/tmp/test/comp"),
+ ),
+ patch("tasks.tasks.FindingOutput._transform_findings_stats"),
+ patch("tasks.tasks.FindingOutput.transform_api_finding"),
+ patch(
+ "tasks.tasks.OUTPUT_FORMATS_MAPPING",
+ {
+ "json": {
+ "class": MagicMock(name="Writer"),
+ "suffix": ".json",
+ "kwargs": {},
+ }
+ },
+ ),
+ patch("tasks.tasks.COMPLIANCE_CLASS_MAP", {"aws": []}),
+ patch(
+ "tasks.tasks._compress_output_files", return_value="/tmp/compressed"
+ ),
+ patch("tasks.tasks._upload_to_s3", return_value=None),
+ patch("tasks.tasks.Scan.all_objects.filter"),
+ ):
+ mock_filter.return_value.exists.return_value = True
+ mock_findings.return_value.order_by.return_value.iterator.return_value = [
+ [MagicMock()],
+ True,
+ ]
+
+ generate_outputs_task(
+ scan_id=self.scan_id,
+ provider_id=self.provider_id,
+ tenant_id=self.tenant_id,
+ )
+
+ # The stale artifacts from the previous run must be gone, so the
+ # append-mode writers cannot duplicate rows onto them.
+ assert not stale_artifact.exists()
+ assert not scan_tmp_dir.exists()
+
+ def test_generate_outputs_aborts_when_stale_cleanup_fails(self):
+ """Regression for PROWLER-2266.
+
+ If the stale output directory cannot be removed (e.g. permission error),
+ the leftover files would be reopened in append mode and every finding
+ row would be duplicated. The task must abort instead of continuing and
+ publishing duplicated rows, so the retry can start from a clean slate.
+ """
+ import tempfile
+ from pathlib import Path
+
+ with tempfile.TemporaryDirectory() as tmp_root:
+ scan_tmp_dir = Path(tmp_root) / self.tenant_id / self.scan_id
+ scan_tmp_dir.mkdir(parents=True)
+ stale_artifact = scan_tmp_dir / "prowler-output-aws-20260723120000.csv"
+ stale_artifact.write_text("HEADER\nold-finding-row\n")
+
+ with (
+ patch("tasks.tasks.DJANGO_TMP_OUTPUT_DIRECTORY", tmp_root),
+ patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter,
+ patch("tasks.tasks.Provider.objects.get"),
+ patch("tasks.tasks.initialize_prowler_provider"),
+ patch("tasks.tasks.Compliance.get_bulk"),
+ patch("tasks.tasks.get_compliance_frameworks"),
+ patch("tasks.tasks.get_prowler_provider_compliance", return_value={}),
+ # `rmtree(ignore_errors=True)` swallows the failure and leaves the
+ # directory behind; simulate that with a no-op so the guard fires.
+ patch("tasks.tasks.rmtree"),
+ patch("tasks.tasks._generate_output_directory") as mock_gen_dir,
+ patch("tasks.tasks._compress_output_files") as mock_compress,
+ patch("tasks.tasks._upload_to_s3") as mock_upload,
+ patch("tasks.tasks.Scan.all_objects.filter") as mock_scan_update,
+ ):
+ mock_filter.return_value.exists.return_value = True
+
+ with pytest.raises(RuntimeError, match="stale output directory"):
+ generate_outputs_task(
+ scan_id=self.scan_id,
+ provider_id=self.provider_id,
+ tenant_id=self.tenant_id,
+ )
+
+ # The task must abort before generating/publishing any output.
+ mock_gen_dir.assert_not_called()
+ mock_compress.assert_not_called()
+ mock_upload.assert_not_called()
+ mock_scan_update.assert_not_called()
+
def test_generate_outputs_triggers_html_extra_update(self):
mock_finding_output = MagicMock()
mock_finding_output.compliance = {"cis": ["requirement-1", "requirement-2"]}
diff --git a/api/uv.lock b/api/uv.lock
index de878d9dc9..b7ba942be7 100644
--- a/api/uv.lock
+++ b/api/uv.lock
@@ -4762,7 +4762,7 @@ dependencies = [
[[package]]
name = "prowler-api"
-version = "1.37.0"
+version = "1.38.0"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
diff --git a/contrib/k8s/helm/prowler-app/values.yaml b/contrib/k8s/helm/prowler-app/values.yaml
index ed390af29e..1bc6fe8422 100644
--- a/contrib/k8s/helm/prowler-app/values.yaml
+++ b/contrib/k8s/helm/prowler-app/values.yaml
@@ -189,6 +189,11 @@ api:
DJANGO_STALE_WHILE_REVALIDATE: "60"
DJANGO_MANAGE_DB_PARTITIONS: "True"
DJANGO_BROKER_VISIBILITY_TIMEOUT: "86400"
+ # Caps the Celery prefork pool size on the worker pods. Without it, Celery
+ # sizes the pool from the number of visible CPUs, so on large nodes the
+ # worker spawns one child per CPU, each loading the full Prowler SDK, and
+ # OOMKills under memory pressure. Raise it on bigger workers.
+ DJANGO_CELERY_WORKER_CONCURRENCY: "2"
# Secret names to be used as env vars for api, worker, and worker_beat.
secrets: []
diff --git a/docs/changelog.mdx b/docs/changelog.mdx
new file mode 100644
index 0000000000..28463af438
--- /dev/null
+++ b/docs/changelog.mdx
@@ -0,0 +1,652 @@
+---
+title: "Changelog"
+description: "New features and improvements in each Prowler release"
+rss: true
+---
+
+
+ ### 🎫 Finding Groups - Jira
+
+
+ This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
+
+
+ Selected Findings, Finding Groups, and mixed selections can now be sent to Jira. When you select multiple findings, choose between one grouped issue or separate issues. Generated issues keep their Prowler context with deep links and filter details, while the UI provides clear dispatch and failure feedback.
+
+ 
+
+ Read more in the [Jira integration documentation](/user-guide/tutorials/prowler-app-jira-integration).
+
+ ### 🕸️ Attack Paths - Queries
+
+
+ This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
+
+
+ Prowler Cloud now records which built-in Attack Paths queries returned data at the end of each scan. The query selector hides confirmed-empty queries for the selected scan, so you can focus on paths that exist without opening blank graph views. Errored, unknown, and parameterized queries remain available when they still require investigation or input.
+
+ All Attack Paths queries are now published on [Prowler Hub](https://hub.prowler.com), where you can browse the full catalog.
+
+ 
+
+ Read more in the [Attack Paths documentation](/user-guide/tutorials/prowler-app-attack-paths).
+
+ ### 🧑🏫 New Tutorials: Connect Your AI Agents to Prowler Cloud
+
+
+ This feature needs a Prowler Cloud API key, so it is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
+
+
+ New tutorials walk you through connecting your own AI agents to Prowler Cloud, so they can query your security posture and act on it programmatically.
+
+ Read more in the [AI agents documentation](/user-guide/ai-agents/index).
+
+ ### ☁️ Region-less Oracle Cloud Infrastructure Setup
+
+ Oracle Cloud Infrastructure (OCI) provider credentials no longer require a region. Existing clients can still send the legacy `region` field for compatibility, but the API ignores it before storing credentials or starting a scan. This removes an unnecessary step from OCI onboarding.
+
+ Read more in the [OCI documentation](/user-guide/providers/oci/getting-started-oci).
+
+ ### 🔍 Checks
+
+ #### AWS
+
+ - `sagemaker_notebook_instance_no_secrets` scans the `OnCreate` and `OnStart` lifecycle scripts of SageMaker notebook instances for hardcoded API keys, passwords, tokens, connection strings, and other secrets. Thanks to @kiranrajsg!
+
+ Read more in the [AWS documentation](/user-guide/providers/aws/getting-started-aws). Explore all AWS checks at [Prowler Hub](https://hub.prowler.com/check?provider=aws).
+
+ ### 🔐 Security
+
+ - Integration responses and operations now respect provider visibility, preventing hidden-provider disclosure and blocking unauthorized attachment, connection checks, Jira dispatches, edits, and deletion.
+ - Next.js was updated from 16.2.9 to 16.2.11, patching four high-severity and five medium-severity vulnerabilities.
+ - The unused `npm` CLI was removed from the UI container image, eliminating the bundled `node-tar` CVE-2026-59873 and reducing exposure to future bundled npm vulnerabilities.
+ - Vitest and its browser packages were updated from 4.1.8 to 4.1.10, resolving the critical `@vitest/browser` file-access permission bypass. These are development dependencies and have no runtime impact.
+ - Kubernetes kubeconfig validation now blocks legacy `auth-provider.config.cmd-path` command authentication, closing a command-execution bypass.
+ - `next-auth` was updated from 5.0.0-beta.30 to 5.0.0-beta.32, patching two critical Auth.js advisories: existence-based authorization checks that could fail open when a provider is misconfigured, and a homoglyph `@` bypass in email address normalization. The bump also pulls in the patched `@auth/core` 0.41.3 transitively.
+
+ ### 🙌 External Contributors
+
+ Thank you to our community contributors for this release!
+
+ - @kiranrajsg: AWS `sagemaker_notebook_instance_no_secrets` check ([#11843](https://github.com/prowler-cloud/prowler/pull/11843))
+ - @owenchenxy: Alibaba Cloud SSH and RDP security group checks now handle capitalized `Policy="Accept"` values correctly ([#12049](https://github.com/prowler-cloud/prowler/pull/12049))
+ - @rsaladra: S3 bucket name validation no longer raises an invalid escape sequence `SyntaxWarning` at startup ([#12041](https://github.com/prowler-cloud/prowler/pull/12041))
+ - @SujayKulkarni-2211: Updated the AWS check count in the README ([#12011](https://github.com/prowler-cloud/prowler/pull/12011))
+
+ See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.36.0) for the complete list of changes.
+
+
+
+ ### 💬 Lighthouse AI - Side Chat
+
+
+ This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
+
+
+ Lighthouse AI now lives in a side panel you can open from anywhere in the app. Ask about the findings you are looking at without leaving the page, and expand to the full-page chat at any time: your draft, messages, and streaming response come along. Finding and resource details share the same panel, with tabs to switch between Details and Lighthouse AI.
+
+ 
+
+ Read more in the [Lighthouse AI documentation](/getting-started/products/prowler-cloud-lighthouse#side-panel).
+
+ ### 🤖 Lighthouse AI - Take Action
+
+
+ This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
+
+
+ Lighthouse AI is no longer read-only. Ask it to do things and it will: connect or remove providers, trigger a scan, schedule daily scans, update scan settings, and manage your mutelist and mute rules, straight from the chat. Every action is gated by RBAC: Lighthouse can only do what the user asking could do themselves.
+
+ Read more in the [Lighthouse AI capabilities](/getting-started/products/prowler-cloud-lighthouse#capabilities).
+
+ ### ☁️ One-step AWS Organizations onboarding
+
+
+ This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
+
+
+ Onboarding an entire AWS Organization is now a single step. One CloudFormation quick-create link deploys the management account role and a service-managed StackSet that rolls the role out to every member account, replacing the manual StackSet console setup. Target the whole organization or a specific Organizational Unit or Root ID, and deploy from the management account or a delegated administrator. The S3 integration quick-create link also pre-fills the bucket owner account ID, preventing a stack validation error.
+
+ 
+
+ Built on the full-organization CloudFormation template contributed by @jchrisfarris — thanks!
+
+ Read more in the [AWS Organizations documentation](/user-guide/tutorials/prowler-cloud-aws-organizations).
+
+ ### 🎯 Scan configurations: exclude checks and services
+
+
+ This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
+
+
+ Scan configurations now accept `excluded_checks` and `excluded_services` to narrow the execution scope. Skip individual checks or entire services per provider, and the scan does not run them at all: less noise, faster scans, and no findings you would mute anyway.
+
+ Read more in the [Scan Configuration documentation](/user-guide/tutorials/prowler-app-scan-configuration#limiting-the-scan-scope).
+
+ ### 🧭 Redesigned sidebar navigation
+
+ The sidebar was redesigned around how you actually work: grouped sections for security, settings, and help, a Home/Chat switch at the top, collapsible configuration entries, clearer active states, and a responsive mobile overlay.
+
+ 
+
+ ### 🔌 Prowler MCP tools renamed to `prowler_*`
+
+ Core Prowler tools in Prowler MCP moved from the `prowler_app_*` prefix to the shorter `prowler_*` namespace, and the MCP documentation was restructured around it. Legacy `prowler_app_*` names keep working in Lighthouse AI, so existing setups are not broken.
+
+ Read more in the [Prowler MCP tools reference](/getting-started/basic-usage/prowler-mcp-tools).
+
+ ### 🔐 Security
+
+ - Jira integration credentials now only accept bare Atlassian site names (letters, numbers, and hyphens), and Jira tenant information requests validate site names and no longer follow redirects.
+ - Social account linking now requires a verified matching email from both the identity provider and the existing user account, and account connection notification emails are disabled.
+ - 13 advisories reported by `pnpm audit` on the UI (3 high, 9 moderate, 1 low) are resolved with patched versions of `hono`, `ws`, `vite`, `dompurify`, `js-yaml`, `@opentelemetry/core`, and `@babel/core`, including `hono` CVE-2026-59896.
+
+ ### 🙌 External Contributors
+
+ No external contributors in this release.
+
+ Special mention to @jchrisfarris, whose full-organization CloudFormation template from v5.34.0 powers the new one-step AWS Organizations onboarding ([#10403](https://github.com/prowler-cloud/prowler/pull/10403)).
+
+ See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.35.0) for the complete list of changes.
+
+
+
+ ### 🏷️ New product names
+
+ The Prowler family has grown, and the names now say what each product is. Same products, clearer names:
+
+ **Prowler products:**
+
+ - **Prowler Cloud** — the managed cloud security platform operated by the Prowler team.
+ - **Prowler Private Cloud** (formerly *Prowler Enterprise*) — the self-hosted deployment of Prowler Cloud in your own environment.
+ - **Prowler Hub** — the free public library of versioned checks, cloud service artifacts, and compliance frameworks.
+ - **Prowler Lighthouse AI** — The Agentic Cloud Defender in Prowler Cloud and Prowler Private Cloud.
+ - **Prowler MCP** — the MCP server that connects AI assistants and agents to Prowler, including the IDE plugins.
+
+ **Open source projects:**
+
+ - **Prowler CLI** — the command-line scanner for all supported providers.
+ - **Prowler Local Server** (formerly *Prowler App*) — the self-hosted web application and API to run scans, visualize findings, and manage providers.
+ - **Prowler Local Dashboard** — the web dashboard for visualizing Prowler CLI scan results, distributed with the CLI.
+ - **Prowler SDK** — the Python library behind Prowler CLI and Prowler Local Server.
+
+ See the full family in the [Prowler products documentation](/getting-started/products).
+
+ ### 🧭 Cross-Provider Compliance
+
+
+ This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
+
+
+ One framework, every cloud, a single answer. The new **Cross-provider** tab in Compliance takes the most recent completed scan of every compatible provider and rolls them up into a single compliance posture per framework, with a per-provider breakdown and a combined executive PDF report. Requirement status follows strict precedence (FAIL over PASS over MANUAL), so one failing provider is enough to flag a requirement across your whole estate.
+
+ 
+
+ Three universal frameworks support it today:
+
+ - **CIS Controls 8.1** — AWS, Azure, Google Cloud, Microsoft 365, Kubernetes, GitHub, Google Workspace, Okta, Oracle Cloud, Alibaba Cloud, Cloudflare, MongoDB Atlas, OpenStack, and Vercel.
+ - **CSA CCM 4.0** — AWS, Azure, Google Cloud, Alibaba Cloud, and Oracle Cloud.
+ - **DORA 2022/2554** — AWS, Azure, Google Cloud, Alibaba Cloud, and Cloudflare.
+
+ Filter by provider type, account, or provider group, drill into each framework's requirements, and export the combined PDF.
+
+ 
+
+ Read more in the [Cross-Provider Compliance documentation](/user-guide/compliance/tutorials/cross-provider-compliance).
+
+ ### 🏢 New Provider — E2E Networks
+
+ Prowler now scans [**E2E Networks**](https://www.e2enetworks.com/), with **27 checks** spanning compute nodes, networking, security groups, load balancers, block and file storage, and managed databases. Thanks to @deepak7093 for their 1st provider in Prowler!
+
+ Available in the Prowler CLI:
+
+ ```bash
+ export E2E_NETWORKS_API_KEY="your-api-key"
+ export E2E_NETWORKS_AUTH_TOKEN="your-auth-token"
+ export E2E_NETWORKS_PROJECT_ID="your-project-id"
+ prowler e2enetworks
+ ```
+
+ Read more in the [E2E Networks documentation](/user-guide/providers/e2enetworks/getting-started-e2enetworks). Explore all E2E Networks checks at [Prowler Hub](https://hub.prowler.com/check?provider=e2enetworks).
+
+ ### 🔐 Security
+
+ User role relationship updates in the API are now limited to the active tenant, preserving the role assignments the same user holds in other tenants.
+
+ ### 🔍 Checks
+
+ #### AWS
+
+ - `ec2_ami_account_block_public_access` — verifies AMI block public access is enabled at the account level in each Region, so AMIs cannot be shared publicly. Thanks to @goutham-hari!
+ - `datapipeline_pipeline_no_secrets_in_definition` — scans Data Pipeline object fields, parameter objects, and parameter values for hardcoded secrets with Kingfisher. Thanks to @YinkaMetrics!
+ - `elbv2_listener_pqc_tls_enabled` — verifies ELBv2 HTTPS/TLS listeners use post-quantum TLS security policies with TLS 1.2 or higher, helping reduce harvest-now-decrypt-later exposure.
+ - `amplify_app_no_secrets_in_environment` — scans Amplify app and branch environment variables and build settings (buildSpec) for hardcoded secrets with Kingfisher. Thanks to @Deep070203!
+
+ #### Azure
+
+ - `app_function_ensure_http_is_redirected_to_https` — verifies that Function Apps enforce HTTPS-only traffic. Thanks to @amandalal007!
+
+ #### Kubernetes
+
+ - `core_minimize_hostpath_volume_mounts` — detects Pods that use `hostPath` volumes. Thanks to @0xTaoZ!
+ - `core_readonly_root_filesystem_enabled` — verifies that every container in each Pod explicitly sets `readOnlyRootFilesystem: true` in its security context. Thanks to @Weedle02!
+
+ #### STACKIT
+
+ - `iaas_server_public_ip_attached` — flags IaaS servers that have a public IP address directly attached to a network interface. Thanks to @johannes-engler-mw!
+
+ Explore all checks at [Prowler Hub](https://hub.prowler.com/check).
+
+ ### 🙌 External Contributors
+
+ Thank you to our community contributors for this release!
+
+ - @jchrisfarris — Deploy AWS Organizations with the CloudFormation template in one step ([#10403](https://github.com/prowler-cloud/prowler/pull/10403))
+ - @deepak7093 — New E2E Networks provider: 27 checks across compute nodes, networking, security groups, load balancers, block/file storage, and managed databases ([#11654](https://github.com/prowler-cloud/prowler/pull/11654))
+ - @goutham-hari — AWS `ec2_ami_account_block_public_access` check ([#11828](https://github.com/prowler-cloud/prowler/pull/11828))
+ - @YinkaMetrics — AWS `datapipeline_pipeline_no_secrets_in_definition` check ([#11821](https://github.com/prowler-cloud/prowler/pull/11821))
+ - @amandalal007 — Azure `app_function_ensure_http_is_redirected_to_https` check ([#11929](https://github.com/prowler-cloud/prowler/pull/11929))
+ - @0xTaoZ — Kubernetes `core_minimize_hostpath_volume_mounts` check ([#11837](https://github.com/prowler-cloud/prowler/pull/11837))
+ - @Weedle02 — Kubernetes `core_readonly_root_filesystem_enabled` check ([#11835](https://github.com/prowler-cloud/prowler/pull/11835))
+ - @johannes-engler-mw — STACKIT `iaas_server_public_ip_attached` check ([#11549](https://github.com/prowler-cloud/prowler/pull/11549))
+ - @janderik — Trailing newlines added to compliance, region, and fixture data files for POSIX compliance ([#11765](https://github.com/prowler-cloud/prowler/pull/11765))
+ - @Deep070203 — AWS `amplify_app_no_secrets_in_environment` check ([#11825](https://github.com/prowler-cloud/prowler/pull/11825))
+
+ See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.34.0) for the complete list of changes.
+
+
+
+ ### 🤖 Lighthouse AI — The Agentic Cloud Defender
+
+
+ This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
+
+
+ Lighthouse AI is now a full agentic assistant wired to the Prowler Cloud backend. Ask it about your findings, your compliance posture, or your riskiest resources, and watch it work: the agent discovers and runs the Prowler tools it needs to answer, with every tool call visible in the new agentic view. It reads your security data through read-only tools, so it can never touch secrets or modify your tenant.
+
+ 
+
+ The chat experience is rebuilt around **persistent sessions**: conversations stream in real time, stay in your session history, can be archived, and a **sidebar chat mode** lets you ask questions from any page in the app without losing your place.
+
+ 
+
+ You control the brain behind it. Configure one or more LLM providers — **OpenAI**, **Amazon Bedrock**, or any **OpenAI-compatible** endpoint (OpenRouter, Ollama) — with connection testing built into the setup and per-provider model selection. Add a shared **business context** (your security goals, compliance needs, organizational priorities) and every session uses it to give answers that fit your environment.
+
+ 
+
+ Read more in the [Lighthouse AI documentation](/getting-started/products/prowler-cloud-lighthouse) and the [multiple LLM providers guide](/user-guide/tutorials/prowler-cloud-lighthouse-multi-llm).
+
+ ### 📄 Compliance PDF Reports Without Credentials
+
+ Compliance PDF reports no longer require the provider's credentials to be present. Findings are now enriched from the provider metadata stored in the database, so a report still generates even after the provider secret has been deleted or its credentials have become invalid.
+
+ Read more in the [compliance documentation](/user-guide/compliance/tutorials/compliance).
+
+ ### ⏳ Scan Queueing
+
+ Overlapping scans for the same provider now queue behind the active one instead of dispatching concurrent scan workers. Launch a manual scan while a scheduled one is running and it waits its turn. No more duplicated work or racing scans.
+
+ ### 🔐 Security
+
+ The Kubernetes provider credentials now reject kubeconfigs using `exec` authentication in Prowler Cloud, at the API and in the credential form, preventing user-supplied commands from running on Cloud workers.
+
+ Read more in the [Kubernetes provider authentication documentation](/user-guide/providers/kubernetes/getting-started-k8s#step-2-configure-kubernetes-authentication).
+
+ ### 🙌 External Contributors
+
+ Thank you to our community contributors for this release!
+
+ - @kratos0718 — Azure `postgresql_flexible_server_log_retention_days_greater_3` Flexible Server log retention fix ([#11761](https://github.com/prowler-cloud/prowler/pull/11761))
+ - @Sanjays2402 — `KeyError: 'MANUAL'` crash fix in the compliance summary table, shipped early in v5.32.1 ([#11823](https://github.com/prowler-cloud/prowler/pull/11823))
+
+ See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.33.0) for the complete list of changes.
+
+
+
+ ### 🔎 Findings Triage
+
+
+ This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
+
+
+ Triage findings straight from the Findings view. Each finding gets a triage status you can move through its lifecycle:
+
+ **Open → Under Review → Remediating → Risk Accepted → False Positive → Resolved**
+
+ Add a triage note to record the decision, mute a finding, all from the row's actions menu. The current status shows inline on every finding row, so you keep track of what has been reviewed and stop re-checking the same issues scan after scan.
+
+ 
+
+ The status also follows the finding automatically across scans: when a finding flips from `FAIL` to `PASS` on the next scan it moves to **Resolved**, and when it flips from `PASS` back to `FAIL` it moves to **Reopened**. You always know whether an issue is genuinely fixed or has regressed, without touching it by hand.
+
+ 
+
+ Read more in the [Findings Triage documentation](/user-guide/tutorials/prowler-app-findings-triage).
+
+ ### ⚙️ Scan Configuration
+
+
+ This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
+
+
+ Create named, reusable scan configurations from a dedicated **Scans / Configuration** page. Each configuration is YAML that follows the structure of [`prowler/config/config.yaml`](https://github.com/prowler-cloud/prowler/blob/master/prowler/config/config.yaml), so you only include the keys you want to override; the rest fall back to the built-in defaults. Values are validated on save against a per-provider, type-safe configuration schema that range-checks each field and rejects unknown keys, so a malformed config is caught before it ever reaches a scan. Attach a configuration to one or more providers so it applies on their next scan, or save it now and attach providers later.
+
+ 
+
+ From the Providers view you can pick which configuration a provider uses (`Default` or any of your saved ones) without leaving the page. No more passing config files around by hand.
+
+ 
+
+ Read more in the [Scan Configuration documentation](/user-guide/tutorials/prowler-app-scan-configuration).
+
+ ### ✅ Per-Requirement Configuration Validation
+
+
+ This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
+
+
+ Compliance frameworks can now declare `ConfigRequirements` on a requirement, so it's reported as **FAIL** when its mapped checks ran under a configuration too loose to satisfy it. Even if every individual finding PASSed. This applies across all compliance outputs: CSV, OCSF, and console tables, and is the engine behind Scan Configuration's "marked as FAIL" behavior described above.
+
+ 
+
+ Read more in the [Configuration File documentation](/user-guide/cli/tutorials/configuration_file).
+
+ ### ⏱️ Okta — Request Throttling & Retries
+
+ Prowler now proactively throttles Okta API requests to stay under rate limits, with reactive retries on HTTP 429 as a safety net. Both are set in the scan configuration (or their equivalent CLI flags):
+
+ - `okta_requests_per_second` (config file) / `--okta-requests-per-second` (CLI) — cap the request rate. Default: 4 req/s.
+ - `okta_max_retries` (config file) / `--okta-retries-max-attempts` (CLI) — bound retry attempts. Default: 5.
+
+ This makes large Okta scans more reliable and less likely to be rate-limited.
+
+ Read more in the [Okta rate limit documentation](/user-guide/providers/okta/retry-configuration#request-throttling-requests-per-second).
+
+ ### 📉 AWS — Cap Resources Scanned per Service
+
+ Large AWS accounts can now cap how many resources Prowler analyzes for the highest-volume services, keeping scan time and cost under control. Set a global limit with `max_scanned_resources_per_service`, or override it per service:
+
+ - EBS snapshots (`max_ebs_snapshots`)
+ - Backup recovery points (`max_backup_recovery_points`)
+ - CloudWatch log groups (`max_cloudwatch_log_groups`)
+ - Lambda functions (`max_lambda_functions`)
+ - ECS task definitions (`max_ecs_task_definitions`)
+ - CodeArtifact packages (`max_codeartifact_packages`)
+
+ Limits are **disabled by default** (`0` = unlimited); only positive values cap the analyzed resources.
+
+
+ When a positive limit is set, compliance results reflect only the sampled resources, not every matching resource in the account.
+
+
+ Read more in the [configuration file documentation](/user-guide/cli/tutorials/configuration_file#supported-aws-resource-limits).
+
+ ### 🏷️ Azure — Filter by Resource Group
+
+ Azure scans can now be scoped to one or more resource groups with the new `--azure-resource-group` / `--azure-resource-groups` option. This lets you run focused assessments against specific environments, teams, or workloads instead of scanning every accessible resource in the subscription. Thanks to @Legin-ML for contributing this feature!
+
+ ```bash
+ # Single resource group
+ prowler azure --az-cli-auth --azure-resource-group rg-prod
+
+ # Multiple resource groups
+ prowler azure --az-cli-auth --azure-resource-group rg-prod1 rg-prod2
+ ```
+
+ Read more in the [Azure Resource Groups documentation](/user-guide/providers/azure/resource-groups).
+
+ ### 🧭 Provider Group Filter
+
+ Filter the **Overview, Findings, Resources, Scans, and Providers** views by provider group. Scope the whole app to a team, an environment, or a business unit in one click instead of filtering provider by provider.
+
+ 
+
+ Read more about managing provider groups in the [RBAC documentation](/user-guide/tutorials/prowler-app-rbac).
+
+ ### 🔬 API — Timestamp Precision in Findings Filters
+
+ The `/api/v1/findings` endpoint now accepts full timestamps on the `inserted_at` and `updated_at` filters (`filter[inserted_at__gte]`, `filter[inserted_at__lte]`, and the `updated_at` variants), so you can query narrow time windows instead of whole days. Date-only filtering keeps working, so existing integrations are unaffected.
+
+ ```bash
+ # Findings inserted within a precise timestamp window
+ curl --globoff \
+ 'http://localhost:8080/api/v1/findings?filter[inserted_at__gte]=2026-07-01T06:12:18Z&filter[inserted_at__lte]=2026-07-02T19:25:55Z' \
+ -H 'Authorization: Bearer ' \
+ -H 'Accept: application/vnd.api+json'
+ ```
+
+ ### 🕸️ Attack Paths — Neptune as a persistent sink
+
+ Attack Paths can now persist its graph in **AWS Neptune** in addition to Neo4j, selectable via `ATTACK_PATHS_SINK_DATABASE=neptune` (default `neo4j`). Cartography's per-scan ingest database stays on Neo4j. The scan task preflights the ingest database and the configured sink before ingestion, and provider graph cleanup now deletes relationships in directed batches before deleting nodes.
+
+ This is the groundwork for scale: a managed graph database lets Attack Paths hold much larger graphs, extend coverage to more providers, and link resources across them so an attack path can cross provider boundaries instead of stopping at one cloud's edge.
+
+ Read more in the [Attack Paths documentation](/user-guide/tutorials/prowler-app-attack-paths).
+
+ ### 🔐 New Secret-Scanning Engine — Kingfisher
+
+ Prowler's secret-scanning checks now run on [Kingfisher](https://github.com/mongodb/kingfisher) instead of `detect-secrets`. Scans run **fully offline by default**, and obvious placeholder values (e.g. `password123`, `changeme`) are no longer reported, cutting down false positives.
+
+ Opt in to **live validation** with the new `--scan-secrets-validate` flag (or the `aws.secrets_validate` config option): Prowler checks discovered secrets against the provider APIs, and any secret confirmed to be **live is reported as critical**, so you can prioritize the credentials that actually work.
+
+
+ The `detect_secrets_plugins` configuration option has been removed, as it is no longer used by the new engine.
+
+
+ Read more in the [secret detection documentation](/user-guide/cli/tutorials/pentesting#detect-secrets).
+
+ ### 🔍 Checks
+
+ #### AWS
+
+ - `stepfunctions_statemachine_encrypted_with_cmk` — Step Functions state machines use a customer-managed KMS key for encryption at rest instead of the default AWS-owned key. Thanks to @Sid-0602!
+ - `waf_regional_webacl_logging_enabled` — AWS WAF Classic Regional Web ACLs have logging enabled to a Kinesis Data Firehose stream. Thanks to @Sid-0602!
+ - **IAM privilege escalation** — the privesc checks now cover **AWS Bedrock AgentCore** paths across Runtime, Harness, Code Interpreter, and Custom Browser. Thanks to @MrCloudSec!
+ - `apigateway_restapi_no_secrets_in_stage_variables` — scans API Gateway REST API stage variables for hardcoded passwords, API keys, and tokens. Thanks to @chirag1206!
+ - `awslambda_function_no_secrets_in_code` — this check now supports a `secrets_ignore_files` audit-config option to skip files inside the deployment package by glob pattern (e.g. `*.deps.json`), suppressing .NET dependency-manifest false positives without masking real secrets.
+ - `s3_bucket_object_public` — spot-checks a configurable sample of object ACLs in each bucket and flags objects granted to the `AllUsers` or `AuthenticatedUsers` groups. Disabled by default; opt in via the `s3_bucket_object_public_enabled` configuration option. Thanks to @Synchx00!
+
+ #### Microsoft 365
+
+ New **Conditional Access** hardening checks:
+
+ - `entra_conditional_access_policy_explicitly_targets_azure_devops` — at least one enabled policy explicitly includes the Azure DevOps cloud application, rather than relying on a broad "All cloud apps" policy. Thanks to @mzl2233!
+ - `entra_conditional_access_policy_no_exclusion_gaps` — every user, group, role, or application excluded from an enabled policy stays in scope of another enabled policy. Thanks to @UTKARSH698 with @arieleli01212 as co-author!
+ - `entra_conditional_access_policy_groups_management_restricted` — every security group referenced by an enabled or report-only policy is management-restricted or role-assignable. Thanks to @SAMurai-16!
+ - `exchange_application_access_policy_restricts_mailbox_apps` — every service principal with Microsoft Graph application-level Exchange mailbox permissions is restricted by an Exchange Online Application Access Policy. Thanks to @VasistAcharya!
+
+ ### 📚 Compliance
+
+ #### CIS Benchmark Refresh — Six New Versions
+
+ Prowler ships a coordinated refresh of the CIS Benchmarks across six providers:
+
+ - **AWS** — CIS Amazon Web Services Foundations Benchmark v7.0.0, adding the new Organizations section (2.1.1-2.1.6), resource policy (2.21), web front-end access logging (4.10), and VPC Endpoints (6.8) recommendations.
+ - **Azure** — CIS Microsoft Azure Foundations Benchmark v6.0.0.
+ - **GCP** — CIS Google Cloud Platform Foundation Benchmark v5.0.0.
+ - **Kubernetes** — CIS Kubernetes Benchmark v2.0.1.
+ - **GitHub** — CIS GitHub Benchmark v1.2.0.
+ - **Microsoft 365** — CIS Microsoft 365 Foundations Benchmark v7.0.0.
+
+ #### CIS Controls v8.1 — Universal Framework
+
+ A new **universal** (cross-provider) compliance framework mapping existing checks across 18 providers — AWS, Azure, GCP, Kubernetes, M365, GitHub, AlibabaCloud, OracleCloud, GoogleWorkspace, Okta, Cloudflare, Vercel, MongoDB Atlas, OpenStack, Linode, StackIT, NHN, and Scaleway — to the 18 CIS Critical Security Controls and their Safeguards. Ships with a dedicated detail view and report mapping in the UI.
+
+ Read more in the [compliance documentation](/user-guide/compliance/tutorials/compliance). Explore the full compliance catalog at [Prowler Hub](https://hub.prowler.com/compliance).
+
+ ### 🙌 External Contributors
+
+ Thank you to our community contributors for this release!
+
+ - @chirag1206 — `apigateway_restapi_no_secrets_in_stage_variables` check ([#11188](https://github.com/prowler-cloud/prowler/pull/11188))
+ - @MrCloudSec — AWS Bedrock AgentCore privilege escalation paths in the IAM privesc checks ([#11726](https://github.com/prowler-cloud/prowler/pull/11726))
+ - @Sid-0602 — `stepfunctions_statemachine_encrypted_with_cmk` ([#11538](https://github.com/prowler-cloud/prowler/pull/11538)) and `waf_regional_webacl_logging_enabled` ([#11539](https://github.com/prowler-cloud/prowler/pull/11539)) checks
+ - @mzl2233 — `entra_conditional_access_policy_explicitly_targets_azure_devops` check ([#11182](https://github.com/prowler-cloud/prowler/pull/11182))
+ - @UTKARSH698 with @arieleli01212 as co-author — `entra_conditional_access_policy_no_exclusion_gaps` check ([#11577](https://github.com/prowler-cloud/prowler/pull/11577))
+ - @SAMurai-16 — `entra_conditional_access_policy_groups_management_restricted` check ([#11342](https://github.com/prowler-cloud/prowler/pull/11342))
+ - @vahidg — Azure PostgreSQL flexible server collection resilience fix ([#11595](https://github.com/prowler-cloud/prowler/pull/11595))
+ - @davletd — Azure `keyvault_logging_enabled` `AuditEvent` category fix ([#11660](https://github.com/prowler-cloud/prowler/pull/11660))
+ - @VasistAcharya — `exchange_application_access_policy_restricts_mailbox_apps` ([#11247](https://github.com/prowler-cloud/prowler/pull/11247))
+ - @Legin-ML — Filter scans at Resource Group level ([#10657](https://github.com/prowler-cloud/prowler/pull/10657))
+ - @Synchx00 — `s3_bucket_object_public` check ([#9517](https://github.com/prowler-cloud/prowler/pull/9517))
+
+ See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.32.0) for the complete list of changes.
+
+
+
+ ### 🗓️ Flexible Scan Scheduling
+
+
+ Available exclusively in **Prowler Cloud**. Prowler Local Server supports daily scans only.
+
+
+ 
+
+ You can now set a per-provider scan schedule from the Providers page. Pick a **scan time** and a **repeat cadence**: Daily, Every 48 hours, Weekly (with a day-of-week selector), or Monthly. Schedules can be edited or removed at any time, and a new scan never interrupts access to existing data.
+
+ 
+
+ All schedules are listed in one place under the **Scheduled** tab in **Scan Jobs**, showing each provider's cadence, next scan, and last scan at a glance.
+
+ 
+
+ Read more in the [scan scheduling documentation](/user-guide/tutorials/prowler-scan-scheduling).
+
+ ### 📚 DORA — Expanded Provider Coverage
+
+ Prowler extends [**DORA**](https://www.eiopa.europa.eu/digital-operational-resilience-act-dora_en) (Digital Operational Resilience Act, Regulation (EU) 2022/2554) coverage to **Azure**, **GCP**, **Cloudflare**, and **Alibaba Cloud**, mapping each provider's existing checks across the five DORA pillars.
+
+ 
+
+
+ The framework follows the `_` naming convention as `DORA_2022_2554`.
+
+
+ Read more in the [compliance documentation](/user-guide/compliance/tutorials/compliance).
+
+ ### 🚀 Guided Onboarding
+
+
+ Available exclusively in **Prowler Cloud**.
+
+
+ New accounts now get a guided first-run experience. The Overview greets you with an **"Add your first provider"** prompt: connect a provider so Prowler has something to scan and assess, then get started in one click (or skip for now).
+
+ 
+
+ From there, contextual empty states across the product point you to the next action rather than leaving you stuck. Attack Paths, for example, explains that you need a completed scan before it can build a graph and links straight to **Scan Jobs**, with a **"See how it works"** affordance for first-timers.
+
+ 
+
+ ### 🔐 Optional SAML SSO `userType`
+
+ The SAML `userType` attribute is now optional. If your IdP does not send it, or sends it blank, Prowler keeps the user's existing roles unchanged instead of replacing them with a fallback role.
+
+ When `userType` is provided, Prowler still maps the user to the matching role. If that role does not exist yet, Prowler creates it with read-only access: visibility over all providers, with no management permissions.
+
+ Read more in the [SAML SSO documentation](/user-guide/tutorials/prowler-app-sso).
+
+ ### 🏢 New Provider — Linode
+
+ Prowler now scans [**Linode**](https://www.linode.com/) (Akamai Cloud), covering its administration, compute, and networking services. Thanks to @varunmamillapalli for their 1st provider in Prowler!
+
+
+ Linode is not officially supported. For more information, [contact us](https://prowler.com/contact).
+
+
+ Read more in the [Linode documentation](/user-guide/providers/linode/getting-started-linode). Explore all Linode checks at [Prowler Hub](https://hub.prowler.com/check?provider=linode).
+
+ ### 🔍 Checks
+
+ #### AWS
+
+ **Post-Quantum Cryptography readiness** — get ahead of the migration to quantum-resistant cryptography:
+
+ - `cloudfront_distributions_pqc_tls_enabled` — CloudFront distributions enforce a post-quantum TLS 1.3 security policy.
+ - `apigateway_domain_name_pqc_tls_enabled` — API Gateway custom domain names use a post-quantum TLS security policy.
+ - `transfer_server_pqc_ssh_kex_enabled` — Transfer Family servers use a post-quantum hybrid SSH key exchange.
+ - `acmpca_certificate_authority_pqc_key_algorithm` — Private CA authorities use a post-quantum (ML-DSA) key algorithm (new `acmpca` service).
+ - `rolesanywhere_trust_anchor_pqc_pki` — IAM Roles Anywhere trust anchors are backed by a post-quantum (ML-DSA) PKI (new `rolesanywhere` service).
+
+ **Organization-wide governance:**
+
+ - `securityhub_delegated_admin_enabled_all_regions` — Security Hub has a delegated administrator, active in all opted-in regions, with organization auto-enable on. Thanks to @ernestprovo23!
+ - `config_delegated_admin_and_org_aggregator_all_regions` — AWS Config has a delegated administrator and an organization aggregator covering all regions. Thanks to @ernestprovo23!
+
+ **Machine learning:**
+
+ - `sagemaker_clarify_exists` — verifies at least one SageMaker Clarify processing job exists per scanned region, so bias-detection and model-explainability controls are in place. Thanks to @AlexanderSanin!
+
+ #### Azure
+
+ A large batch of new Azure checks spanning data, compute, identity, and networking:
+
+ - **Cosmos DB** — automatic failover, continuous backup policy, minimum TLS 1.2, and public network access disabled.
+ - **MySQL & PostgreSQL Flexible Servers** — geo-redundant backup and high availability.
+ - **AKS** — auto-upgrade, Azure Monitor (Container Insights), local accounts disabled, and Microsoft Defender enabled.
+ - **Databricks** — public network access disabled and secure cluster connectivity (no public IP).
+ - **Defender** — CSPM on the Standard tier.
+ - **Networking** — NSG association on subnets and DDoS Network Protection on VNets.
+ - **Entra ID** — app registration credential expiry, users with recent sign-in and strong authentication enforcement.
+ - **Recovery Services** — vaults with at least one protected backup item and vaults with adequate backup policy.
+
+ Thanks to @s1ns3nz0 for all these contributions!
+
+ #### GCP
+
+ New coverage for high availability and public-exposure detection:
+
+ - `cloudsql_instance_high_availability_enabled` — Cloud SQL primary instances use `REGIONAL` availability for automatic zone failover.
+ - `cloudfunction_function_inside_vpc` — Cloud Functions use a Serverless VPC Access connector for private egress.
+ - `cloudfunction_function_not_publicly_accessible` — detects `allUsers` / `allAuthenticatedUsers` IAM invocation bindings.
+ - `secretmanager_secret_not_publicly_accessible` — detects Secret Manager secrets with public IAM bindings.
+ - `secretmanager_secret_rotation_enabled` — verifies Secret Manager secrets have automatic rotation configured with a period of 90 days or less and no missed rotation.
+
+ Thanks to @s1ns3nz0 for all these contributions!
+
+ #### Kubernetes
+
+ New core checks for container resource governance and reliability: CPU limits, CPU requests, memory limits, memory requests, fixed image tags, liveness probes, and readiness probes. Thanks to @Nikhilkumar2311 for all these contributions!
+
+ #### Microsoft 365
+
+ - `entra_directory_sync_object_takeover_blocked` — hybrid Entra tenants block cloud object takeover through soft-match and hard-match directory synchronization. Thanks to @PrettyFox0 and @omobolajiadeyan!
+ - `entra_conditional_access_policy_no_deleted_object_references` — flags Conditional Access policies that reference user, group, or role objects that no longer resolve in the directory. Thanks to @ernestprovo23!
+
+ #### Oracle Cloud Infrastructure
+
+ - `identity_storage_service_level_admins_scoped` — CIS 3.1 control 1.15, ensuring storage service-level administrators exclude delete permissions.
+
+ Explore all checks at [Prowler Hub](https://hub.prowler.com/check).
+
+ ### 🐍 Python 3.13 Support
+
+ The Prowler SDK now supports **Python 3.13**. Thanks to @branchv!
+
+ ### 🔐 Security Updates
+
+ - **SDK** — `pytest` 8.3.5 → 9.0.3, `black` 25.1.0 → 26.3.1, `microsoft-kiota-*` → 1.9.9, and `aiohttp` → 3.14.0, patching known CVEs.
+ - **API** — `aiohttp` → 3.14.0 and `idna` → 3.15, patching known CVEs.
+ - **UI** — bumped vulnerable `Next.js`, React, AI SDK, `postcss`, `hono`, `qs`, `esbuild`, and Alpine OpenSSL packages; `dompurify` 3.4.2 → 3.4.10, patching XSS sanitization bypass advisories.
+ - **Containers** — base image bumped to `python:3.12.13-slim-bookworm` (patches `libgnutls30` CVE-2026-33845 and CVE-2026-42010) and `trivy` to 0.71.0 (patches embedded `golang.org/x/crypto` and Go stdlib CVEs).
+
+ ### 🙌 External Contributors
+
+ Thank you to our community contributors for this release!
+
+ - @varunmamillapalli — New Linode provider: administration, compute, and networking services ([#11633](https://github.com/prowler-cloud/prowler/pull/11633))
+ - @s1ns3nz0 — 20+ Azure & GCP checks across Cosmos DB, AKS, Databricks, Flexible Servers, Entra, networking, and GCP public-exposure
+ - @Nikhilkumar2311 — Kubernetes resource limits, requests, image tag, and probe checks ([#11373](https://github.com/prowler-cloud/prowler/pull/11373))
+ - @ernestprovo23 — AWS Security Hub/Config org-wide delegated admin checks ([#11259](https://github.com/prowler-cloud/prowler/pull/11259)) and M365 conditional access check ([#11236](https://github.com/prowler-cloud/prowler/pull/11236))
+ - @AlexanderSanin — `sagemaker_clarify_exists` check ([#11211](https://github.com/prowler-cloud/prowler/pull/11211))
+ - @PrettyFox0 with @omobolajiadeyan as co-author — M365 directory sync object takeover check ([#11098](https://github.com/prowler-cloud/prowler/pull/11098))
+ - @branchv — Python 3.13 support ([#9293](https://github.com/prowler-cloud/prowler/pull/9293))
+ - @alinealfa — GCP audit-filtered aggregated sinks fix ([#11575](https://github.com/prowler-cloud/prowler/pull/11575))
+ - @b-abderrahmane — Configurable Celery worker concurrency ([#11075](https://github.com/prowler-cloud/prowler/pull/11075))
+
+ See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.31.0) for the complete list of changes.
+
+
+
+ Release notes for v5.30.0 and earlier, along with every patch release, are on [GitHub Releases](https://github.com/prowler-cloud/prowler/releases).
+
diff --git a/docs/developer-guide/configurable-checks.mdx b/docs/developer-guide/configurable-checks.mdx
index f550ff4f52..04a31a8cde 100644
--- a/docs/developer-guide/configurable-checks.mdx
+++ b/docs/developer-guide/configurable-checks.mdx
@@ -133,6 +133,7 @@ Only fields with a numeric range, a fixed value set, or a length cap are listed.
| `max_unused_sagemaker_access_days` | `7..180` days | |
| `max_security_group_rules` | `1..1000` | AWS hard limit is 1000 rules per security group |
| `max_ec2_instance_age_in_days` | `1..1095` days | 3 years |
+| `max_ec2_instance_stopped_days` | `1..1095` days | 3 years |
| `ec2_high_risk_ports` | each port `1..65535` | port 0 is reserved |
| `max_idle_disconnect_timeout_in_seconds` | `60..1800` s | NIST AC-12: cap at 30 min |
| `max_disconnect_timeout_in_seconds` | `60..3600` s | |
diff --git a/docs/developer-guide/environment-variables.mdx b/docs/developer-guide/environment-variables.mdx
index 913444d84b..e2bec0f94b 100644
--- a/docs/developer-guide/environment-variables.mdx
+++ b/docs/developer-guide/environment-variables.mdx
@@ -36,6 +36,9 @@ The former build-time variables map to the new runtime variables as follows:
| `NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID` | `UI_GOOGLE_TAG_MANAGER_ID` |
| `NEXT_PUBLIC_SENTRY_DSN`, `SENTRY_DSN` | `UI_SENTRY_DSN` |
| `NEXT_PUBLIC_SENTRY_ENVIRONMENT`, `SENTRY_ENVIRONMENT` | `UI_SENTRY_ENVIRONMENT` |
+| `NEXT_PUBLIC_IS_CLOUD_ENV` | `UI_CLOUD_ENABLED` |
+
+`UI_CLOUD_ENABLED` is a plain runtime boolean flag that enables Prowler Cloud behavior when set to the exact string `"true"` and defaults to off; unlike the other renamed variables it has no legacy fallback, so `NEXT_PUBLIC_IS_CLOUD_ENV` is no longer read.
The build-time-only Sentry variables used for source-map upload — `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_AUTH_TOKEN`, and `SENTRY_RELEASE` — keep their names, as they are not part of Prowler Local Server's runtime configuration.
diff --git a/docs/docs.json b/docs/docs.json
index b9bc195182..d3e1eb420d 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -80,7 +80,24 @@
{
"group": "Prowler for AI Agents",
"pages": [
- "getting-started/products/prowler-claude-code-plugin"
+ "user-guide/ai-agents/index",
+ "user-guide/ai-agents/claude-code",
+ "user-guide/ai-agents/claude-desktop",
+ "user-guide/ai-agents/codex",
+ "user-guide/ai-agents/cursor",
+ "user-guide/ai-agents/vscode"
+ ]
+ },
+ {
+ "group": "Prowler for MSPs and MSSPs",
+ "pages": [
+ "getting-started/products/prowler-for-msps",
+ "user-guide/tutorials/prowler-for-msps-sign-up",
+ "user-guide/tutorials/prowler-for-msps-organization",
+ "user-guide/tutorials/prowler-for-msps-team",
+ "user-guide/tutorials/prowler-for-msps-customers",
+ "user-guide/tutorials/prowler-for-msps-billing",
+ "user-guide/tutorials/prowler-for-msps-branding"
]
}
]
@@ -195,6 +212,17 @@
}
]
},
+ {
+ "group": "Prowler for MSPs and MSSPs",
+ "pages": [
+ "user-guide/tutorials/prowler-for-msps-sign-up",
+ "user-guide/tutorials/prowler-for-msps-organization",
+ "user-guide/tutorials/prowler-for-msps-team",
+ "user-guide/tutorials/prowler-for-msps-customers",
+ "user-guide/tutorials/prowler-for-msps-billing",
+ "user-guide/tutorials/prowler-for-msps-branding"
+ ]
+ },
{
"group": "Prowler Lighthouse AI",
"pages": [
@@ -217,7 +245,12 @@
{
"group": "Prowler for AI Agents",
"pages": [
- "getting-started/products/prowler-claude-code-plugin"
+ "user-guide/ai-agents/index",
+ "user-guide/ai-agents/claude-code",
+ "user-guide/ai-agents/claude-desktop",
+ "user-guide/ai-agents/codex",
+ "user-guide/ai-agents/cursor",
+ "user-guide/ai-agents/vscode"
]
},
{
@@ -333,6 +366,13 @@
"user-guide/providers/googleworkspace/authentication"
]
},
+ {
+ "group": "Huawei Cloud",
+ "pages": [
+ "user-guide/providers/huaweicloud/getting-started-huaweicloud",
+ "user-guide/providers/huaweicloud/authentication"
+ ]
+ },
{
"group": "IaC",
"pages": [
@@ -525,15 +565,16 @@
"troubleshooting"
]
},
+ {
+ "tab": "Changelog",
+ "pages": [
+ "changelog"
+ ]
+ },
{
"tab": "About Us",
"icon": "/favicon.ico",
"href": "https://prowler.com/about#team"
- },
- {
- "tab": "Changelog",
- "icon": "github",
- "href": "https://github.com/prowler-cloud/prowler/releases"
}
],
"global": {
@@ -570,7 +611,7 @@
]
},
"banner": {
- "content": "Prowler App is now Prowler Local Server, and Prowler Enterprise is now Prowler Private Cloud. See [Prowler product families](/getting-started/products).",
+ "content": "Prowler App is now Prowler Local Server, and Prowler Enterprise is now Prowler Private Cloud. See [Prowler product families](/getting-started/products). Check the [latest changes](/changelog).",
"dismissible": false
},
"markdown": {
@@ -660,6 +701,10 @@
{
"source": "/user-guide/tutorials/prowler-cloud-public-ips",
"destination": "/security/networking"
+ },
+ {
+ "source": "/getting-started/products/prowler-claude-code-plugin",
+ "destination": "/user-guide/ai-agents/claude-code"
}
]
}
diff --git a/docs/getting-started/basic-usage/prowler-mcp.mdx b/docs/getting-started/basic-usage/prowler-mcp.mdx
index 120e5c038b..0a4c7fec04 100644
--- a/docs/getting-started/basic-usage/prowler-mcp.mdx
+++ b/docs/getting-started/basic-usage/prowler-mcp.mdx
@@ -23,6 +23,28 @@ Most users should use the **Cloud MCP Server** — it needs no installation and
- **Cloud MCP Server (HTTP)**: the managed server at `https://mcp.prowler.com/mcp` (or your own self-hosted HTTP server).
- **Local MCP Server (STDIO)**: local installation only (runs as a subprocess of your MCP client).
+### Step-by-Step Guides Per Agent
+
+The tabs below are a quick configuration reference. For a walkthrough with screenshots, troubleshooting, and client-specific caveats, follow the dedicated guide for your agent:
+
+
+
+ Plugin vs. MCP-only, and which Claude surfaces work
+
+
+ The Chat tab, via a local bridge
+
+
+ CLI and the VS Code extension
+
+
+ Global and project scopes
+
+
+ Agent mode with secure key prompts
+
+
+
## Cloud MCP Server Configuration (Recommended)
Connect to the **Cloud MCP Server** at `https://mcp.prowler.com/mcp` over HTTP. This is the recommended path — no installation, always up to date. The same configuration works for a self-hosted HTTP server: just swap the URL.
@@ -76,67 +98,6 @@ Connect to the **Cloud MCP Server** at `https://mcp.prowler.com/mcp` over HTTP.
The `mcp-remote` tool acts as a bridge for clients that don't support HTTP natively. Learn more at [mcp-remote on npm](https://www.npmjs.com/package/mcp-remote).
-
-
- 1. Open Claude Desktop settings
- 2. Go to "Developer" tab
- 3. Click in "Edit Config" button
- 4. Edit the `claude_desktop_config.json` file with your favorite editor
- 5. Install a reviewed version of `mcp-remote` in a dedicated local workspace:
- ```bash
- mkdir -p ~/.local/share/prowler-mcp-bridge
- cd ~/.local/share/prowler-mcp-bridge
- npm init -y
- npm install --save-exact mcp-remote@0.1.38
- ```
- 6. Add the following configuration:
- ```json
- {
- "mcpServers": {
- "prowler": {
- "command": "/absolute/path/to/.local/share/prowler-mcp-bridge/node_modules/.bin/mcp-remote",
- "args": [
- "https://mcp.prowler.com/mcp",
- "--header",
- "Authorization: Bearer ${PROWLER_API_KEY}"
- ],
- "env": {
- "PROWLER_API_KEY": ""
- }
- }
- }
- }
- ```
-
-
-
- Run the following command:
- ```bash
- export PROWLER_API_KEY=""
- claude mcp add --transport http prowler https://mcp.prowler.com/mcp --header "Authorization: Bearer $PROWLER_API_KEY" --scope user
- ```
-
-
-
- 1. Open Cursor settings
- 2. Go to "Tools & MCP"
- 3. Click in "New MCP Server" button
- 4. Add to the JSON Configuration the following:
- ```json
- {
- "mcpServers": {
- "prowler": {
- "url": "https://mcp.prowler.com/mcp",
- "headers": {
- "Authorization": "Bearer "
- }
- }
- }
- }
- ```
-
-
-
## Local MCP Server Configuration
diff --git a/docs/getting-started/installation/prowler-app.mdx b/docs/getting-started/installation/prowler-app.mdx
index 1994b8ad0f..8d342415b1 100644
--- a/docs/getting-started/installation/prowler-app.mdx
+++ b/docs/getting-started/installation/prowler-app.mdx
@@ -128,8 +128,8 @@ To update the environment file:
Edit the `.env` file and change version values:
```env
-PROWLER_UI_VERSION="5.35.0"
-PROWLER_API_VERSION="5.35.0"
+PROWLER_UI_VERSION="5.36.0"
+PROWLER_API_VERSION="5.36.0"
```
diff --git a/docs/getting-started/products/index.mdx b/docs/getting-started/products/index.mdx
index c14ea37471..d8def66737 100644
--- a/docs/getting-started/products/index.mdx
+++ b/docs/getting-started/products/index.mdx
@@ -18,7 +18,7 @@ Read the [public announcement of the Prowler product families](https://prowler-w
| Prowler Private Cloud | Prowler Cloud deployed in your own environment. Formerly Prowler Enterprise. See [pricing](https://prowler.com/pricing). |
| [Prowler Hub](https://hub.prowler.com) | Free public library of versioned checks, cloud service artifacts, and compliance frameworks. |
| [Prowler Lighthouse AI](/getting-started/products/prowler-cloud-lighthouse) | AI security analyst capabilities within Prowler Cloud and Prowler Private Cloud. |
-| [Prowler MCP](/getting-started/products/prowler-mcp) | MCP server that connects AI assistants and agents to Prowler, including IDE plugins such as [Prowler for Claude Code](/getting-started/products/prowler-claude-code-plugin). |
+| [Prowler MCP](/getting-started/products/prowler-mcp) | MCP server that connects AI assistants and agents to Prowler, including IDE plugins such as [Prowler for Claude Code](/user-guide/ai-agents/claude-code). |
{/* Unreleased products. Uncomment these rows in the Prowler Products table when announced:
| Prowler Registry | Distribution service for Prowler content such as checks and compliance frameworks. Free and paid tiers. |
diff --git a/docs/getting-started/products/prowler-claude-code-plugin.mdx b/docs/getting-started/products/prowler-claude-code-plugin.mdx
deleted file mode 100644
index 99c92a1488..0000000000
--- a/docs/getting-started/products/prowler-claude-code-plugin.mdx
+++ /dev/null
@@ -1,102 +0,0 @@
----
-title: 'Prowler for Claude Code'
-sidebarTitle: 'Claude Code'
----
-
-End-to-end cloud security and compliance from inside [Claude Code](https://www.claude.com/product/claude-code), powered by the [Prowler MCP server](/getting-started/products/prowler-mcp). The plugin lets Claude walk a Prowler Cloud-connected account through a compliance assessment and remediate findings until the chosen security or industry framework is compliant.
-
-
-**Preview**: this plugin is under active development. Please report issues on [GitHub](https://github.com/prowler-cloud/prowler/issues) or join the [Slack community](https://goto.prowler.com/slack) for feedback.
-
-
-## Requirements
-
-
-
- Installed and signed in. See the [official install guide](https://www.claude.com/product/claude-code).
-
-
- The free tier is enough to start. Sign up at [cloud.prowler.com](https://cloud.prowler.com).
-
-
- Create one at [cloud.prowler.com/profile](https://cloud.prowler.com/profile).
-
-
-
-## Installation
-
-
-
- Inside a Claude Code session:
-
- ```text
- /plugin marketplace add prowler-cloud/prowler
- /plugin install prowler@prowler-plugins
- ```
-
-
- If you already have the repository checked out:
-
- ```text
- /plugin marketplace add /absolute/path/to/prowler
- /plugin install prowler@prowler-plugins
- ```
-
-
-
-## Configuration
-
-On first install, Claude Code prompts for your **Prowler API key**. The value is stored securely (macOS keychain or `~/.claude/.credentials.json`) and used to authenticate against Prowler Cloud.
-
-
-To rotate the key, uninstall and reinstall the plugin — Claude Code will prompt again.
-
-
-## Verify the installation
-
-In a Claude Code session:
-
-```text
-/mcp → "prowler" appears as a connected server
-/plugin → "prowler" enabled, skill listed as prowler:framework-compliance-triage
-```
-
-If `/mcp` reports the `prowler` server as failed, the most common cause is a rejected API key — re-issue one in Prowler Cloud and reinstall the plugin so it re-prompts.
-
-## Usage
-
-Open a conversation that mentions the framework you want to comply with. Examples:
-
-- *"Make my AWS production account compliant with CIS 4.0."*
-- *"Make my current Terraform project compliant with Prowler ThreatScore Compliance Framework based on the latest scan results."*
-- *"Help me get to 100% on PCI-DSS for this GCP project."*
-
-You pick a **primary tool** (Terraform, gh / az / aws CLI, web console, or mixed) and a **mode**:
-
-
-
- Claude shows each fix — target resource, exact commands, side effects, reversibility — and waits for your go-ahead before applying.
-
-
- Claude presents a single up-front plan grouped by shared fixes, waits for one confirmation, then proceeds. It pauses mid-loop if a fix has wide blast radius or a finding is not applicable.
-
-
-
-Claude tracks progress in a markdown report under `.prowler/` at your project root — one file per framework × account. Open it any time to see exactly where the flow is. When all findings are addressed, Claude proposes a fresh Prowler scan to verify everything end-to-end.
-
-## Uninstalling
-
-```text
-/plugin uninstall prowler@prowler-plugins
-/plugin marketplace remove prowler-plugins
-```
-
-The stored API key is removed automatically.
-
-## Troubleshooting
-
-| Symptom | Likely cause | Fix |
-| --- | --- | --- |
-| `/mcp` shows `prowler` as failed | Rejected API key | Generate a new one in Prowler Cloud and reinstall the plugin to re-prompt. |
-| Skill not invoked when expected | The skill description didn't match the prompt | Mention the framework name plus "compliance" or "compliant" in your prompt. |
-| "Framework not supported" | Prowler Hub does not list the framework for that provider | Open an issue or PR at [github.com/prowler-cloud/prowler](https://github.com/prowler-cloud/prowler). |
diff --git a/docs/getting-started/products/prowler-for-msps.mdx b/docs/getting-started/products/prowler-for-msps.mdx
new file mode 100644
index 0000000000..c147c37435
--- /dev/null
+++ b/docs/getting-started/products/prowler-for-msps.mdx
@@ -0,0 +1,78 @@
+---
+title: "Prowler for MSPs and MSSPs"
+sidebarTitle: "Overview"
+---
+
+Prowler for MSPs and MSSPs is a dedicated console for Managed Service Providers (MSPs), Managed Security Service Providers (MSSPs), resellers and consultants who run cloud security for other organizations. It lets a provider onboard customers, group them, manage a team, and operate each customer's Prowler Cloud tenant on their behalf.
+
+The console is available at [partners.prowler.com](https://partners.prowler.com).
+
+
+
+## What You Get
+
+* **Customer onboarding:** provision a Prowler Cloud tenant for each customer, with a billing plan selected up front.
+* **Delegated access:** open any customer's Prowler Cloud tenant from the console. Every action is attributed to you acting on behalf of that customer.
+* **Team and roles:** invite team members by email and assign a role that governs what they can do.
+* **Consolidated billing:** each customer carries its own plan, with month-to-date revenue reported across every customer.
+* **Branding:** upload your logo to appear alongside Prowler branding in the console.
+
+## Core Concepts
+
+Three objects make up the model. Getting these straight makes the rest of the documentation easy to follow.
+
+| Object | What it is |
+|---|---|
+| **Partner organization** | The provider's own company. The top-level container for everything below, created at sign-up. |
+| **Customer** | One of the provider's customers. Each customer maps to a Prowler Cloud tenant and carries its own billing plan. |
+| **Team member** | A user in the partner organization, holding a role that governs what they can do. |
+
+## How It Relates to Prowler Cloud
+
+| | Prowler Cloud | Prowler for MSPs and MSSPs |
+|---|---|---|
+| **Audience** | End customers | MSPs, MSSPs, resellers, consultants |
+| **Console** | [cloud.prowler.com](https://cloud.prowler.com) | [partners.prowler.com](https://partners.prowler.com) |
+| **Scope** | One organization's own cloud accounts | Many customer organizations |
+| **Billing** | Each organization pays for itself | The provider manages a plan per customer |
+| **Branding** | Prowler-branded | Your logo alongside Prowler branding |
+
+Your customers keep signing in to Prowler Cloud with their own users. Provider-side access is **additive** — it does not replace or restrict customer-side users.
+
+## The Console at a Glance
+
+Signing in lands you on the **Dashboard**. The sidebar carries:
+
+| Entry | What it does | Visible to |
+|---|---|---|
+| **Dashboard** | Partner Insights, a Billing Overview card and an Active Customers table | Everyone |
+| **Customers** | Add customers, review their posture and billing, and open their Prowler Cloud tenant | Everyone |
+| **Team** | Invite, re-invite, disable and remove team members | Roles with **Manage members** |
+| **Settings** | Profile, Partner Code, branding and security | Everyone; editing requires **Manage settings** |
+
+
+
+**Partner Insights** is the top row: **Total Customers**, broken down into active and non-paid; **Cloud Accounts**, broken down by cloud provider; and **Monitored Resources**, with a note on organizations whose critical risk has grown. Each card carries a 30-day trend.
+
+Below it, **Billing Overview** reports monthly expenses against the previous month and splits revenue for the period into annual, monthly and overage. **Active Customers** lists your customers with their provider count, resource count and last completed scan, and carries its own **Add Customer** button.
+
+## Getting Access
+
+Sign-up is self-service, approval is not. Register at [partners.prowler.com/sign-up](https://partners.prowler.com/sign-up), then verify your email address — the organization sits in **Pending email verification** until you do, and the Prowler team does not review it before that. Verifying moves the organization to **Pending approval**. Once approved, you can invite your team and start onboarding customers.
+
+## Next Steps
+
+
+
+ Register, verify your email, and get approved.
+
+
+ Lifecycle, settings, Partner Code and closing your organization.
+
+
+ Add customers and open their Prowler Cloud tenants.
+
+
+ Invite team members and assign roles.
+
+
diff --git a/docs/getting-started/products/prowler-mcp.mdx b/docs/getting-started/products/prowler-mcp.mdx
index 93159b2e7d..a30c4e5488 100644
--- a/docs/getting-started/products/prowler-mcp.mdx
+++ b/docs/getting-started/products/prowler-mcp.mdx
@@ -26,7 +26,7 @@ The fastest way to get started is the **Cloud MCP Server** at `https://mcp.prowl
```
- Step-by-step setup for Claude Desktop, Claude Code, Cursor, and other clients.
+ Step-by-step setup for Claude Code, Codex, Cursor, VS Code, and other agents.
diff --git a/docs/images/changelog/v5.31.0-dora-alibaba.png b/docs/images/changelog/v5.31.0-dora-alibaba.png
new file mode 100644
index 0000000000..04d6f7cf94
Binary files /dev/null and b/docs/images/changelog/v5.31.0-dora-alibaba.png differ
diff --git a/docs/images/changelog/v5.31.0-onboarding-1.png b/docs/images/changelog/v5.31.0-onboarding-1.png
new file mode 100644
index 0000000000..3881c7a6d1
Binary files /dev/null and b/docs/images/changelog/v5.31.0-onboarding-1.png differ
diff --git a/docs/images/changelog/v5.31.0-onboarding-2.png b/docs/images/changelog/v5.31.0-onboarding-2.png
new file mode 100644
index 0000000000..3cd9ad5e2d
Binary files /dev/null and b/docs/images/changelog/v5.31.0-onboarding-2.png differ
diff --git a/docs/images/changelog/v5.31.0-schedule-1.png b/docs/images/changelog/v5.31.0-schedule-1.png
new file mode 100644
index 0000000000..4de4838714
Binary files /dev/null and b/docs/images/changelog/v5.31.0-schedule-1.png differ
diff --git a/docs/images/changelog/v5.31.0-schedule-2.png b/docs/images/changelog/v5.31.0-schedule-2.png
new file mode 100644
index 0000000000..6897a53c61
Binary files /dev/null and b/docs/images/changelog/v5.31.0-schedule-2.png differ
diff --git a/docs/images/changelog/v5.31.0-schedule-3.png b/docs/images/changelog/v5.31.0-schedule-3.png
new file mode 100644
index 0000000000..db7319b607
Binary files /dev/null and b/docs/images/changelog/v5.31.0-schedule-3.png differ
diff --git a/docs/images/changelog/v5.32.0-config-1.png b/docs/images/changelog/v5.32.0-config-1.png
new file mode 100644
index 0000000000..561eaa865d
Binary files /dev/null and b/docs/images/changelog/v5.32.0-config-1.png differ
diff --git a/docs/images/changelog/v5.32.0-config-2.png b/docs/images/changelog/v5.32.0-config-2.png
new file mode 100644
index 0000000000..2e57803e21
Binary files /dev/null and b/docs/images/changelog/v5.32.0-config-2.png differ
diff --git a/docs/images/changelog/v5.32.0-per-requirement-validation.png b/docs/images/changelog/v5.32.0-per-requirement-validation.png
new file mode 100644
index 0000000000..821e0cff8d
Binary files /dev/null and b/docs/images/changelog/v5.32.0-per-requirement-validation.png differ
diff --git a/docs/images/changelog/v5.32.0-provider-group-filter.png b/docs/images/changelog/v5.32.0-provider-group-filter.png
new file mode 100644
index 0000000000..39458a1a04
Binary files /dev/null and b/docs/images/changelog/v5.32.0-provider-group-filter.png differ
diff --git a/docs/images/changelog/v5.32.0-triage-1.png b/docs/images/changelog/v5.32.0-triage-1.png
new file mode 100644
index 0000000000..ce0ea3f9f1
Binary files /dev/null and b/docs/images/changelog/v5.32.0-triage-1.png differ
diff --git a/docs/images/changelog/v5.32.0-triage-2.png b/docs/images/changelog/v5.32.0-triage-2.png
new file mode 100644
index 0000000000..3c7abe387f
Binary files /dev/null and b/docs/images/changelog/v5.32.0-triage-2.png differ
diff --git a/docs/images/changelog/v5.33.0-lighthouse-ai-1.webp b/docs/images/changelog/v5.33.0-lighthouse-ai-1.webp
new file mode 100644
index 0000000000..2771d57edb
Binary files /dev/null and b/docs/images/changelog/v5.33.0-lighthouse-ai-1.webp differ
diff --git a/docs/images/changelog/v5.33.0-lighthouse-ai-2.webp b/docs/images/changelog/v5.33.0-lighthouse-ai-2.webp
new file mode 100644
index 0000000000..746d71f110
Binary files /dev/null and b/docs/images/changelog/v5.33.0-lighthouse-ai-2.webp differ
diff --git a/docs/images/changelog/v5.33.0-lighthouse-ai-3.webp b/docs/images/changelog/v5.33.0-lighthouse-ai-3.webp
new file mode 100644
index 0000000000..4bff7b1460
Binary files /dev/null and b/docs/images/changelog/v5.33.0-lighthouse-ai-3.webp differ
diff --git a/docs/images/changelog/v5.34.0-cross-provider-compliance-detail.png b/docs/images/changelog/v5.34.0-cross-provider-compliance-detail.png
new file mode 100644
index 0000000000..aa858ed64c
Binary files /dev/null and b/docs/images/changelog/v5.34.0-cross-provider-compliance-detail.png differ
diff --git a/docs/images/changelog/v5.34.0-cross-provider-compliance-overview.png b/docs/images/changelog/v5.34.0-cross-provider-compliance-overview.png
new file mode 100644
index 0000000000..70d27a51d0
Binary files /dev/null and b/docs/images/changelog/v5.34.0-cross-provider-compliance-overview.png differ
diff --git a/docs/images/changelog/v5.35.0-aws-orgs-wizard.png b/docs/images/changelog/v5.35.0-aws-orgs-wizard.png
new file mode 100644
index 0000000000..36fdd92db7
Binary files /dev/null and b/docs/images/changelog/v5.35.0-aws-orgs-wizard.png differ
diff --git a/docs/images/changelog/v5.35.0-lighthouse-ai-side-chat.png b/docs/images/changelog/v5.35.0-lighthouse-ai-side-chat.png
new file mode 100644
index 0000000000..829dffe4e9
Binary files /dev/null and b/docs/images/changelog/v5.35.0-lighthouse-ai-side-chat.png differ
diff --git a/docs/images/changelog/v5.35.0-new-menu.png b/docs/images/changelog/v5.35.0-new-menu.png
new file mode 100644
index 0000000000..f58a34e42e
Binary files /dev/null and b/docs/images/changelog/v5.35.0-new-menu.png differ
diff --git a/docs/images/changelog/v5.36.0-attack-paths-queries.png b/docs/images/changelog/v5.36.0-attack-paths-queries.png
new file mode 100644
index 0000000000..a6ede139f2
Binary files /dev/null and b/docs/images/changelog/v5.36.0-attack-paths-queries.png differ
diff --git a/docs/images/changelog/v5.36.0-finding-groups-jira.png b/docs/images/changelog/v5.36.0-finding-groups-jira.png
new file mode 100644
index 0000000000..3054b787ba
Binary files /dev/null and b/docs/images/changelog/v5.36.0-finding-groups-jira.png differ
diff --git a/docs/images/prowler-app/jira/connection-settings.png b/docs/images/prowler-app/jira/connection-settings.png
index 86ebe73dea..2ec60dfe0b 100644
Binary files a/docs/images/prowler-app/jira/connection-settings.png and b/docs/images/prowler-app/jira/connection-settings.png differ
diff --git a/docs/images/prowler-app/jira/group-info.png b/docs/images/prowler-app/jira/group-info.png
new file mode 100644
index 0000000000..045087d4e9
Binary files /dev/null and b/docs/images/prowler-app/jira/group-info.png differ
diff --git a/docs/images/prowler-app/jira/group-resources.png b/docs/images/prowler-app/jira/group-resources.png
new file mode 100644
index 0000000000..8adedc65d0
Binary files /dev/null and b/docs/images/prowler-app/jira/group-resources.png differ
diff --git a/docs/images/prowler-app/jira/integrations-tab.png b/docs/images/prowler-app/jira/integrations-tab.png
index e71773fc52..11c30c4806 100644
Binary files a/docs/images/prowler-app/jira/integrations-tab.png and b/docs/images/prowler-app/jira/integrations-tab.png differ
diff --git a/docs/images/prowler-app/jira/prowler-finding-group.png b/docs/images/prowler-app/jira/prowler-finding-group.png
new file mode 100644
index 0000000000..1967d44875
Binary files /dev/null and b/docs/images/prowler-app/jira/prowler-finding-group.png differ
diff --git a/docs/images/prowler-app/jira/select-group.png b/docs/images/prowler-app/jira/select-group.png
new file mode 100644
index 0000000000..8158ce1c21
Binary files /dev/null and b/docs/images/prowler-app/jira/select-group.png differ
diff --git a/docs/images/prowler-app/jira/select-multiple-findings.png b/docs/images/prowler-app/jira/select-multiple-findings.png
new file mode 100644
index 0000000000..d82abc2b5b
Binary files /dev/null and b/docs/images/prowler-app/jira/select-multiple-findings.png differ
diff --git a/docs/images/prowler-app/jira/send-group-to-jira.png b/docs/images/prowler-app/jira/send-group-to-jira.png
new file mode 100644
index 0000000000..4822b83f12
Binary files /dev/null and b/docs/images/prowler-app/jira/send-group-to-jira.png differ
diff --git a/docs/images/prowler-app/jira/send-to-jira-modal.png b/docs/images/prowler-app/jira/send-to-jira-modal.png
index 2cf498926a..dc9b6f990e 100644
Binary files a/docs/images/prowler-app/jira/send-to-jira-modal.png and b/docs/images/prowler-app/jira/send-to-jira-modal.png differ
diff --git a/docs/images/prowler-for-msps/add-customer-billing.png b/docs/images/prowler-for-msps/add-customer-billing.png
new file mode 100644
index 0000000000..674c85c697
Binary files /dev/null and b/docs/images/prowler-for-msps/add-customer-billing.png differ
diff --git a/docs/images/prowler-for-msps/add-customer-launch.png b/docs/images/prowler-for-msps/add-customer-launch.png
new file mode 100644
index 0000000000..1ea41ff7fb
Binary files /dev/null and b/docs/images/prowler-for-msps/add-customer-launch.png differ
diff --git a/docs/images/prowler-for-msps/add-customer-profile.png b/docs/images/prowler-for-msps/add-customer-profile.png
new file mode 100644
index 0000000000..ae3290a18c
Binary files /dev/null and b/docs/images/prowler-for-msps/add-customer-profile.png differ
diff --git a/docs/images/prowler-for-msps/change-plan-dialog.png b/docs/images/prowler-for-msps/change-plan-dialog.png
new file mode 100644
index 0000000000..5b806bb512
Binary files /dev/null and b/docs/images/prowler-for-msps/change-plan-dialog.png differ
diff --git a/docs/images/prowler-for-msps/customer-row-actions.png b/docs/images/prowler-for-msps/customer-row-actions.png
new file mode 100644
index 0000000000..8fe86728e1
Binary files /dev/null and b/docs/images/prowler-for-msps/customer-row-actions.png differ
diff --git a/docs/images/prowler-for-msps/customers-table.png b/docs/images/prowler-for-msps/customers-table.png
new file mode 100644
index 0000000000..d636b02704
Binary files /dev/null and b/docs/images/prowler-for-msps/customers-table.png differ
diff --git a/docs/images/prowler-for-msps/dashboard.png b/docs/images/prowler-for-msps/dashboard.png
new file mode 100644
index 0000000000..615b976b86
Binary files /dev/null and b/docs/images/prowler-for-msps/dashboard.png differ
diff --git a/docs/images/prowler-for-msps/invite-user-dialog.png b/docs/images/prowler-for-msps/invite-user-dialog.png
new file mode 100644
index 0000000000..24e16acd4a
Binary files /dev/null and b/docs/images/prowler-for-msps/invite-user-dialog.png differ
diff --git a/docs/images/prowler-for-msps/settings-branding.png b/docs/images/prowler-for-msps/settings-branding.png
new file mode 100644
index 0000000000..3d481a9d77
Binary files /dev/null and b/docs/images/prowler-for-msps/settings-branding.png differ
diff --git a/docs/images/prowler-for-msps/settings-profile.png b/docs/images/prowler-for-msps/settings-profile.png
new file mode 100644
index 0000000000..8749b66b1b
Binary files /dev/null and b/docs/images/prowler-for-msps/settings-profile.png differ
diff --git a/docs/images/prowler-for-msps/sign-in-form.png b/docs/images/prowler-for-msps/sign-in-form.png
new file mode 100644
index 0000000000..6f9a0ce64d
Binary files /dev/null and b/docs/images/prowler-for-msps/sign-in-form.png differ
diff --git a/docs/images/prowler-for-msps/sign-up-form.png b/docs/images/prowler-for-msps/sign-up-form.png
new file mode 100644
index 0000000000..78fddccf28
Binary files /dev/null and b/docs/images/prowler-for-msps/sign-up-form.png differ
diff --git a/docs/images/prowler-mcp/claude/claude-code-mcp-add.png b/docs/images/prowler-mcp/claude/claude-code-mcp-add.png
new file mode 100644
index 0000000000..2f5894219c
Binary files /dev/null and b/docs/images/prowler-mcp/claude/claude-code-mcp-add.png differ
diff --git a/docs/images/prowler-mcp/claude/claude-code-mcp-command.png b/docs/images/prowler-mcp/claude/claude-code-mcp-command.png
new file mode 100644
index 0000000000..c036ce8b61
Binary files /dev/null and b/docs/images/prowler-mcp/claude/claude-code-mcp-command.png differ
diff --git a/docs/images/prowler-mcp/claude/claude-code-prowler-query.png b/docs/images/prowler-mcp/claude/claude-code-prowler-query.png
new file mode 100644
index 0000000000..f78f5286e6
Binary files /dev/null and b/docs/images/prowler-mcp/claude/claude-code-prowler-query.png differ
diff --git a/docs/images/prowler-mcp/claude/claude-desktop-developer-settings.png b/docs/images/prowler-mcp/claude/claude-desktop-developer-settings.png
new file mode 100644
index 0000000000..494661238b
Binary files /dev/null and b/docs/images/prowler-mcp/claude/claude-desktop-developer-settings.png differ
diff --git a/docs/images/prowler-mcp/claude/claude-desktop-prowler-tools.png b/docs/images/prowler-mcp/claude/claude-desktop-prowler-tools.png
new file mode 100644
index 0000000000..30d0ad7be8
Binary files /dev/null and b/docs/images/prowler-mcp/claude/claude-desktop-prowler-tools.png differ
diff --git a/docs/images/prowler-mcp/codex/codex-app-mcp-servers.png b/docs/images/prowler-mcp/codex/codex-app-mcp-servers.png
new file mode 100644
index 0000000000..3b735864f9
Binary files /dev/null and b/docs/images/prowler-mcp/codex/codex-app-mcp-servers.png differ
diff --git a/docs/images/prowler-mcp/codex/codex-mcp-slash-command.png b/docs/images/prowler-mcp/codex/codex-mcp-slash-command.png
new file mode 100644
index 0000000000..df3f8a65b3
Binary files /dev/null and b/docs/images/prowler-mcp/codex/codex-mcp-slash-command.png differ
diff --git a/docs/images/prowler-mcp/codex/codex-prowler-query.png b/docs/images/prowler-mcp/codex/codex-prowler-query.png
new file mode 100644
index 0000000000..b225933cfd
Binary files /dev/null and b/docs/images/prowler-mcp/codex/codex-prowler-query.png differ
diff --git a/docs/images/prowler-mcp/cursor/cursor-customize-page.png b/docs/images/prowler-mcp/cursor/cursor-customize-page.png
new file mode 100644
index 0000000000..8afe41c1c5
Binary files /dev/null and b/docs/images/prowler-mcp/cursor/cursor-customize-page.png differ
diff --git a/docs/images/prowler-mcp/cursor/cursor-mcp-json.png b/docs/images/prowler-mcp/cursor/cursor-mcp-json.png
new file mode 100644
index 0000000000..79814b4db4
Binary files /dev/null and b/docs/images/prowler-mcp/cursor/cursor-mcp-json.png differ
diff --git a/docs/images/prowler-mcp/cursor/cursor-prowler-connected.png b/docs/images/prowler-mcp/cursor/cursor-prowler-connected.png
new file mode 100644
index 0000000000..ae946abdc5
Binary files /dev/null and b/docs/images/prowler-mcp/cursor/cursor-prowler-connected.png differ
diff --git a/docs/images/prowler-mcp/cursor/cursor-prowler-query.png b/docs/images/prowler-mcp/cursor/cursor-prowler-query.png
new file mode 100644
index 0000000000..3bdec29a36
Binary files /dev/null and b/docs/images/prowler-mcp/cursor/cursor-prowler-query.png differ
diff --git a/docs/images/prowler-mcp/vscode/vscode-agent-tools.png b/docs/images/prowler-mcp/vscode/vscode-agent-tools.png
new file mode 100644
index 0000000000..e1d90719d6
Binary files /dev/null and b/docs/images/prowler-mcp/vscode/vscode-agent-tools.png differ
diff --git a/docs/images/prowler-mcp/vscode/vscode-command-palette.png b/docs/images/prowler-mcp/vscode/vscode-command-palette.png
new file mode 100644
index 0000000000..453e973a36
Binary files /dev/null and b/docs/images/prowler-mcp/vscode/vscode-command-palette.png differ
diff --git a/docs/images/prowler-mcp/vscode/vscode-list-servers.png b/docs/images/prowler-mcp/vscode/vscode-list-servers.png
new file mode 100644
index 0000000000..596a6af443
Binary files /dev/null and b/docs/images/prowler-mcp/vscode/vscode-list-servers.png differ
diff --git a/docs/images/prowler-mcp/vscode/vscode-mcp-json.png b/docs/images/prowler-mcp/vscode/vscode-mcp-json.png
new file mode 100644
index 0000000000..01fbf91768
Binary files /dev/null and b/docs/images/prowler-mcp/vscode/vscode-mcp-json.png differ
diff --git a/docs/introduction.mdx b/docs/introduction.mdx
index 9307fa351a..810be353d6 100644
--- a/docs/introduction.mdx
+++ b/docs/introduction.mdx
@@ -48,6 +48,7 @@ Prowler supports a wide range of providers organized by category:
| [Cloudflare](/user-guide/providers/cloudflare/getting-started-cloudflare) | Official | Accounts | UI, API, CLI |
| [E2E Networks](/user-guide/providers/e2enetworks/getting-started-e2enetworks) | [Contact us](https://prowler.com/contact) | Projects | CLI |
| [Google Cloud](/user-guide/providers/gcp/getting-started-gcp) | Official | Projects | UI, API, CLI |
+| [Huawei Cloud](/user-guide/providers/huaweicloud/getting-started-huaweicloud) | [Contact us](https://prowler.com/contact) | Accounts | CLI |
| [Linode](/user-guide/providers/linode/getting-started-linode) | [Contact us](https://prowler.com/contact) | Accounts | CLI |
| **NHN** | [Contact us](https://prowler.com/contact) | Tenants | CLI |
| [OpenStack](/user-guide/providers/openstack/getting-started-openstack) | Official | Projects | UI, API, CLI |
@@ -83,7 +84,7 @@ Prowler supports a wide range of providers organized by category:
| Provider | Support | Audit Scope/Entities | Interface |
| ------------------------------------------------------------------- | -------- | -------------------- | --------- |
-| [Image](/user-guide/providers/image/getting-started-image) | Official | Container Images / Registries | CLI, API |
+| [Image](/user-guide/providers/image/getting-started-image) | Official | Container Images / Registries | UI, API, CLI |
### Custom Providers (Prowler Private Cloud Only)
diff --git a/docs/style.css b/docs/style.css
index 9a1ed19a0f..edbb1fbfcf 100644
--- a/docs/style.css
+++ b/docs/style.css
@@ -84,6 +84,7 @@ li[data-title="Prowler Lighthouse AI"] > button span:first-child::after,
li[data-title="Providers"] > button span:first-child::after,
li[data-title="Scans"] > button span:first-child::after,
li[data-title="Prowler MCP"] > button span:first-child::after,
+li[data-title="Prowler for AI Agents"] > button span:first-child::after,
div:has(+ ul a[href="/security/encryption"]) h3 span::after,
li[id="/user-guide/compliance/tutorials/cross-provider-compliance"] a > div > div > span:first-child::after,
li[id="/user-guide/tutorials/prowler-alerts"] a > div > div > span:first-child::after,
diff --git a/docs/user-guide/ai-agents/claude-code.mdx b/docs/user-guide/ai-agents/claude-code.mdx
new file mode 100644
index 0000000000..84763bf173
--- /dev/null
+++ b/docs/user-guide/ai-agents/claude-code.mdx
@@ -0,0 +1,294 @@
+---
+title: "Connect Claude Code to Prowler MCP Server"
+sidebarTitle: "Claude Code"
+---
+
+Connect [Claude Code](https://www.claude.com/product/claude-code) to the Prowler Cloud MCP Server at `https://mcp.prowler.com/mcp`.
+
+## Where Claude Code Runs
+
+Claude Code runs in two places. Both read the same configuration file, so you set it up **once from a terminal** and it works in both.
+
+| Surface | How you open it | Reads | Covered by |
+|---|---|---|---|
+| **Claude Code CLI** | `claude` in a terminal | `~/.claude.json` | This guide |
+| **Claude Code in the desktop app** | The **Code** tab inside the Claude app | `~/.claude.json` — the same file | This guide, [set up from a terminal](#claude-code-in-the-desktop-app-code-tab) |
+| **Claude app Chat** | The **Chat** tab inside the Claude app | `claude_desktop_config.json` | [Claude App Chat](/user-guide/ai-agents/claude-desktop) — a separate setup |
+
+
+**The Chat tab is not Claude Code.** It is a different product surface with its own configuration file and its own connection method (a local bridge). Nothing on this page applies to it. If you want Prowler in Chat, use the [Claude App Chat](/user-guide/ai-agents/claude-desktop) guide instead.
+
+
+## Choose Your Setup
+
+There are two ways to connect. Both end with the same MCP Server connection, the difference is what comes with it.
+
+| | 🔌 **Prowler Plugin** | ⚙️ **MCP Connection Only** |
+|---|---|---|
+| **What you get** | The MCP connection **plus** the official Prowler skills for cloud security tasks | The MCP connection |
+| **Setup** | Two slash commands, prompts for the API key | One `claude mcp add` command |
+| **Guided workflows** | ✅ Skills drive multi-step security work end to end | ❌ You drive the conversation |
+| **Best for** | Structured cloud security work, such as taking an account to compliance | Ad-hoc queries and your own workflows |
+| **Where to use it** | Claude Code CLI | Claude Code CLI, and the **recommended setup for the desktop app's [Code tab](#claude-code-in-the-desktop-app-code-tab)** |
+
+
+**The plugin already includes the MCP connection.** If you install the plugin, do **not** also run `claude mcp add` — you would end up with the server configured twice.
+
+
+## Prerequisites
+
+- **Claude Code** installed and signed in. See the [official install guide](https://www.claude.com/product/claude-code).
+- **A Prowler Cloud account.** The free tier is enough to start. Sign up at [cloud.prowler.com](https://cloud.prowler.com).
+
+## Get Your Prowler API Key
+
+Create an API key in Prowler Cloud and copy it. The key begins with `pk_` and is shown only once. Check the [API Keys](/user-guide/tutorials/prowler-app-api-keys#creating-api-keys) guide for details.
+
+---
+
+# Option 1: Install the Prowler Plugin
+
+
+**Preview**: this plugin is under active development. Please report issues on [GitHub](https://github.com/prowler-cloud/prowler/issues) or join the [Slack community](https://goto.prowler.com/slack) for feedback.
+
+
+End-to-end cloud security from inside Claude Code, powered by the Prowler MCP server. The plugin bundles the official Prowler skills, task-specific workflows that let Claude carry out multi-step security work against a Prowler Cloud-connected account, rather than answering one question at a time.
+
+### Included Skills
+
+| Skill | What it does |
+| --- | --- |
+| `prowler:framework-compliance-triage` | Walks an account through a compliance assessment and remediates findings until the chosen security or industry framework is compliant. |
+
+
+More skills are on the way. Installing the plugin keeps you current — new skills arrive with plugin updates, no extra configuration required.
+
+
+## Installation (Claude Code CLI)
+
+
+
+ Inside a Claude Code session:
+
+ ```text
+ /plugin marketplace add prowler-cloud/prowler
+ /plugin install prowler@prowler-plugins
+ ```
+
+
+ If you already have the repository checked out:
+
+ ```text
+ /plugin marketplace add /absolute/path/to/prowler
+ /plugin install prowler@prowler-plugins
+ ```
+
+
+
+On first install, Claude Code prompts for your **Prowler API key**. The value is stored securely (macOS keychain or `~/.claude/.credentials.json`) and used to authenticate against Prowler Cloud.
+
+## Verify the Installation
+
+In a Claude Code session:
+
+```text
+/mcp → "prowler" appears as a connected server
+/plugin → "prowler" enabled, with the bundled Prowler skills listed
+```
+
+If `/mcp` reports the `prowler` server as failed, the most common cause is a rejected API key, re-issue one in Prowler Cloud and reinstall the plugin so it re-prompts.
+
+## Usage
+
+Describe the security task you want done and Claude selects the matching skill.
+
+### Framework Compliance Triage
+
+Mention the framework you want to comply with:
+
+- *"Make my AWS production account compliant with CIS 4.0."*
+- *"Make my current Terraform project compliant with Prowler ThreatScore Compliance Framework based on the latest scan results."*
+- *"Help me get to 100% on PCI-DSS for this GCP project."*
+
+You pick a **primary tool** (Terraform, gh / az / aws CLI, web console, or mixed) and a **mode**:
+
+
+
+ Claude shows each fix — target resource, exact commands, side effects, reversibility — and waits for your go-ahead before applying.
+
+
+ Claude presents a single up-front plan grouped by shared fixes, waits for one confirmation, then proceeds. It pauses mid-loop if a fix has wide blast radius or a finding is not applicable.
+
+
+
+Claude tracks progress in a markdown report under `.prowler/` at your project root — one file per framework × account. Open it any time to see exactly where the flow is. When all findings are addressed, Claude proposes a fresh Prowler scan to verify everything end-to-end.
+
+## Uninstalling
+
+```text
+/plugin uninstall prowler@prowler-plugins
+/plugin marketplace remove prowler-plugins
+```
+
+The stored API key is removed automatically.
+
+---
+
+# Option 2: Connect the MCP Server Only
+
+Choose this when you want Prowler's tools available without the Prowler skills.
+
+## Add the Server
+
+Claude Code connects to remote HTTP MCP servers natively and supports custom headers, so no bridge is required.
+
+```bash
+export PROWLER_API_KEY="pk_your_api_key_here"
+
+claude mcp add --transport http prowler https://mcp.prowler.com/mcp \
+ --header "Authorization: Bearer $PROWLER_API_KEY" \
+ --scope user
+```
+
+
+
+
+
+
+**Always pass `--scope user`.** The default scope is `local`, which binds the server to the single directory you ran the command in. A locally-scoped server does not load when you open Claude Code anywhere else — this is the most common reason Prowler tools appear to vanish.
+
+
+| Scope | Loads in | Shared | Stored in |
+|-------|----------|--------|-----------|
+| `user` | All your projects | No | `~/.claude.json`, top-level `mcpServers` |
+| `project` | Current project only | Yes, via version control | `.mcp.json` in the project root |
+| `local` (default) | Current project only | No | `~/.claude.json`, under that project's entry |
+
+When the same server name exists in more than one scope, precedence is **local → project → user**. The winning entry is used whole; fields are not merged.
+
+
+Avoid `--scope project` for Prowler. That writes `.mcp.json` into your repository, and committing the file would publish your API key.
+
+
+
+**Local server:** Replace the URL with your own HTTP endpoint. Everything else stays the same.
+
+
+## Verify the Connection
+
+```bash
+claude mcp get prowler # shows which scope holds the definition
+claude mcp list # lists all servers and their status
+```
+
+Inside a Claude Code session, run `/mcp` to see connected servers and their tools.
+
+
+
+
+
+## Start Using Prowler MCP
+
+- *"Show me all critical findings from my AWS accounts"*
+- *"What does the S3 bucket public access check do?"*
+- *"Onboard this new AWS account in my Prowler organization"*
+
+
+
+
+
+---
+
+# Claude Code in the Desktop App (Code Tab)
+
+The **Code** tab in the Claude desktop app runs the same Claude Code as the CLI, and reads the same `~/.claude.json`. There is no separate Prowler setup for it — you configure it **from a terminal** and the Code tab picks it up.
+
+
+**Use [Option 2](#option-2-connect-the-mcp-server-only) with `--scope user` here.** It is the recommended setup for the Code tab. The Prowler plugin ([Option 1](#option-1-install-the-prowler-plugin)) is not the recommended route for the desktop app — install it in the Claude Code CLI instead.
+
+
+
+**You cannot do this from inside the app.** The desktop app has no interface for adding an MCP server to a Claude Code session. **Settings → Connectors** configures the **Chat** tab, not the **Code** tab, so anything added there never reaches Claude Code. Trying to configure it from the app is the main reason this appears not to work.
+
+
+
+
+ In a normal terminal — not inside the app:
+
+ ```bash
+ export PROWLER_API_KEY="pk_your_api_key_here"
+
+ claude mcp add --transport http prowler https://mcp.prowler.com/mcp \
+ --header "Authorization: Bearer $PROWLER_API_KEY" \
+ --scope user
+ ```
+
+ `--scope user` is what makes this work. It writes to `~/.claude.json`, the file the Code tab reads.
+
+
+
+ ```bash
+ claude mcp get prowler
+ ```
+
+ The scope must be `user`. A `local`-scoped server is bound to the directory you ran the command in and will not load in an app session opened elsewhere.
+
+
+
+ Quit the app completely and reopen it. Configuration is read at startup.
+
+
+
+ Open a **Code** tab session and ask for a Prowler tool: "Do you have access to the Prowler MCP tools?", it should respond with a list of available tools or confirming that it has access.
+
+
+
+---
+
+# Claude App Chat (Chat Tab)
+
+Not covered by this page. The **Chat** tab is a separate surface: it does not read `~/.claude.json`, so a server added with `claude mcp add` appears in the CLI and in the Code tab but **never** in Chat. That is expected behavior, not a broken setup.
+
+Chat reads `claude_desktop_config.json` and reaches the Prowler MCP Server through a local bridge.
+
+
+ Separate guide: local bridge and its own configuration file
+
+
+---
+
+# Troubleshooting
+
+| Symptom | Likely cause | Fix |
+| --- | --- | --- |
+| `/mcp` shows `prowler` as failed | Rejected API key | Generate a new one in Prowler Cloud. With the plugin, reinstall it to re-prompt. |
+| No MCP servers configured | Server added at `local` scope from another directory | Run `claude mcp get prowler`, then re-add with `--scope user`. |
+| A stale entry overrides a working one | Precedence is local → project → user | `claude mcp remove prowler --scope local` |
+| Tools appear in the CLI but not in the app's **Code** tab | Server added at `local` scope, or the app was not restarted | Re-add with `--scope user`, then quit and reopen the app. See [Claude Code in the Desktop App](#claude-code-in-the-desktop-app-code-tab). |
+| Tools appear in the **Code** tab but not the **Chat** tab | Chat is a different surface with its own config file | Expected. Set Chat up separately, see [Claude App Chat](/user-guide/ai-agents/claude-desktop). |
+| No way to add the server from inside the app | The app has no MCP interface for Claude Code sessions | Configure it from a terminal with `--scope user`, then restart the app. See [Claude Code in the Desktop App](#claude-code-in-the-desktop-app-code-tab). |
+| Skill not invoked when expected | The prompt didn't match any skill's description | Name the task explicitly. For compliance triage, mention the framework plus "compliance" or "compliant". |
+| "Framework not supported" | Prowler Hub does not list the framework for that provider | Open an issue or PR at [github.com/prowler-cloud/prowler](https://github.com/prowler-cloud/prowler). |
+
+### Authentication Fails With 401
+
+- Confirm the header value includes the `Bearer ` prefix.
+- Check that `PROWLER_API_KEY` was set when you ran `claude mcp add` — the shell expands it at that moment and stores the resulting literal value. If the variable was empty, the stored header reads `Bearer ` with nothing after it. Verify with `claude mcp get prowler`.
+- Confirm the key has not been revoked in Prowler Cloud.
+
+## Next Steps
+
+
+
+ Explore all available tools and capabilities
+
+
+ Configuration reference for every supported client
+
+
+
+## Getting Help
+
+- Search for existing [GitHub issues](https://github.com/prowler-cloud/prowler/issues)
+- Ask for help in our [Slack community](https://goto.prowler.com/slack)
+- Report a new issue on [GitHub](https://github.com/prowler-cloud/prowler/issues/new)
diff --git a/docs/user-guide/ai-agents/claude-desktop.mdx b/docs/user-guide/ai-agents/claude-desktop.mdx
new file mode 100644
index 0000000000..1a09c43116
--- /dev/null
+++ b/docs/user-guide/ai-agents/claude-desktop.mdx
@@ -0,0 +1,142 @@
+---
+title: "Connect the Claude App Chat to Prowler MCP Server"
+sidebarTitle: "Claude App (Chat)"
+---
+
+Connect the **Chat** tab of the Claude desktop app to the Prowler Cloud MCP Server at `https://mcp.prowler.com/mcp`.
+
+
+**This page covers the Chat tab only.** Looking for **Claude Code** — either the CLI or the app's **Code** tab? Those are a different surface, with a different configuration file and a different connection method. See [Connect Claude Code](/user-guide/ai-agents/claude-code).
+
+
+## Prerequisites
+
+- **Claude desktop app** installed and signed in.
+- **Node.js and npm**, to install the bridge.
+- **A Prowler Cloud account.** The free tier is enough to start. Sign up at [cloud.prowler.com](https://cloud.prowler.com).
+
+## Why "Add Custom Connector" Does Not Work
+
+The app's **Settings → Connectors → Add custom connector** dialog is the obvious place to paste an MCP URL, but it does not fit the Prowler Cloud MCP Server for two independent reasons:
+
+1. **Connectors authenticate with OAuth.** Authenticating with a fixed API key sent as a request header is a separate mechanism that Anthropic documents as **beta**, rolled out on request. Without it, the dialog offers a URL and OAuth client credentials, with nowhere to supply `Authorization: Bearer pk_...`.
+2. **Connectors do not connect from your machine.** Claude reaches your MCP server from Anthropic's cloud infrastructure rather than your local device. A Prowler MCP Server on `localhost`, behind a VPN, or restricted by an IP allowlist is unreachable that way regardless of authentication.
+
+Use a local bridge instead, as described below.
+
+## Step 1: Get Your Prowler API Key
+
+Create an API key in Prowler Cloud and copy it. The key begins with `pk_` and is shown only once. Check the [API Keys](/user-guide/tutorials/prowler-app-api-keys#creating-api-keys) guide for details.
+
+## Step 2: Install the Bridge
+
+`mcp-remote` presents the remote HTTP server to Claude as a local STDIO server and injects the `Authorization` header. Install a pinned version into a dedicated directory:
+
+```bash
+mkdir -p ~/.local/share/prowler-mcp-bridge
+cd ~/.local/share/prowler-mcp-bridge
+npm init -y
+npm install --save-exact mcp-remote@0.1.38
+```
+
+
+Do not configure Claude to run `npx mcp-remote` directly. `npx` can fetch and execute a new version on every launch, which means unreviewed code runs with access to your API key. Install a pinned version and point Claude at the installed binary.
+
+
+
+`mcp-remote` is community-maintained and is not an Anthropic product. Review it before use.
+
+
+## Step 3: Edit the Configuration File
+
+In the Claude app, go to **Settings → Developer** and click **Edit Config**. This reveals `claude_desktop_config.json`:
+
+- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
+- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
+
+
+
+
+
+Add the following, replacing the `command` path with the absolute path to the installed binary and the placeholder with your API key:
+
+```json
+{
+ "mcpServers": {
+ "prowler": {
+ "command": "/absolute/path/to/.local/share/prowler-mcp-bridge/node_modules/.bin/mcp-remote",
+ "args": [
+ "https://mcp.prowler.com/mcp",
+ "--header",
+ "Authorization: Bearer ${PROWLER_API_KEY}"
+ ],
+ "env": {
+ "PROWLER_API_KEY": "pk_your_api_key_here"
+ }
+ }
+ }
+}
+```
+
+
+**Local server:** Replace the URL with your own HTTP endpoint. Everything else stays the same.
+
+
+## Step 4: Restart the App
+
+Quit the Claude app completely and reopen it. Configuration is read at startup.
+
+## Step 5: Start Using Prowler MCP
+
+Open a Chat conversation and ask questions that use the Prowler tools:
+
+- *"Show me all critical findings from my AWS accounts"*
+- *"What does the S3 bucket public access check do?"*
+- *"Summarize my CIS compliance status by provider"*
+
+
+
+
+
+## Troubleshooting
+
+### Server Does Not Appear After Editing the Config
+
+- Quit and reopen the app entirely — closing the window is not enough on macOS.
+- Confirm `claude_desktop_config.json` is valid JSON.
+- Confirm the `command` path points at a real executable. A wrong path surfaces as the server failing to start rather than as an auth error.
+
+### Tools Appear in Claude Code but Not in Chat
+
+Expected. The Chat tab does not read `~/.claude.json`, so servers added with `claude mcp add` never appear here. The Chat tab needs an entry in `claude_desktop_config.json`, which is what this guide sets up.
+
+### Authentication Fails With 401
+
+- Confirm the header value includes the `Bearer ` prefix.
+- Confirm the key has not been revoked in Prowler Cloud.
+
+### Checking the Logs
+
+- **macOS:** `~/Library/Logs/Claude/mcp*.log`
+- **Windows:** `%APPDATA%\Claude\logs\mcp*.log`
+
+```bash
+tail -f ~/Library/Logs/Claude/mcp*.log
+```
+
+## Next Steps
+
+
+
+ Explore all available tools and capabilities
+
+
+ Configuration reference for every supported client
+
+
+
+## Getting Help
+
+- Search for existing [GitHub issues](https://github.com/prowler-cloud/prowler/issues)
+- Ask for help in our [Slack community](https://goto.prowler.com/slack)
+- Report a new issue on [GitHub](https://github.com/prowler-cloud/prowler/issues/new)
diff --git a/docs/user-guide/ai-agents/codex.mdx b/docs/user-guide/ai-agents/codex.mdx
new file mode 100644
index 0000000000..4a346e999f
--- /dev/null
+++ b/docs/user-guide/ai-agents/codex.mdx
@@ -0,0 +1,188 @@
+---
+title: "Connect Codex / ChatGPT Desktop to Prowler MCP Server"
+sidebarTitle: "Codex / ChatGPT"
+---
+
+Connect [OpenAI Codex](https://learn.chatgpt.com/docs/extend/mcp) to the Prowler Cloud MCP Server at `https://mcp.prowler.com/mcp` so Codex can query findings, inspect checks, and manage your Prowler providers.
+
+## Which Codex Surfaces Work
+
+Codex keeps MCP servers in one file, `~/.codex/config.toml`. You can set it up from either the **Codex / ChatGPT desktop app** or the **Codex CLI** — both write to that same file, so pick whichever you already use.
+
+| Surface | Set it up here | Notes |
+|---------|----------------|-------|
+| **[Codex / ChatGPT desktop app](https://learn.chatgpt.com/docs/app)** (macOS, Windows) | ✅ Yes | **Settings → MCP servers** |
+| **Codex CLI** (terminal) | ✅ Yes | `codex mcp` commands |
+| **Codex IDE extension** (VS Code) | Inherits | Works automatically once the app or CLI is configured |
+| **ChatGPT on the web** | ❌ No | Does not read local Codex configuration |
+
+
+**Codex and ChatGPT share one desktop app.** Since July 2026 the standalone Codex app and the ChatGPT desktop app are the same application: Codex is a dedicated coding surface inside it, alongside Chat and Work. If you already had the Codex app, updating turns it into the new ChatGPT desktop app and it still opens in Codex. Either way, this guide applies.
+
+Not to be confused with **ChatGPT Classic**, the name given to the previous-generation ChatGPT desktop app.
+
+
+
+**Configure once, use everywhere.** The Codex documentation states that the ChatGPT desktop app, Codex CLI, and IDE extension "share this configuration. Once you configure your MCP servers, you can switch among those clients without redoing setup." Set the server up in the app or the CLI and the IDE extension picks it up with no extra work.
+
+
+## Prerequisites
+
+- **The Codex / ChatGPT desktop app, or Codex CLI 0.46.0 or later.** Remote MCP servers over streamable HTTP were added to the CLI in 0.46.0 — check with `codex --version` and upgrade if needed.
+- **A Prowler Cloud account.** The free tier is enough to start. Sign up at [cloud.prowler.com](https://cloud.prowler.com).
+
+## Step 1: Get Your Prowler API Key
+
+Create an API key in Prowler Cloud and copy it. The key begins with `pk_` and is shown only once. Check the [API Keys](/user-guide/tutorials/prowler-app-api-keys#creating-api-keys) guide for details.
+
+## Step 2: Add the Prowler MCP Server
+
+The Prowler MCP Server needs two request headers: `Authorization` to authenticate you, and `User-Agent` because Codex does not send one by default.
+
+Each tab below is a complete setup — follow the one that matches the surface you use.
+
+
+
+ 1. Open **Settings** and select **Plugins → MCPs**
+ 2. Click **Add server**
+ 3. Enter `prowler` as the name and choose type **Streamable HTTP**
+ 4. Enter the URL `https://mcp.prowler.com/mcp`
+ 5. Add two headers:
+
+ | Header | Value |
+ |--------|-------|
+ | `Authorization` | `Bearer pk_your_api_key_here` |
+ | `User-Agent` | `codex` |
+
+ 6. Save the server
+
+
+
+
+
+
+ **Enter the key directly here rather than using an environment variable.** Codex can read credentials from an environment variable, but desktop applications do not reliably inherit variables exported in a shell profile — on macOS an app launched from Finder or the Dock typically sees none of them. Pasting the key into the dialog is the approach that works consistently in the app.
+
+
+
+ **This stores your API key in plain text** in `~/.codex/config.toml`. Treat that file accordingly: exclude it from dotfile repositories and config sync, and create the key from an account with the minimum permissions you need so its exposure is limited. Revoke and re-issue the key in Prowler Cloud if the file is ever shared.
+
+
+
+
+ Register the server:
+
+ ```bash
+ codex mcp add prowler --url https://mcp.prowler.com/mcp
+ ```
+
+ Codex confirms with `Added global MCP server 'prowler'.`
+
+ Then add both headers by hand, since `codex mcp add` has no flag for headers. Open `~/.codex/config.toml` and complete the entry:
+
+ ```toml
+ [mcp_servers.prowler]
+ url = "https://mcp.prowler.com/mcp"
+ http_headers = { Authorization = "Bearer pk_your_api_key_here", "User-Agent" = "codex" }
+ ```
+
+
+ **Write the key literally rather than using an environment variable.** This is the form that works across every Codex surface. All of them read this same file, but only the CLI reliably sees variables exported in your shell profile — see the warning below.
+
+
+
+ **This stores your API key in plain text** in `~/.codex/config.toml`. Treat that file accordingly: exclude it from dotfile repositories and config sync, and create the key from an account with the minimum permissions you need so its exposure is limited. Revoke and re-issue the key in Prowler Cloud if the file is ever shared.
+
+
+
+
+Restart Codex once you are done.
+
+
+**Local server:** Replace the URL with your own HTTP endpoint. Everything else stays the same.
+
+
+## Step 3: Verify the Connection
+
+Run `/mcp` in the app or in a CLI session to list connected servers and their tools.
+
+
+
+
+
+From the CLI you can also inspect the stored entry directly:
+
+```bash
+codex mcp list # one row per server, with status and auth
+codex mcp get prowler # full entry, header values masked
+```
+
+
+**Verify rather than assume.** Codex silently ignores unrecognized keys in `config.toml` — a misspelled key name produces no error at all, and the server simply never receives your credentials. Always confirm with `codex mcp get prowler` after editing the file by hand.
+
+
+## Step 4: Start Using Prowler MCP
+
+Ask Codex questions that use the Prowler tools:
+
+- *"Show me all critical findings from my AWS accounts"*
+- *"What does the S3 bucket public access check do?"*
+- *"List my connected Prowler providers and their last scan date"*
+
+
+
+
+
+## Troubleshooting
+
+### Startup Fails With HTTP 403 Forbidden
+
+Codex reports a handshake failure on startup, with an HTML error page rather than a JSON response:
+
+```
+⚠ MCP client for `prowler` failed to start: MCP startup failed: handshaking with MCP server
+ failed: ... unexpected server response: HTTP 403:
+ 403 Forbidden
+```
+
+The `User-Agent` header is missing. Codex's HTTP client does not send one, and requests without it are rejected before reaching the MCP server. Note this is a **403**, not a 401 — so it is not an API key problem. Add the header as shown in [Step 2](#step-2-add-the-prowler-mcp-server); the value itself does not matter, only that the header is present.
+
+### Authentication Fails With 401
+
+- Run `codex mcp get prowler` and confirm the entry has the headers you expect. Values are masked, but a missing header shows as `-`.
+- If you used a literal header, confirm the value starts with `Bearer ` and contains the full key.
+- **If it works in the CLI but fails in the desktop app or the VS Code extension, you are almost certainly using an environment variable.** Those surfaces do not inherit your shell profile. Switch that entry to a literal `Authorization` header as shown in [Step 2](#step-2-add-the-prowler-mcp-server).
+- If you use an environment variable, verify it is set in the environment Codex was launched from: `echo $PROWLER_API_KEY`.
+- With `env_http_headers` the variable must include the `Bearer ` prefix. With `bearer_token_env_var` it must **not** — Codex adds the prefix itself.
+- Confirm the key has not been revoked in Prowler Cloud.
+
+### Server Not Listed
+
+- Confirm your Codex CLI version is 0.46.0 or later with `codex --version`.
+- Run `codex mcp get prowler`. If it reports the server is not found, the entry was not written or the TOML table name is misspelled.
+- Check for a typo in the key names. Codex ignores unknown keys without warning.
+
+### Project-Scoped Config Is Ignored
+
+A `.codex/config.toml` inside a project is loaded **only when the project is trusted**. If your entry lives there and does nothing, trust the project or move the entry to `~/.codex/config.toml`.
+
+### Tools Do Not Appear After Editing the Config
+
+Restart Codex. Configuration is read at startup. In the app, quit completely and reopen it, sometimes just clous the window is not enough.
+
+## Next Steps
+
+
+
+ Explore all available tools and capabilities
+
+
+ Configuration reference for every supported client
+
+
+
+## Getting Help
+
+- Search for existing [GitHub issues](https://github.com/prowler-cloud/prowler/issues)
+- Ask for help in our [Slack community](https://goto.prowler.com/slack)
+- Report a new issue on [GitHub](https://github.com/prowler-cloud/prowler/issues/new)
diff --git a/docs/user-guide/ai-agents/cursor.mdx b/docs/user-guide/ai-agents/cursor.mdx
new file mode 100644
index 0000000000..5e28eeeb1f
--- /dev/null
+++ b/docs/user-guide/ai-agents/cursor.mdx
@@ -0,0 +1,171 @@
+---
+title: "Connect Cursor to Prowler MCP Server"
+sidebarTitle: "Cursor"
+---
+
+Connect [Cursor](https://cursor.com/docs/mcp) to the Prowler Cloud MCP Server at `https://mcp.prowler.com/mcp` so the Cursor agent can query findings, inspect security checks, and manage your Prowler providers while you work.
+
+Cursor supports remote MCP servers over HTTP natively, so no bridge or local installation is required.
+
+## Prerequisites
+
+- **Cursor** installed and authenticated. See the [official install guide](https://cursor.com/download).
+- **A Prowler Cloud account.** The free tier is enough to start. Sign up at [cloud.prowler.com](https://cloud.prowler.com).
+
+## Step 1: Get Your Prowler API Key
+
+Create an API key in Prowler Cloud and copy it. The key begins with `pk_` and is shown only once. Check the [API Keys](/user-guide/tutorials/prowler-app-api-keys#creating-api-keys) guide for details.
+
+## Step 2: Add the Prowler MCP Server
+
+Cursor reads MCP servers from an `mcp.json` file. Choose the scope that fits your use case:
+
+| Scope | File | Applies to |
+|-------|------|------------|
+| **Global** | `~/.cursor/mcp.json` | Every project you open in Cursor |
+| **Project** | `.cursor/mcp.json` in the project root | That project only |
+
+Both files are merged. If the same server name appears in both, the project-level entry takes priority.
+
+For Prowler, the **global** scope is usually the right choice — your findings are not tied to a single repository, and it keeps the API key out of any project directory that might be committed.
+
+
+
+ From Agent Window open **Customize** in the Cursor sidebar, then select the MCP section.
+
+ On earlier versions, press `Cmd + Shift + J` (macOS) or `Ctrl + Shift + J` (Windows/Linux) to open Cursor Settings, then click **Tools & MCP** in the sidebar.
+
+
+
+
+
+ Click **New MCP Server** (or **Add Custom MCP**). Cursor opens `mcp.json` in the editor.
+
+
+
+
+
+
+
+ Paste the following, replacing the placeholder with your API key:
+
+ ```json
+ {
+ "mcpServers": {
+ "prowler": {
+ "url": "https://mcp.prowler.com/mcp",
+ "headers": {
+ "Authorization": "Bearer "
+ }
+ }
+ }
+ }
+ ```
+
+ Save the file. Cursor picks up the change and connects to the server.
+
+
+
+
+
+
+
+
+**Local server:** Replace the URL with your own HTTP endpoint. Everything else stays the same.
+
+
+### Keeping the API Key Out of the File
+
+Cursor resolves variables in the `command`, `args`, `env`, `url`, and `headers` fields, so you can reference an environment variable instead of writing the key into `mcp.json`:
+
+```json
+{
+ "mcpServers": {
+ "prowler": {
+ "url": "https://mcp.prowler.com/mcp",
+ "headers": {
+ "Authorization": "Bearer ${env:PROWLER_API_KEY}"
+ }
+ }
+ }
+}
+```
+
+Export the variable in your shell profile (`~/.zshrc`, `~/.bashrc`, or equivalent):
+
+```bash
+export PROWLER_API_KEY="pk_your_api_key_here"
+```
+
+
+The syntax is `${env:NAME}`, not a bare `${NAME}`. Restart Cursor after changing your shell profile so it inherits the new value.
+
+
+
+The `envFile` option does **not** work for remote servers — it is STDIO-only. Use `${env:...}` interpolation with variables set in your shell profile instead.
+
+
+This form is strongly recommended when using a **project-scoped** `.cursor/mcp.json`, since that file may be committed to version control.
+
+## Step 3: Verify the Connection
+
+Return to the MCP settings. The `prowler` server should be listed as enabled, with the Prowler tools shown beneath it.
+
+
+
+
+
+## Step 4: Start Using Prowler MCP
+
+Open the chat panel and ask questions that use the Prowler tools:
+
+- *"Show me all critical findings from my AWS accounts"*
+- *"What does the S3 bucket public access check do?"*
+- *"Which of my providers failed the most CIS checks in the last scan?"*
+
+Cursor asks for approval before running an MCP tool the first time.
+
+
+
+
+
+You can toggle individual tools on or off from the tools list at the top of the chat panel, which is useful for keeping the active tool count down.
+
+## Troubleshooting
+
+### Server Does Not Connect
+
+- Check that `mcp.json` is valid JSON. A trailing comma or missing brace prevents the whole file from loading.
+- Open **MCP Logs** in the Output panel for the specific error.
+- Confirm the URL is exactly `https://mcp.prowler.com/mcp`.
+
+### Authentication Fails With 401
+
+- Verify the header value includes the `Bearer ` prefix: `"Bearer pk_..."`, not just the key.
+- Confirm the key has not been revoked in Prowler Cloud.
+- If using `${env:PROWLER_API_KEY}`, check the variable is set in the environment Cursor inherits. Restart Cursor after editing your shell profile — a value exported only in an already-open terminal will not reach the app.
+
+### The Entire `mcp.json` Is Ignored
+
+Remove any `"type": "streamable-http"` field. One such entry causes the Cursor CLI to drop every server in the file silently.
+
+### Some Prowler Tools Are Missing
+
+Cursor limits how many tools it exposes to the agent at once. With several MCP servers enabled you may exceed it, and some tools become unavailable. Disable servers you are not using, or turn off individual tools from the chat panel's tools list.
+
+## Next Steps
+
+
+
+ Explore all available tools and capabilities
+
+
+ Configuration reference for every supported client
+
+
+
+## Getting Help
+
+- Search for existing [GitHub issues](https://github.com/prowler-cloud/prowler/issues)
+- Ask for help in our [Slack community](https://goto.prowler.com/slack)
+- Report a new issue on [GitHub](https://github.com/prowler-cloud/prowler/issues/new)
diff --git a/docs/user-guide/ai-agents/index.mdx b/docs/user-guide/ai-agents/index.mdx
new file mode 100644
index 0000000000..ca05419050
--- /dev/null
+++ b/docs/user-guide/ai-agents/index.mdx
@@ -0,0 +1,49 @@
+---
+title: "Connect Your AI Agent to Prowler"
+sidebarTitle: "Overview"
+description: "Pick your AI agent and follow its guide to connect it to the Prowler Cloud MCP Server."
+---
+
+Connect your AI agent to the Prowler Cloud MCP Server at `https://mcp.prowler.com/mcp` so it can query findings, inspect security checks, and manage your Prowler providers.
+
+Pick your agent below. Each guide is a full walkthrough with screenshots, verification steps, and the caveats specific to that client.
+
+
+
+ Plugin and MCP-only choices, and which Claude surfaces work
+
+
+ The Chat tab, via a local bridge
+
+
+ ChatGPT Desktop App, Codex CLI, the VS Code extension through same config file
+
+
+ Agentic code editor. Global and project scopes
+
+
+ Agent mode with secure key prompts
+
+
+
+## Before You Start
+
+All guides need the same two things:
+
+- A **Prowler Cloud account** with at least one cloud provider connected. [Sign up](https://cloud.prowler.com) if you do not have one.
+- A **Prowler API key**, created in Prowler Cloud. The key begins with `pk_` and is shown only once. See the [API Keys](/user-guide/tutorials/prowler-app-api-keys#creating-api-keys) guide.
+
+
+Using an agent that is not listed here? Any MCP-compatible client can connect. See the [generic configuration reference](/getting-started/basic-usage/prowler-mcp#cloud-mcp-server-configuration-recommended) for the raw connection details.
+
+
+## Next Steps
+
+
+
+ How the MCP Server fits into Prowler
+
+
+ Cloud and local server options, all clients
+
+
diff --git a/docs/user-guide/ai-agents/vscode.mdx b/docs/user-guide/ai-agents/vscode.mdx
new file mode 100644
index 0000000000..90f41e5816
--- /dev/null
+++ b/docs/user-guide/ai-agents/vscode.mdx
@@ -0,0 +1,145 @@
+---
+title: "Connect VS Code and GitHub Copilot to Prowler MCP Server"
+sidebarTitle: "VS Code / Copilot"
+---
+
+Connect [Visual Studio Code](https://code.visualstudio.com/docs/agents/reference/mcp-configuration) and GitHub Copilot agent mode to the Prowler Cloud MCP Server at `https://mcp.prowler.com/mcp` so Copilot can query findings, inspect security checks, and manage your Prowler providers.
+
+## Prerequisites
+
+- **VS Code 1.102 or later.** MCP support became generally available in 1.102.
+- **GitHub Copilot** enabled, with access to agent mode.
+- **A Prowler Cloud account.** The free tier is enough to start. Sign up at [cloud.prowler.com](https://cloud.prowler.com).
+
+## Step 1: Get Your Prowler API Key
+
+Create an API key in Prowler Cloud and copy it. The key begins with `pk_` and is shown only once. Check the [API Keys](/user-guide/tutorials/prowler-app-api-keys#creating-api-keys) guide for details.
+
+## Step 2: Add the Prowler MCP Server
+
+VS Code stores MCP servers in an `mcp.json` file. Choose the scope that fits your use case:
+
+| Scope | How to open it | Applies to |
+|-------|----------------|------------|
+| **User** | Command palette → **MCP: Open User Configuration** | Every workspace |
+| **Workspace** | `.vscode/mcp.json` in the project root | That workspace only |
+
+For Prowler, the **user** scope is usually the right choice — your findings are not tied to a single repository, and it keeps the API key out of any project directory that might be committed.
+
+
+
+ Open the command palette with `Cmd + Shift + P` (macOS) or `Ctrl + Shift + P` (Windows/Linux), then run **MCP: Open User Configuration**.
+
+ VS Code opens your user-level `mcp.json`. Use this command rather than navigating to the file by hand — the file lives inside your active profile folder, and the path differs per profile.
+
+
+
+
+
+
+
+ Paste the following. This version prompts you for the API key on first use and stores it securely, so the key is never written into the file:
+
+ ```json
+ {
+ "inputs": [
+ {
+ "type": "promptString",
+ "id": "prowler-api-key",
+ "description": "Prowler API Key",
+ "password": true
+ }
+ ],
+ "servers": {
+ "prowler": {
+ "type": "http",
+ "url": "https://mcp.prowler.com/mcp",
+ "headers": {
+ "Authorization": "Bearer ${input:prowler-api-key}"
+ }
+ }
+ }
+ }
+ ```
+
+ Save the file.
+
+
+
+
+
+
+
+ Start the server. VS Code prompts for the Prowler API key. Paste it and press Enter — VS Code stores it securely and does not ask again.
+
+
+
+
+
+**The root key is `servers`, not `mcpServers`.** VS Code uses a different schema from Cursor, Claude, and most other clients. Copying a `mcpServers` snippet from elsewhere silently fails to register the server.
+
+
+
+**Local server:** Replace the URL with your own HTTP endpoint. Everything else stays the same.
+
+
+## Step 3: Verify the Connection
+
+Run **MCP: List Servers** from the command palette. The `prowler` server should appear as running.
+
+
+
+
+
+Select the server to start, stop, or restart it, and to view its output log if the connection fails.
+
+## Step 4: Start Using Prowler MCP
+
+Open the Chat view and switch the mode selector to **Agent**. Click the tools icon to confirm the Prowler tools are available, then ask:
+
+- *"Show me all critical findings from my AWS accounts"*
+- *"What does the S3 bucket public access check do?"*
+- *"Summarize my CIS compliance status by provider"*
+
+
+
+
+
+Copilot asks for confirmation before running an MCP tool for the first time.
+
+## Troubleshooting
+
+### Server Does Not Appear
+
+- Confirm the root key is `servers`, not `mcpServers`.
+- Confirm each server entry has `"type": "http"`.
+- Check that `mcp.json` is valid JSON.
+- Verify your VS Code version is 1.102 or later.
+
+### Authentication Fails With 401
+
+- Verify the header value includes the `Bearer ` prefix.
+- Confirm the key has not been revoked in Prowler Cloud.
+- If you mistyped the key at the prompt, run **MCP: List Servers**, select `prowler`, and restart it to be prompted again.
+
+### Tools Do Not Appear in Chat
+
+- Make sure the Chat view is in **Agent** mode. MCP tools are not available in Ask mode.
+- Open the tools picker and confirm the Prowler tools are enabled.
+
+## Next Steps
+
+
+
+ Explore all available tools and capabilities
+
+
+ Configuration reference for every supported client
+
+
+
+## Getting Help
+
+- Search for existing [GitHub issues](https://github.com/prowler-cloud/prowler/issues)
+- Ask for help in our [Slack community](https://goto.prowler.com/slack)
+- Report a new issue on [GitHub](https://github.com/prowler-cloud/prowler/issues/new)
diff --git a/docs/user-guide/cli/tutorials/configuration_file.mdx b/docs/user-guide/cli/tutorials/configuration_file.mdx
index e637ea9c9a..2b5ef617cf 100644
--- a/docs/user-guide/cli/tutorials/configuration_file.mdx
+++ b/docs/user-guide/cli/tutorials/configuration_file.mdx
@@ -63,6 +63,7 @@ The following list includes all the AWS checks with configurable variables that
| `dynamodb_table_cross_account_access` | `trusted_account_ids` | List of Strings | `[]` |
| `ec2_elastic_ip_shodan` | `shodan_api_key` | String | `null` |
| `ec2_instance_older_than_specific_days` | `max_ec2_instance_age_in_days` | Integer | `180` |
+| `ec2_instance_stopped_older_than_specific_days` | `max_ec2_instance_stopped_days` | Integer | `30` |
| `ec2_instance_secrets_user_data` | `secrets_ignore_patterns` | List of Strings | `[]` |
| `ec2_launch_template_no_secrets` | `secrets_ignore_patterns` | List of Strings | `[]` |
| `ec2_securitygroup_allow_ingress_from_internet_to_any_port` | `ec2_allowed_instance_owners` | List of Strings | `["amazon-elb"]` |
@@ -392,6 +393,8 @@ aws:
max_security_group_rules: 50
# aws.ec2_instance_older_than_specific_days --> by default is 6 months (180 days)
max_ec2_instance_age_in_days: 180
+ # aws.ec2_instance_stopped_older_than_specific_days --> by default is 30 days
+ max_ec2_instance_stopped_days: 30
# aws.ec2_securitygroup_allow_ingress_from_internet_to_any_port
# allowed network interface types for security groups open to the Internet
ec2_allowed_interface_types:
diff --git a/docs/user-guide/providers/huaweicloud/authentication.mdx b/docs/user-guide/providers/huaweicloud/authentication.mdx
new file mode 100644
index 0000000000..d04da1620b
--- /dev/null
+++ b/docs/user-guide/providers/huaweicloud/authentication.mdx
@@ -0,0 +1,135 @@
+---
+title: "Huawei Cloud Authentication in Prowler"
+---
+
+import { VersionBadge } from "/snippets/version-badge.mdx"
+
+
+
+Prowler for Huawei Cloud authenticates against the Huawei Cloud APIs using an IAM user's **Access Key ID** and **Secret Access Key** (AK/SK). Credentials are read exclusively from environment variables to avoid exposing secrets in shell history or process listings; there are no credential CLI flags.
+
+## Required Credentials
+
+Prowler requires read access to the Huawei Cloud account. The following values are supported:
+
+| Credential | Environment Variable | Description |
+|------------|----------------------|-------------|
+| Access Key ID | `HUAWEICLOUD_ACCESS_KEY_ID` (or `HW_ACCESS_KEY`) | Permanent access key ID of the IAM user |
+| Secret Access Key | `HUAWEICLOUD_SECRET_ACCESS_KEY` (or `HW_SECRET_KEY`) | Secret access key paired with the access key ID |
+| Domain ID | `HUAWEICLOUD_DOMAIN_ID` (or `HW_DOMAIN_ID`) | Optional account (domain) ID |
+| Security Token | `HUAWEICLOUD_SECURITY_TOKEN` | Optional security token for temporary credentials |
+| Region | `HUAWEICLOUD_REGION` (or `HW_REGION`) | Default region(s) when `--region` is not passed (e.g. `eu-west-101`) |
+| Cloud | `HUAWEICLOUD_CLOUD` (or `HW_CLOUD`) | Scan every region of a cloud (`international`, `europe`, or `china`) when no region is set |
+
+
+The endpoint domain (`.eu` or `.com`) and the per-region project ID are resolved automatically from the region, so neither endpoint nor project configuration is needed. Multi-region scans work out of the box. For the region precedence rules and the full list of supported regions, see [Regions and Clouds](/user-guide/providers/huaweicloud/getting-started-huaweicloud#regions-and-clouds).
+
+
+
+Huawei Cloud runs separate clouds: **International** and **China** (`.com` endpoints) and **Huawei Cloud Europe** (`.eu` endpoints). The region (or `--cloud` selector) determines the endpoint, so accounts outside China must select a region they can reach — for example `eu-west-101` for a Huawei Cloud Europe account. A single set of credentials belongs to one cloud, so it cannot authenticate against both `.com` and `.eu`; scan each account with its own credentials.
+
+
+---
+
+## API Credentials
+
+### Step 1: Create an Access Key (AK/SK)
+
+1. Log in to the [Huawei Cloud console](https://console-intl.huaweicloud.com).
+2. Open **My Credentials** from the account menu, then select **Access Keys**.
+3. Click **Create Access Key** and complete the identity verification.
+4. Download the `credentials.csv` file — it contains the Access Key ID and Secret Access Key. The secret is not shown again.
+
+
+Use an IAM user with read-only permissions (for example, the built-in `ReadOnly` policy) rather than the account root credentials.
+
+
+### Step 2: Configure Authentication
+
+Export the credentials as environment variables:
+
+```bash
+export HUAWEICLOUD_ACCESS_KEY_ID="your-access-key-id"
+export HUAWEICLOUD_SECRET_ACCESS_KEY="your-secret-access-key"
+```
+
+Then run Prowler:
+
+```bash
+prowler huaweicloud
+```
+
+---
+
+## Assuming an Agency (Cross-Account)
+
+To scan a different account, assume an [agency](https://support.huaweicloud.com/intl/en-us/usermanual-iam/iam_06_0002.html) delegated to your account. Set the agency name and the target account, and Prowler exchanges the base credentials for temporary credentials scoped to the agency:
+
+```bash
+export HUAWEICLOUD_ACCESS_KEY_ID="your-access-key-id"
+export HUAWEICLOUD_SECRET_ACCESS_KEY="your-secret-access-key"
+export HUAWEICLOUD_AGENCY_NAME="your-agency-name"
+export HUAWEICLOUD_ASSUME_DOMAIN_ID="target-account-domain-id" # or HUAWEICLOUD_ASSUME_DOMAIN_NAME
+prowler huaweicloud
+```
+
+| Environment Variable | Description |
+|----------------------|-------------|
+| `HUAWEICLOUD_AGENCY_NAME` | Name of the agency to assume in the target account |
+| `HUAWEICLOUD_ASSUME_DOMAIN_ID` | Domain ID of the target (delegating) account |
+| `HUAWEICLOUD_ASSUME_DOMAIN_NAME` | Domain name of the target account (alternative to the domain ID) |
+
+---
+
+## Verifying Authentication
+
+To confirm that Prowler can reach the account, run a scan against a single region the account can reach:
+
+```bash
+# China account
+prowler huaweicloud --region cn-north-4
+
+# International account
+prowler huaweicloud --region ap-southeast-1
+
+# Huawei Cloud Europe account
+prowler huaweicloud --region eu-west-101
+```
+
+To scan the account's entire cloud instead, use the `--cloud` selector:
+
+```bash
+prowler huaweicloud --cloud europe
+```
+
+A successful run reports findings for the discovered resources. A failed run displays an error message indicating the credential or connectivity issue.
+
+---
+
+## CI/CD Integration
+
+For automated pipelines, set the credentials as secret environment variables:
+
+**GitHub Actions:**
+
+```yaml
+env:
+ HUAWEICLOUD_ACCESS_KEY_ID: ${{ secrets.HUAWEICLOUD_ACCESS_KEY_ID }}
+ HUAWEICLOUD_SECRET_ACCESS_KEY: ${{ secrets.HUAWEICLOUD_SECRET_ACCESS_KEY }}
+
+steps:
+ - name: Run Prowler
+ run: prowler huaweicloud
+```
+
+**GitLab CI:**
+
+```yaml
+variables:
+ HUAWEICLOUD_ACCESS_KEY_ID: $HUAWEICLOUD_ACCESS_KEY_ID
+ HUAWEICLOUD_SECRET_ACCESS_KEY: $HUAWEICLOUD_SECRET_ACCESS_KEY
+
+prowler_scan:
+ script:
+ - prowler huaweicloud
+```
diff --git a/docs/user-guide/providers/huaweicloud/getting-started-huaweicloud.mdx b/docs/user-guide/providers/huaweicloud/getting-started-huaweicloud.mdx
new file mode 100644
index 0000000000..f6ccd1024c
--- /dev/null
+++ b/docs/user-guide/providers/huaweicloud/getting-started-huaweicloud.mdx
@@ -0,0 +1,177 @@
+---
+title: 'Getting Started With Huawei Cloud on Prowler'
+---
+
+import { VersionBadge } from "/snippets/version-badge.mdx"
+
+
+
+Prowler for Huawei Cloud scans your Huawei Cloud account for security misconfigurations across compute, storage, networking, identity, encryption, database, and logging services.
+
+
+Huawei Cloud support in Prowler is community-maintained. For commercial support or to request additional service coverage, [contact us](https://prowler.com/contact).
+
+
+## Prerequisites
+
+Set up authentication for Huawei Cloud with the [Huawei Cloud Authentication](/user-guide/providers/huaweicloud/authentication) guide before starting:
+
+- Create an Access Key ID and Secret Access Key (AK/SK) for an IAM user with read-only permissions.
+- Prowler reads the credentials exclusively from environment variables, so secrets are never passed on the command line.
+
+## Prowler CLI
+
+### Run Prowler for Huawei Cloud
+
+Once authenticated, export the credentials as environment variables and run Prowler for Huawei Cloud. Environment variables keep secrets out of shell history and process listings:
+
+```bash
+export HUAWEICLOUD_ACCESS_KEY_ID="your-access-key-id"
+export HUAWEICLOUD_SECRET_ACCESS_KEY="your-secret-access-key"
+prowler huaweicloud
+```
+
+### Run Specific Checks
+
+```bash
+prowler huaweicloud --checks obs_bucket_public_access iam_user_mfa_enabled
+```
+
+### Run a Specific Service
+
+```bash
+prowler huaweicloud --services iam
+```
+
+## Regions and Clouds
+
+Huawei Cloud operates as three separate clouds, and every account belongs to exactly one of them:
+
+- **International** — served from the `.com` endpoints (for example `myhuaweicloud.com`).
+- **China** — also served from the `.com` endpoints, on the China regions (`cn-*`).
+- **Huawei Cloud Europe** — served from the `.eu` endpoints (for example `myhuaweicloud.eu`).
+
+The cloud is a property of the region: each region ID maps to exactly one endpoint domain. Prowler selects the correct endpoint automatically from the region, so no endpoint configuration is required.
+
+### Region Selection Precedence
+
+Prowler resolves the regions to scan from the first source that is set, in this order:
+
+1. **`--region` flag** (aliases `--filter-region`, `-f`) — one or more explicit region IDs.
+2. **`HUAWEICLOUD_REGION`** (or `HW_REGION`) environment variable — one or more region IDs, separated by spaces or commas.
+3. **`--cloud` selector** (or `HUAWEICLOUD_CLOUD` / `HW_CLOUD`) — expands to every region of the selected cloud.
+4. **Default** — when none is set, Prowler falls back to its built-in region list.
+
+A more specific source always wins: `--region` overrides `HUAWEICLOUD_REGION`, which overrides `--cloud`.
+
+### Select Specific Regions
+
+To scan a defined set of regions, pass the region IDs to `--region` or set `HUAWEICLOUD_REGION`. Accounts outside China must select a region they can reach — for example `eu-west-101` for Huawei Cloud Europe or `ap-southeast-1` for International.
+
+```bash
+# Flag (one or more regions)
+prowler huaweicloud --region eu-west-101 ap-southeast-1
+
+# Environment variable (space- or comma-separated)
+export HUAWEICLOUD_REGION="ap-southeast-1, ap-southeast-2"
+prowler huaweicloud
+```
+
+### Scan an Entire Cloud
+
+To scan every region of an account's cloud without listing regions, use the `--cloud` selector or the `HUAWEICLOUD_CLOUD` environment variable. Prowler expands it to that cloud's regions and selects the matching endpoint automatically:
+
+```bash
+# Scan all Huawei Cloud Europe regions (.eu endpoints)
+prowler huaweicloud --cloud europe
+
+# Scan all International regions (.com endpoints)
+prowler huaweicloud --cloud international
+
+# Scan all China regions (.com endpoints)
+prowler huaweicloud --cloud china
+```
+
+The `--cloud` flag accepts three values: `international`, `europe`, and `china`. The `HUAWEICLOUD_CLOUD` (or `HW_CLOUD`) environment variable additionally accepts the short aliases `intl`/`com` (International), `eu` (Europe), and `cn` (China).
+
+
+A single set of credentials belongs to one cloud, so `--cloud` selects which cloud to scan — it cannot authenticate against both `.com` and `.eu` at once. To scan accounts on different clouds, run Prowler once per account with that account's credentials.
+
+
+### How Prowler Handles Regions and Endpoints
+
+Prowler manages several Huawei Cloud specifics automatically during a scan:
+
+- **Endpoint selection:** The endpoint domain (`.eu` or `.com`) is derived from each region, so every service targets the right cloud. This corrects services whose bundled metadata still points Europe regions at `.com`.
+- **Project resolution:** The per-region project ID is resolved automatically, so multi-region scans work without any project configuration.
+- **Credential validation:** Credentials are validated against a region in the account's cloud that exposes IAM, so validation succeeds even when the requested regions do not all offer IAM.
+- **Unsupported regions:** Any region a given service does not offer is skipped and logged, so scanning an entire cloud never fails on regions where a service is unavailable.
+
+### Supported Regions
+
+Prowler recognizes the following region IDs, grouped by cloud.
+
+**International (`.com`)**
+
+| Region ID | Location |
+|-----------|----------|
+| `ae-ad-1` | UAE (Abu Dhabi) |
+| `af-north-1` | Egypt (Cairo) |
+| `af-south-1` | South Africa |
+| `ap-southeast-1` | Hong Kong |
+| `ap-southeast-2` | Singapore |
+| `ap-southeast-3` | Thailand |
+| `ap-southeast-4` | Malaysia |
+| `ap-southeast-5` | Indonesia (Jakarta) |
+| `eu-west-0` | Ireland |
+| `la-north-2` | Mexico |
+| `la-south-2` | Chile (Santiago) |
+| `me-east-1` | UAE (Dubai) |
+| `my-kualalumpur-1` | Malaysia (Kuala Lumpur) |
+| `na-mexico-1` | Mexico (Mexico City) |
+| `ru-moscow-1` | Russia (Moscow-1) |
+| `sa-brazil-1` | Brazil |
+| `tr-west-1` | Türkiye (Istanbul) |
+
+**Huawei Cloud Europe (`.eu`)**
+
+| Region ID | Location |
+|-----------|----------|
+| `eu-west-101` | Ireland (Dublin) |
+
+**China (`.com`)**
+
+| Region ID | Location |
+|-----------|----------|
+| `cn-north-1` | China (Beijing-1) |
+| `cn-north-2` | China (Beijing-2) |
+| `cn-north-4` | China (Beijing-4) |
+| `cn-north-9` | China (Ulanqab) |
+| `cn-north-11` | China (Ulanqab-11) |
+| `cn-north-12` | China (Ulanqab-12) |
+| `cn-east-2` | China (Shanghai-2) |
+| `cn-east-3` | China (Shanghai-1) |
+| `cn-east-4` | China (Shanghai-4) |
+| `cn-east-5` | China (Shanghai-5) |
+| `cn-south-1` | China (Guangzhou) |
+| `cn-south-2` | China (Guangzhou-2) |
+| `cn-south-4` | China (Guangzhou-4) |
+| `cn-southwest-2` | China (Guiyang) |
+| `cn-southwest-3` | China (Guiyang-3) |
+
+## Available Services
+
+Prowler for Huawei Cloud currently supports the following services:
+
+| Service | Description |
+|---------|-------------|
+| `cts` | Cloud Trace Service trackers that record account and API activity for audit logging |
+| `ecs` | Elastic Cloud Server compute instances and their key pair, public IP, and security group configuration |
+| `elb` | Elastic Load Balance load balancers and their public exposure |
+| `evs` | Elastic Volume Service block volumes and their encryption settings |
+| `iam` | Identity and Access Management users, MFA devices, password policy, and account operation protection |
+| `kms` | Key Management Service keys, their state, and rotation configuration |
+| `obs` | Object Storage Service buckets and their public-access configuration |
+| `rds` | Relational Database Service instances and their public access, backup, and disk-encryption settings |
+| `vpc` | Virtual Private Cloud security groups and their ingress rules |
+| `waf` | Web Application Firewall instances and their status |
diff --git a/docs/user-guide/tutorials/prowler-app-jira-integration.mdx b/docs/user-guide/tutorials/prowler-app-jira-integration.mdx
index 83554ee8c0..3621fd514c 100644
--- a/docs/user-guide/tutorials/prowler-app-jira-integration.mdx
+++ b/docs/user-guide/tutorials/prowler-app-jira-integration.mdx
@@ -9,11 +9,11 @@ import { AppliesTo } from "/snippets/applies-to.mdx"
-Prowler Cloud enables automatic export of security findings to Jira, providing seamless integration with Atlassian's work item tracking and project management platform. This comprehensive guide demonstrates how to configure and manage Jira integrations to streamline security incident management and enhance team collaboration across security workflows.
+Prowler Cloud enables automatic export of security Findings to Jira, providing seamless integration with Atlassian's work item tracking and project management platform. This comprehensive guide demonstrates how to configure and manage Jira integrations to streamline security incident management and enhance team collaboration across security workflows.
Integrating Prowler Cloud with Jira provides:
-* **Streamlined management:** Convert security findings directly into actionable Jira work items
+* **Streamlined management:** Convert security Findings directly into actionable Jira work items
* **Enhanced team collaboration:** Leverage existing project management workflows for security remediation
* **Automated ticket creation:** Reduce manual effort in tracking and assigning security work items
@@ -21,8 +21,18 @@ Integrating Prowler Cloud with Jira provides:
When enabled and configured:
-1. Security findings can be manually sent to Jira from the Findings table.
-2. Each finding creates a Jira work item with all the check's metadata, including guidance on how to remediate it.
+1. Select one or more complete Finding Groups, or expand a Finding Group and select multiple Findings, from the Findings table.
+2. Send the selection to Jira in one action.
+3. Choose how Jira issues are created:
+ * **Grouped issue:** Create one Jira issue that contains all selected Findings from the Finding Group.
+ * **Separate issues:** Create one Jira issue for each selected Finding. Each Finding represents one affected resource.
+4. Review the Finding Group summary and affected-resource details in Jira, then use the link at the bottom of the issue to open the complete Finding Group in Prowler Cloud.
+
+## Prerequisites
+
+
+
+Configuring and using the Jira integration requires the **Manage Integrations** permission. The Jira integration is tenant-wide, so it does not require **Unlimited Visibility** or any specific Provider Group. Findings sent to Jira are still limited to the providers the role can access.
## Configuration
@@ -46,16 +56,68 @@ To generate a Jira API token, visit: https://id.atlassian.com/manage-profile/sec
-Once configured successfully, the integration is ready to send findings to Jira.
+Once configured successfully, the integration is ready to send Findings to Jira.
## Sending Findings to Jira
-### Manual Export
+Prowler Cloud can send a complete Finding Group or a selection of Findings within a group to Jira in one action.
-To manually send individual findings to Jira:
+### Sending a Complete Finding Group
+
+
+
+To send every Finding in a Finding Group:
+
+1. Navigate to **Findings** in Prowler Cloud.
+2. Select one or more Finding Groups.
+3. Open the bulk actions menu and click **Send Finding Group to Jira**.
+
+ 
+
+4. Select the Jira project and issue type.
+5. Choose an issue creation mode:
+ * **Create one Jira issue for all selected Findings in this Finding Group:** Keeps the complete Finding Group in one Jira issue.
+ * **Create separate Jira issues:** Creates one Jira issue per selected Finding so that each affected resource can be tracked independently.
+6. Click **Send to Jira**.
+
+ 
+
+### Sending Multiple Findings from a Group
+
+
+
+To send only specific affected resources:
+
+1. Expand a Finding Group.
+2. Select the Findings to send. Each Finding represents one affected resource.
+3. Open the bulk actions menu and click **Send Findings to Jira**.
+4. Select the Jira project, issue type, and grouped or separate issue creation mode.
+5. Click **Send to Jira**.
+
+ 
+
+### Reviewing the Jira Issue
+
+A grouped Jira issue starts with Finding Group summary information. This section identifies the check and provides context such as the check title and ID, severity, status, provider, service, number of affected failing resources, last-seen time, failure duration, and risk.
+
+
+
+The affected-resources table lists the selected Findings included in the Jira issue. Each row represents an affected resource and includes the information needed to identify and triage it, such as resource and provider identifiers, provider, service, status, severity, region, last-seen time, failure duration, and triage status.
+
+
+
+At the bottom of the affected-resources table, click **View this Finding Group in Prowler Cloud** to open the complete Finding Group in Prowler Cloud. The link opens the group, not only the Findings included in the Jira issue.
+
+
+
+### Sending One Finding
+
+
+
+To manually send individual Findings to Jira:
1. Navigate to the **Findings** section in Prowler Cloud
-2. Select one finding you want to export
+2. Select one Finding you want to export
3. Click the action button on the table row and select **Send to Jira**
4. Select the Jira integration and project
5. Click **Send to Jira**
@@ -69,8 +131,8 @@ Monitor and manage your Jira integrations through the management interface:
1. Review configured integrations in the integrations dashboard
2. Each integration displays:
- - **Connection Status:** Connected or Disconnected indicator
- - **Instance Information:** Jira domain and last checked timestamp
+ * **Connection Status:** Connected or Disconnected indicator
+ * **Instance Information:** Jira domain and last checked timestamp
### Actions
@@ -89,7 +151,7 @@ Each Jira integration provides management actions through dedicated buttons:
Certain Jira issue types (such as Epic) may require mandatory custom fields that Prowler does not currently populate when creating work items. If a selected issue type enforces required fields beyond the standard set (e.g., "Team", "Epic Name"), the work item creation will fail.
-To avoid this, select an issue type that does not require additional custom fields — **Task**, **Bug**, or **Story** typically work without restrictions. If unsure which issue types are available for a project, Prowler automatically fetches and displays them in the "Issue Type" selector when sending a finding.
+To avoid this, select an issue type that does not require additional custom fields — **Task**, **Bug**, or **Story** typically work without restrictions. If unsure which issue types are available for a project, Prowler automatically fetches and displays them in the "Issue Type" selector when sending a Finding.
Support for custom field mapping is planned for a future release.
@@ -99,9 +161,9 @@ Support for custom field mapping is planned for a future release.
### Connection test fails
-- Verify Jira instance domain is correct and accessible
-- Confirm API token or credentials are valid
-- Ensure API access is enabled in Jira settings and the needed scopes are granted
+* Verify Jira instance domain is correct and accessible
+* Confirm API token or credentials are valid
+* Ensure API access is enabled in Jira settings and the needed scopes are granted
### Check task status (API)
@@ -114,7 +176,7 @@ Replace `http://localhost:8080` with the base URL where your Prowler API is acce
1) Get an access token (replace email and password):
-```
+```bash
curl --location 'http://localhost:8080/api/v1/tokens' \
--header 'Content-Type: application/vnd.api+json' \
--header 'Accept: application/vnd.api+json' \
@@ -131,7 +193,7 @@ curl --location 'http://localhost:8080/api/v1/tokens' \
2) List tasks filtered by the Jira task (`integration-jira`) using the access token:
-```
+```bash
curl --location --globoff 'http://localhost:8080/api/v1/tasks?filter[name]=integration-jira' \
--header 'Accept: application/vnd.api+json' \
--header 'Authorization: Bearer ACCESS_TOKEN' | jq
@@ -144,7 +206,7 @@ If you don't have `jq` installed, run the command without `| jq`.
3) Share the output so we can help. A typical result will look like:
-```
+```json
{
"links": {
"first": "https://api.dev.prowler.com/api/v1/tasks?page%5Bnumber%5D=1",
@@ -213,5 +275,5 @@ If you don't have `jq` installed, run the command without `| jq`.
How to read it:
-- "created_count": number of Jira issues successfully created.
-- "failed_count": number of Jira issues that could not be created. If `failed_count > 0` or the issue does not appear in Jira, please contact us so we can assist while detailed logs are not available through the UI.
+* "created_count": number of Jira issues successfully created.
+* "failed_count": number of Jira issues that could not be created. If `failed_count > 0` or the issue does not appear in Jira, please contact us so we can assist while detailed logs are not available through the UI.
diff --git a/docs/user-guide/tutorials/prowler-app-rbac.mdx b/docs/user-guide/tutorials/prowler-app-rbac.mdx
index 4533e0423c..2598c6d9ec 100644
--- a/docs/user-guide/tutorials/prowler-app-rbac.mdx
+++ b/docs/user-guide/tutorials/prowler-app-rbac.mdx
@@ -128,7 +128,7 @@ To resend the invitation to the user, it is necessary to explicitly **delete the
## Managing Groups and Roles
-Roles combine administrative permissions with provider visibility. Administrative permissions control the actions a role can perform. Provider Groups and Unlimited Visibility control the providers, resources, findings, scans, and compliance results the role can access.
+Roles combine administrative permissions with provider visibility. Administrative permissions control the actions a role can perform. Provider Groups and Unlimited Visibility control the providers, resources, findings, scans, compliance results, and integrations the role can access.
**Only users that have the _Manage Account_ or _admin_ permission can access this section.**
@@ -142,6 +142,12 @@ New roles have no provider visibility by default. Assign at least one Provider G
**Unlimited Visibility** grants organization-wide visibility across every provider, regardless of the Provider Groups assigned to the role. It does not grant administrative permissions.
+#### Integration Visibility
+
+
+
+Integrations follow the visibility of the providers attached to them: a role can see an integration when it can access at least one of its providers, and only the providers visible to that role are listed on the integration. Editing or deleting an integration attached to providers outside the visibility of the role is not allowed. Integrations that are not attached to any provider, such as Jira, are tenant-wide and remain available to every role with the **Manage Integrations** permission.
+
#### Creating a Provider Group
Follow these steps to create a provider group in your account:
diff --git a/docs/user-guide/tutorials/prowler-for-msps-billing.mdx b/docs/user-guide/tutorials/prowler-for-msps-billing.mdx
new file mode 100644
index 0000000000..97cbd68a0f
--- /dev/null
+++ b/docs/user-guide/tutorials/prowler-for-msps-billing.mdx
@@ -0,0 +1,62 @@
+---
+title: "Billing and Customer Plans in Prowler for MSPs and MSSPs"
+sidebarTitle: "Billing and Plans"
+---
+
+Each customer carries its own billing plan, set when the customer is created and changed later from the **Customers** page. This page covers both, and where the resulting revenue is reported.
+
+## Permissions
+
+Managing customer plans requires a role with **Manage billing**, held today by both **Superadmin** and **Organization Admin**. See [Managing Your Team](/user-guide/tutorials/prowler-for-msps-team).
+
+## Customer Plans
+
+Plans are chosen in the **Set Their Billing Plan** step of the Add Customer wizard. A customer starts on a trial or on one of the paid plans, billed monthly or annually.
+
+Each plan card in the wizard shows its own price, included usage and overage rate. For current pricing, see [prowler.com/pricing](https://prowler.com/pricing).
+
+### Provider Accounts on the Annual Plan
+
+The annual plan is paid upfront for a fixed number of cloud provider accounts, between **1 and 20**. You set that count when you pick the plan. The monthly plan does not require an upfront provider account count.
+
+## Change a Customer's Plan
+
+Open the actions menu on a customer's row and choose **Change plan**.
+
+
+**Plan changes are one way: trial to paid.** The action is only offered while a customer is on trial or its trial has expired. Once a customer holds a paid subscription, **Change plan** no longer appears on the row, and the trial is never a valid target.
+
+
+The **Change Plan** dialog opens on **Choose your plan**, with the same **Monthly** and **Annual** toggle used when the customer was created.
+
+
+
+Select a plan and confirm with **Change Plan**. Choosing the annual plan also asks for the upfront provider account count. The change is submitted to Prowler Cloud and applied asynchronously; the customer's row updates once it lands. If it fails, a message explains why:
+
+| Message | Cause |
+|---|---|
+| Cloud accounts count is required | The annual plan was selected without a provider account count. |
+| Cloud accounts count out of range | The count is outside 1–20. |
+| Company name is required | The customer record has no usable company name. |
+| Customer not found | The customer no longer exists or is not linked to a tenant. |
+
+## Revenue Reporting
+
+Billing figures surface in two places.
+
+**On the Customers page**, stat cards above the table summarize **Billing active** — how many customers hold an active subscription — and **Total MTD** per currency, with a percentage change against last month. The cards appear once a customer has billing activity.
+
+
+
+**On the Dashboard**, the **Billing Overview** card reports monthly expenses against the previous month and splits revenue for the period into annual, monthly and overage, giving the same picture across every customer.
+
+## Next Steps
+
+
+
+ Add customers and open their Prowler Cloud tenants.
+
+
+ Lifecycle, settings, Partner Code and closing your organization.
+
+
diff --git a/docs/user-guide/tutorials/prowler-for-msps-branding.mdx b/docs/user-guide/tutorials/prowler-for-msps-branding.mdx
new file mode 100644
index 0000000000..6a0ef1528e
--- /dev/null
+++ b/docs/user-guide/tutorials/prowler-for-msps-branding.mdx
@@ -0,0 +1,66 @@
+---
+title: "Customizing Your Branding in Prowler for MSPs and MSSPs"
+sidebarTitle: "Branding"
+---
+
+Upload your company logo to have it displayed alongside Prowler branding in the console. Branding is managed from **Settings → Branding** and requires a role with **Manage settings**.
+
+## Upload a Logo
+
+Open **Settings → Branding**, click **Upload Logo**, and pick your file. The logo replaces the placeholder immediately and a confirmation appears.
+
+
+
+### Logo Requirements
+
+| Requirement | Value |
+|---|---|
+| **Formats** | PNG or SVG |
+| **Maximum file size** | 512 KB |
+| **Dimensions** | 200 × 60 pixels — a hard limit for PNG, not checked for SVG |
+
+
+Two limits on this screen are looser than what the server accepts:
+
+* The upload dialog accepts files up to 1 MB, but anything above **512 KB** is rejected.
+* The page describes 200 × 60 pixels as a recommended size. For PNG it is a **maximum**: a larger PNG is rejected with *"PNG dimensions must not exceed 200×60 px."* SVG is exempt from the dimension check.
+
+Keep PNG logos within both limits to avoid an upload that appears to start and then fails.
+
+
+A wide, horizontal logo with a transparent background renders best. SVG stays crisp at every size, is not subject to the dimension limit, and is the better choice where you have it.
+
+
+Uploaded SVG files are sanitized on the server. Scripts, external references and other active content are stripped before the file is stored.
+
+
+## Replace or Remove a Logo
+
+Uploading a new file replaces the current one. **Remove Logo** deletes it and returns the console to the default Prowler branding. Both take effect immediately for everyone on your team.
+
+## Logo Placement Preview
+
+Below the upload controls, **Logo Placement Preview** renders your logo underneath the Prowler wordmark, showing how the two are intended to sit together in a Prowler Cloud organization. With no logo uploaded, the slot shows a *Your Logo* placeholder.
+
+
+The preview is rendered locally in the console. Uploading a logo does not currently push it to Prowler Cloud.
+
+
+## Where Your Logo Appears
+
+Your logo is shown alongside Prowler branding within the console for your team.
+
+
+**Branding does not extend to Prowler Cloud.** Customers signing in at [cloud.prowler.com](https://cloud.prowler.com) see the standard Prowler interface. White-labeling Prowler Cloud is not supported.
+
+
+## Next Steps
+
+
+
+ Lifecycle, settings, Partner Code and closing your organization.
+
+
+ Invite team members and assign roles.
+
+
diff --git a/docs/user-guide/tutorials/prowler-for-msps-customers.mdx b/docs/user-guide/tutorials/prowler-for-msps-customers.mdx
new file mode 100644
index 0000000000..6d500ec292
--- /dev/null
+++ b/docs/user-guide/tutorials/prowler-for-msps-customers.mdx
@@ -0,0 +1,104 @@
+---
+title: "Onboarding Customers and Accessing Their Tenants"
+sidebarTitle: "Onboarding Customers"
+---
+
+Adding a customer in Prowler for MSPs and MSSPs provisions a Prowler Cloud tenant for that organization and links it to yours. From then on you can open that tenant from the console and work inside it on the customer's behalf.
+
+## Add a Customer
+
+Select **Customers** in the sidebar, then click **Add Customer** to open a three-step wizard.
+
+
+Adding a customer is restricted to the Superadmin role. Organization Admins manage existing customers but do not see the **Add Customer** button.
+
+
+
+
+ Enter the **Customer Business Name** and confirm the **Region**. The name must be unique within your partner organization; a duplicate is rejected inline. Click **Next**.
+
+ 
+
+
+
+ Under **Choose your plan**, switch between **Monthly** and **Annual** and pick the plan the customer starts on. Click **Create Customer**. See [Billing and Customer Plans](/user-guide/tutorials/prowler-for-msps-billing) for what the annual plan asks for.
+
+ 
+
+
+
+ A banner confirms the outcome — created on trial, or subscribed to a paid plan — and the tenant is created and linked to your organization. Click **Go To Organization** to head straight there and start connecting cloud providers, or **Close** to return to the customer list.
+
+ 
+
+
+
+Provisioning is asynchronous. The customer's row shows **Provisioning** until the tenant is ready, then switches to **Active**.
+
+## The Customers View
+
+**My Customers** lists every customer you can reach.
+
+
+
+Each row carries:
+
+| Column | What it shows |
+|---|---|
+| **Customer Business Name** | The customer's name |
+| **Providers** | Icons for each cloud provider connected in their tenant |
+| **Cloud Accounts** | Number of provider accounts under scan |
+| **Resources** | Resources discovered by the latest scan |
+| **Failed Findings** | Failed findings from the latest scan |
+| **Billing Type** | The customer's current plan, shown as **Trial**, **Pro Monthly** or **Pro Annual** |
+| **MTD** | Month-to-date spend |
+| **Last Month Expenses** | Previous month's total |
+| **Status** | **Active**, **Provisioning** or **Inactive** |
+| **Last scan completed** | When the most recent scan finished |
+
+A customer shows as **Provisioning** while its tenant is being created, and **Inactive** once it has been marked for removal.
+
+Above the table, search by name and filter by provider or status. The download button at the top right of the table exports the list.
+
+## Open a Customer's Prowler Cloud Tenant
+
+Open the actions menu at the end of a customer's row and choose **Access Organization**. You are redirected into that customer's tenant in Prowler Cloud, signed in as yourself acting on their behalf.
+
+While you are in the tenant you see what a customer administrator sees, and every action is recorded in the Prowler Cloud audit log against both your identity and the customer you are acting for.
+
+Opening a tenant requires a role with **Access tenants**. The action fails with a clear message if you lack permission, if the customer no longer exists, or if the tenant is not ready.
+
+
+
+**Change plan** only appears while the customer is on trial or its trial has expired. See [Billing and Customer Plans](/user-guide/tutorials/prowler-for-msps-billing).
+
+## Edit a Customer
+
+Choose **Edit** from the row actions to open the **Edit customer** panel and rename the customer. The new name must still be unique within the partner organization.
+
+## Link an Existing Customer with Your Partner Code
+
+Each approved partner organization carries a **Partner Code**, shown on **Settings → Profile** with the helper text *"Share this code with customers to link their accounts."* A customer who already runs Prowler Cloud can use that code to request a link to you, rather than having you provision a fresh tenant.
+
+
+The customer-side flow that consumes the Partner Code is rolling out progressively in Prowler Cloud. Confirm availability with your Prowler contact before sharing the code.
+
+
+## Customer Self-Access
+
+Your customers keep signing in to [cloud.prowler.com](https://cloud.prowler.com) with their own users. Your access is additive — it neither replaces nor restricts theirs.
+
+## Remove a Customer
+
+Removing a customer detaches it from your partner organization and moves it to **Inactive**. It does not delete the customer's data in Prowler Cloud; contact Prowler to arrange the offboarding of the tenant itself.
+
+## Next Steps
+
+
+
+ Customer plans and revenue reporting.
+
+
+ Invite team members and assign roles.
+
+
diff --git a/docs/user-guide/tutorials/prowler-for-msps-organization.mdx b/docs/user-guide/tutorials/prowler-for-msps-organization.mdx
new file mode 100644
index 0000000000..9c3f27f43e
--- /dev/null
+++ b/docs/user-guide/tutorials/prowler-for-msps-organization.mdx
@@ -0,0 +1,90 @@
+---
+title: "Managing Your Partner Organization"
+sidebarTitle: "Your Partner Organization"
+---
+
+Your partner organization is the top-level container in Prowler for MSPs and MSSPs. It holds your team, your branding, your Partner Code and every customer whose Prowler Cloud tenant you operate.
+
+## Lifecycle
+
+A partner organization moves through four states:
+
+| State | Meaning |
+|---|---|
+| **Pending email verification** | The first administrator has signed up but has not yet clicked the verification link. |
+| **Pending approval** | Email verified. Prowler is reviewing the application. |
+| **Active** | Approved. The organization can sign in, invite team members and onboard customers. |
+| **Rejected** | Prowler reviewed and declined the application. The account cannot sign in. |
+
+A rejection email carries the reason, categorized as **Incomplete documentation**, **Not eligible**, **Duplicate** or **Other**.
+
+## Settings
+
+Open **Settings** from the sidebar. The page has three tabs — **Profile**, **Branding** and **Security** — though what you can do in them depends on your role.
+
+Every signed-in user can view the settings and change their own password. Editing the organization itself requires a role with **Manage settings**; without it, only the **Security** tab is available.
+
+### Profile
+
+The **Profile** tab shows the **Partner Information** card: your organization name, its current status, the date it joined, the Partner Code and an editable **Company Name**.
+
+
+
+* **Partner Code** — a read-only, Prowler-issued identifier in the form `PRW-00000`. The helper text reads *"Share this code with customers to link their accounts."* Copy it with the button at the end of the row.
+* **Company Name** — the display name used in the console, in invitations and in outbound email. Edit it and click **Save Changes**.
+
+### Branding
+
+Upload your logo. See [Customizing Your Branding](/user-guide/tutorials/prowler-for-msps-branding).
+
+### Security
+
+Change your own password. Roles with **Manage settings** — Superadmin today — also reach the Danger zone described below. An Organization Admin sees the password form only.
+
+## Customer Capacity
+
+Each partner organization has a cap on how many customers it can hold at once. The default is **50**. To raise it, contact Prowler.
+
+## Closing Your Organization
+
+Deleting a partner organization is a request, not an immediate action.
+
+
+
+ Go to **Settings → Security**. The Danger zone and its **Delete Partner** action require a role with **Manage settings**.
+
+
+
+ Optionally give a reason in **Reason for deletion**, then type `DELETE` in the confirmation field to enable the button and submit.
+
+
+
+ Filing the request notifies the Prowler team and sends a confirmation to the requester. The Danger zone then reports that a deletion request is already pending review. You and your team keep full access while it is pending.
+
+
+
+ The Prowler team coordinates the offboarding from there, including what happens to each customer tenant and when your organization is closed. Closing removes the partner organization, its team memberships and its branding assets, and invalidates every session.
+
+
+
+
+Closing a partner organization does not delete customer data in Prowler Cloud on its own. The Prowler team confirms the handling of each customer tenant as part of the offboarding.
+
+
+## Ownership
+
+One user is the **owner** of the partner organization — by default, whoever signed up. Ownership can be transferred to another team member, who keeps the Superadmin role. The owner cannot be removed from the team while they hold ownership.
+
+## Next Steps
+
+
+
+ Invite team members and assign roles.
+
+
+ Upload your logo.
+
+
+ Add customers and open their Prowler Cloud tenants.
+
+
diff --git a/docs/user-guide/tutorials/prowler-for-msps-sign-up.mdx b/docs/user-guide/tutorials/prowler-for-msps-sign-up.mdx
new file mode 100644
index 0000000000..b3f2d627f9
--- /dev/null
+++ b/docs/user-guide/tutorials/prowler-for-msps-sign-up.mdx
@@ -0,0 +1,88 @@
+---
+title: "Sign Up and Sign In to Prowler for MSPs and MSSPs"
+sidebarTitle: "Sign Up and Sign In"
+---
+
+Sign-up for Prowler for MSPs and MSSPs is self-service, but activation requires approval from the Prowler team. The first administrator registers the partner organization; every other team member joins by invitation.
+
+## Sign Up
+
+
+
+ Go to [partners.prowler.com/sign-up](https://partners.prowler.com/sign-up), fill in **Full name**, **Company name**, **Email**, **Password** and **Confirm password**, then click **Create account**.
+
+ 
+
+
+
+ Prowler sends a verification email containing a one-time link valid for **24 hours**. Click it to confirm the address; your partner organization then moves to **Pending approval**.
+
+ If the link expires, request a new one at [partners.prowler.com/resend-verification](https://partners.prowler.com/resend-verification). Issuing a fresh link invalidates any earlier unused link for the same account.
+
+
+
+ Prowler reviews every new application. You receive an email when the organization is approved — its status becomes **Active** — or when it is rejected, along with the reason.
+
+
+
+ Once approved, sign in at [partners.prowler.com](https://partners.prowler.com) with the email and password you chose. You land on the Dashboard.
+
+
+
+## Password Requirements
+
+Every password in the console — at sign-up, when accepting an invitation, and on reset — must satisfy all of the following:
+
+| Requirement | Rule |
+|---|---|
+| **Length** | At least 12 characters |
+| **Uppercase** | At least 1 uppercase letter |
+| **Lowercase** | At least 1 lowercase letter |
+| **Number** | At least 1 digit |
+| **Special character** | At least 1 special character |
+| **Not common** | Rejected if it appears on the common-password list |
+| **Not similar to your details** | Rejected if too close to your name or email |
+
+## Sign In
+
+Sign in at [partners.prowler.com](https://partners.prowler.com) with your email and password, then click **Login**. Select **Remember me** to keep the session alive longer between visits.
+
+
+
+Sign-in fails while the partner organization is not yet active:
+
+| Message | What it means |
+|---|---|
+| Invalid email or password | The credentials do not match an account. |
+| Please verify your email before signing in | Email verification is still outstanding. Open the verification email or request a fresh link. |
+| Your partner application is still under review | Prowler has not approved the application yet. |
+| Your partner application was not approved | The application was rejected. The account cannot sign in. |
+
+## Reset a Forgotten Password
+
+Click **Forgot Password?** on the sign-in screen and enter your email. Prowler sends a reset link valid for **15 minutes**. The reset page asks for a new password and a confirmation; on success you return to sign-in.
+
+
+The confirmation banner appears whether or not the email matches an account, so the screen never reveals which addresses are registered. Requesting a new link invalidates any earlier unused one.
+
+
+## Sessions
+
+Access tokens are short-lived — **30 minutes** by default — and refresh automatically while you are active. The refresh window is **24 hours**, extended to **7 days** when you select **Remember me**. Refresh tokens rotate on every refresh and the previous one is revoked.
+
+If a refresh fails — after long inactivity, a revoked token, or a server restart — you return to the sign-in screen with a `RefreshAccessTokenError` notice. Sign in again to continue.
+
+## Sign Out
+
+Open the user avatar in the top-right corner of any page and select **Sign out**. The session is cleared and you return to the sign-in form.
+
+## Next Steps
+
+
+
+ Invite team members and assign roles.
+
+
+ Lifecycle, settings, Partner Code and closing your organization.
+
+
diff --git a/docs/user-guide/tutorials/prowler-for-msps-team.mdx b/docs/user-guide/tutorials/prowler-for-msps-team.mdx
new file mode 100644
index 0000000000..c92d9405d6
--- /dev/null
+++ b/docs/user-guide/tutorials/prowler-for-msps-team.mdx
@@ -0,0 +1,85 @@
+---
+title: "Managing Your Team in Prowler for MSPs and MSSPs"
+sidebarTitle: "Managing Your Team"
+---
+
+Each partner organization has its own team and its own role catalog. Administrators invite team members by email and assign each one a role that governs what they can do.
+
+## Roles
+
+Two roles ship with every partner organization:
+
+* **Superadmin** — full account management. Invites members, adds and manages customers, edits branding and settings, and opens customer tenants.
+* **Organization Admin** — manages customers and billing, and opens customer tenants. Cannot invite members, change organization settings, or add new customers.
+
+Each role is a set of permission flags:
+
+| Permission | What it allows | Superadmin | Organization Admin |
+|---|---|:---:|:---:|
+| **Manage members** | Invite, re-invite, disable, enable and remove team members | ✓ | |
+| **Manage settings** | Edit the organization profile and branding | ✓ | |
+| **Manage billing** | Manage customer plans | ✓ | ✓ |
+| **Manage organizations** | Remove customers and edit their details | ✓ | ✓ |
+| **Access tenants** | Open a customer's Prowler Cloud tenant | ✓ | ✓ |
+
+
+The Prowler Cloud-side permission level for a team member is provisioned automatically as **Manager** and is managed in Prowler Cloud, not here. There is no Cloud role to pick at invitation time.
+
+
+## Invite a Team Member
+
+
+
+ Select **Team** in the sidebar. The entry only appears for roles with **Manage members**.
+
+
+
+ Click **Invite User**, enter the **Email**, pick a **User Role**, then click **Send Invite**. Each role option in the selector carries a one-line description of what it grants.
+
+ 
+
+
+
+ The team table lists **Name**, **Email**, **Role**, **Status** and **User Event**. An invitation is **Pending**, **Accepted**, **Expired** or **Revoked**.
+
+
+
+The invitee receives an email with a one-time link, valid for **7 days**, that opens a public acceptance page. There they set their full name and a password, accept, and are sent to the sign-in screen.
+
+An email address can hold only one pending invitation at a time.
+
+## Re-Invite or Revoke
+
+For a **Pending** or **Expired** invitation, **Re-invite** sends a fresh link and resets the expiry. **Revoke** invalidates the invitation immediately — the recipient can no longer accept it.
+
+To re-issue an invitation that is still pending, use **Re-invite** rather than sending a second one.
+
+## Disable, Enable or Remove a Member
+
+Active members carry a **Disable** action. Disabling revokes access immediately but keeps the row in the table, flagged as disabled, so the audit trail survives.
+
+A disabled member can be:
+
+* **Enabled** — access is restored as it was.
+* **Re-invited** — a fresh invitation brings them back as a new active member. The invite dialog opens pre-filled with their address and its title changes to **Re-invite user**.
+
+
+The organization owner cannot be removed from the team while they hold ownership. Transfer ownership first.
+
+
+## Notes
+
+* The first administrator is created during sign-up and becomes the owner.
+* An email address can hold only one active membership in a given partner organization.
+* Permissions are scoped to one partner organization. A session for one organization carries no permissions in another.
+
+## Next Steps
+
+
+
+ Add customers and open their Prowler Cloud tenants.
+
+
+ Customer plans and revenue reporting.
+
+
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index 408c76d3e1..82495c340b 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -4,6 +4,23 @@ All notable changes to the **Prowler SDK** are documented in this file.
+## [5.36.0] (Prowler v5.36.0)
+
+### 🚀 Added
+
+- `sagemaker_notebook_instance_no_secrets` check for AWS provider, scanning SageMaker notebook instance lifecycle configuration scripts (`OnCreate` and `OnStart`) for hardcoded secrets such as API keys, passwords, tokens, and connection strings [(#11843)](https://github.com/prowler-cloud/prowler/pull/11843)
+
+### 🔄 Changed
+
+- Jira output rendering supports grouped Finding Group issues with caller-provided links and capped or uncapped finding copy [(#12035)](https://github.com/prowler-cloud/prowler/pull/12035)
+
+### 🐞 Fixed
+
+- Fix invalid escape sequence `SyntaxWarning` raised on startup by the S3 bucket name validation regex [(#12041)](https://github.com/prowler-cloud/prowler/pull/12041)
+- Alibaba Cloud SSH and RDP security group checks no longer produce false negatives when allowed rules use capitalized `Policy="Accept"` values [(#12049)](https://github.com/prowler-cloud/prowler/pull/12049)
+
+---
+
## [5.35.0] (Prowler v5.35.0)
### 🚀 Added
diff --git a/prowler/__main__.py b/prowler/__main__.py
index 12f4b24cac..2d77068704 100644
--- a/prowler/__main__.py
+++ b/prowler/__main__.py
@@ -144,6 +144,7 @@ from prowler.providers.e2enetworks.models import E2eNetworksOutputOptions
from prowler.providers.gcp.models import GCPOutputOptions
from prowler.providers.github.models import GithubOutputOptions
from prowler.providers.googleworkspace.models import GoogleWorkspaceOutputOptions
+from prowler.providers.huaweicloud.models import HuaweiCloudOutputOptions
from prowler.providers.iac.models import IACOutputOptions
from prowler.providers.image.exceptions.exceptions import ImageBaseException
from prowler.providers.image.models import ImageOutputOptions
@@ -449,6 +450,10 @@ def prowler():
output_options = LinodeOutputOptions(
args, bulk_checks_metadata, global_provider.identity
)
+ elif provider == "huaweicloud":
+ output_options = HuaweiCloudOutputOptions(
+ args, bulk_checks_metadata, global_provider.identity
+ )
else:
# Dynamic fallback: any external/custom provider
try:
diff --git a/prowler/changelog.d/alibabacloud-security-group-policy-case.fixed.md b/prowler/changelog.d/alibabacloud-security-group-policy-case.fixed.md
deleted file mode 100644
index cef4ff076c..0000000000
--- a/prowler/changelog.d/alibabacloud-security-group-policy-case.fixed.md
+++ /dev/null
@@ -1 +0,0 @@
-Alibaba Cloud SSH and RDP security group checks no longer produce false negatives when allowed rules use capitalized `Policy="Accept"` values
diff --git a/prowler/changelog.d/bucket-validation-syntaxwarning.fixed.md b/prowler/changelog.d/bucket-validation-syntaxwarning.fixed.md
deleted file mode 100644
index ceba09541c..0000000000
--- a/prowler/changelog.d/bucket-validation-syntaxwarning.fixed.md
+++ /dev/null
@@ -1 +0,0 @@
-Fix invalid escape sequence `SyntaxWarning` raised on startup by the S3 bucket name validation regex
diff --git a/prowler/changelog.d/ec2-instance-stopped-older-than-specific-days.added.md b/prowler/changelog.d/ec2-instance-stopped-older-than-specific-days.added.md
new file mode 100644
index 0000000000..dc34a0aa6d
--- /dev/null
+++ b/prowler/changelog.d/ec2-instance-stopped-older-than-specific-days.added.md
@@ -0,0 +1 @@
+`ec2_instance_stopped_older_than_specific_days` check for AWS provider, detecting EC2 instances stopped longer than a configurable number of days (default 30)
diff --git a/prowler/changelog.d/gcp-firewall-multiport.fixed.md b/prowler/changelog.d/gcp-firewall-multiport.fixed.md
new file mode 100644
index 0000000000..d9902a56f5
--- /dev/null
+++ b/prowler/changelog.d/gcp-firewall-multiport.fixed.md
@@ -0,0 +1 @@
+GCP firewall SSH and RDP checks now detect exposed target ports in any position within multi-port rules
diff --git a/prowler/changelog.d/grouped-jira-dispatch.changed.md b/prowler/changelog.d/grouped-jira-dispatch.changed.md
deleted file mode 100644
index 8dd2e43ab5..0000000000
--- a/prowler/changelog.d/grouped-jira-dispatch.changed.md
+++ /dev/null
@@ -1 +0,0 @@
-Jira output rendering supports grouped Finding Group issues with caller-provided links and capped or uncapped finding copy
diff --git a/prowler/changelog.d/huaweicloud-provider.added.md b/prowler/changelog.d/huaweicloud-provider.added.md
new file mode 100644
index 0000000000..8539e016cd
--- /dev/null
+++ b/prowler/changelog.d/huaweicloud-provider.added.md
@@ -0,0 +1 @@
+Support for the Huawei Cloud provider, with CTS, ECS, ELB, EVS, IAM, KMS, OBS, RDS, VPC and WAF services and a CIS 1.0 compliance benchmark
diff --git a/prowler/changelog.d/ocsf-analytic-attacks.added.md b/prowler/changelog.d/ocsf-analytic-attacks.added.md
new file mode 100644
index 0000000000..265ea95671
--- /dev/null
+++ b/prowler/changelog.d/ocsf-analytic-attacks.added.md
@@ -0,0 +1 @@
+OCSF detection finding output now populates `finding_info.analytic` as the Prowler check rule and `finding_info.attacks` as MITRE ATT&CK technique and tactic objects for findings with MITRE-ATTACK compliance metadata
diff --git a/prowler/compliance/huaweicloud/__init__.py b/prowler/compliance/huaweicloud/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/compliance/huaweicloud/cis_1.0_huaweicloud.json b/prowler/compliance/huaweicloud/cis_1.0_huaweicloud.json
new file mode 100644
index 0000000000..8559a06f75
--- /dev/null
+++ b/prowler/compliance/huaweicloud/cis_1.0_huaweicloud.json
@@ -0,0 +1,429 @@
+{
+ "Framework": "CIS",
+ "Name": "CIS Huawei Cloud Foundations Benchmark v1.0.0",
+ "Version": "1.0",
+ "Provider": "HuaweiCloud",
+ "Description": "CIS Huawei Cloud Foundations Benchmark v1.0.0 provides prescriptive guidance for configuring security options for a subset of Huawei Cloud services. It has been developed to help cloud operators establish a secure baseline configuration for their Huawei Cloud environment.",
+ "Requirements": [
+ {
+ "Id": "1.1",
+ "Description": "Ensure IAM password policy requires minimum password length of 14 or greater",
+ "Checks": [
+ "iam_account_password_policy"
+ ],
+ "Attributes": [
+ {
+ "Section": "1 Identity and Access Management",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "Ensure IAM password policy requires a minimum password length of 14 or greater characters.",
+ "RationaleStatement": "Short passwords are easier to crack via brute force attacks. A minimum length of 14 characters significantly increases the keyspace and provides exponentially more security against automated password cracking.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "1. Log on to the Huawei Cloud console. 2. Choose IAM & Security. 3. Click the Password Policy tab. 4. Set Minimum Password Length to 14 or greater. 5. Click OK.",
+ "AuditProcedure": "Run prowler to check the IAM password policy minimum length configuration.",
+ "AdditionalInformation": "",
+ "References": "https://support.huaweicloud.com/intl/en-us/usermanual-iam/iam_01_0060.html"
+ }
+ ]
+ },
+ {
+ "Id": "1.2",
+ "Description": "Ensure IAM password policy requires passwords to expire",
+ "Checks": [
+ "iam_password_policy_expires_passwords"
+ ],
+ "Attributes": [
+ {
+ "Section": "1 Identity and Access Management",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "Ensure IAM password policy requires passwords to expire after a defined period.",
+ "RationaleStatement": "Regular password expiration reduces the risk of compromised credentials being used indefinitely. It forces users to periodically update their passwords, limiting the window of opportunity for attackers.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "1. Log on to the Huawei Cloud console. 2. Choose IAM & Security. 3. Click the Password Policy tab. 4. Set Password Validity Period to a non-zero value (e.g., 90 days). 5. Click OK.",
+ "AuditProcedure": "Run prowler to check if the IAM password policy has a password validity period set.",
+ "AdditionalInformation": "",
+ "References": "https://support.huaweicloud.com/intl/en-us/usermanual-iam/iam_01_0060.html"
+ }
+ ]
+ },
+ {
+ "Id": "1.3",
+ "Description": "Ensure IAM password policy prevents password reuse",
+ "Checks": [
+ "iam_password_policy_reuse_prevention"
+ ],
+ "Attributes": [
+ {
+ "Section": "1 Identity and Access Management",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "Ensure IAM password policy disallows reuse of at least the last 3 passwords.",
+ "RationaleStatement": "Preventing password reuse ensures users cannot cycle through previously used passwords, which reduces the risk of compromised credentials being reused.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "1. Log on to the Huawei Cloud console. 2. Choose IAM & Security. 3. Click the Password Policy tab. 4. Set Number of Recent Passwords Disallowed to 3 or greater. 5. Click OK.",
+ "AuditProcedure": "Run prowler to check if the IAM password policy disallows reuse of at least 3 recent passwords.",
+ "AdditionalInformation": "",
+ "References": "https://support.huaweicloud.com/intl/en-us/usermanual-iam/iam_01_0060.html"
+ }
+ ]
+ },
+ {
+ "Id": "1.4",
+ "Description": "Ensure IAM password policy requires character combination",
+ "Checks": [
+ "iam_password_policy_char_combination"
+ ],
+ "Attributes": [
+ {
+ "Section": "1 Identity and Access Management",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "Ensure IAM password policy requires at least 3 character types (uppercase, lowercase, digits, special characters).",
+ "RationaleStatement": "Requiring multiple character types increases password complexity and makes passwords more resistant to dictionary and brute force attacks.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "1. Log on to the Huawei Cloud console. 2. Choose IAM & Security. 3. Click the Password Policy tab. 4. Set Password Character Combination to 3 or greater. 5. Click OK.",
+ "AuditProcedure": "Run prowler to check if the IAM password policy requires at least 3 character types.",
+ "AdditionalInformation": "",
+ "References": "https://support.huaweicloud.com/intl/en-us/usermanual-iam/iam_01_0060.html"
+ }
+ ]
+ },
+ {
+ "Id": "1.5",
+ "Description": "Ensure IAM password policy enforces minimum password age",
+ "Checks": [
+ "iam_password_policy_minimum_age"
+ ],
+ "Attributes": [
+ {
+ "Section": "1 Identity and Access Management",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "Ensure IAM password policy enforces a minimum password age to prevent users from changing passwords too frequently.",
+ "RationaleStatement": "A minimum password age prevents users from rapidly cycling through passwords to bypass password reuse restrictions.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "1. Log on to the Huawei Cloud console. 2. Choose IAM & Security. 3. Click the Password Policy tab. 4. Set Minimum Password Age to a non-zero value (e.g., 1 day). 5. Click OK.",
+ "AuditProcedure": "Run prowler to check if the IAM password policy enforces a minimum password age.",
+ "AdditionalInformation": "",
+ "References": "https://support.huaweicloud.com/intl/en-us/usermanual-iam/iam_01_0060.html"
+ }
+ ]
+ },
+ {
+ "Id": "1.6",
+ "Description": "Ensure root account has hardware MFA enabled",
+ "Checks": [
+ "iam_root_hardware_mfa_enabled"
+ ],
+ "Attributes": [
+ {
+ "Section": "1 Identity and Access Management",
+ "Profile": "Level 2",
+ "AssessmentStatus": "Automated",
+ "Description": "Ensure the root account has hardware multi-factor authentication (MFA) enabled.",
+ "RationaleStatement": "The root account is the most privileged account in the Huawei Cloud environment. Enabling hardware MFA provides an additional layer of security against unauthorized access.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "1. Log on to the Huawei Cloud console as root. 2. Go to IAM & Security. 3. Click the MFA tab. 4. Enable virtual or hardware MFA for the root account. 5. Follow the setup instructions.",
+ "AuditProcedure": "Run prowler to check if the root account has hardware MFA enabled.",
+ "AdditionalInformation": "",
+ "References": "https://support.huaweicloud.com/intl/en-us/usermanual-iam/iam_01_0012.html"
+ }
+ ]
+ },
+ {
+ "Id": "1.7",
+ "Description": "Ensure all IAM users have MFA enabled",
+ "Checks": [
+ "iam_user_mfa_enabled"
+ ],
+ "Attributes": [
+ {
+ "Section": "1 Identity and Access Management",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "Ensure all IAM users have multi-factor authentication (MFA) enabled.",
+ "RationaleStatement": "MFA provides an additional layer of security against unauthorized access. Without MFA, a compromised password alone is sufficient to gain access.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "1. Log on to the Huawei Cloud console. 2. Go to IAM & Security > Users. 3. For each user, click Enable MFA and follow the setup instructions.",
+ "AuditProcedure": "Run prowler to check if all IAM users have MFA enabled.",
+ "AdditionalInformation": "",
+ "References": "https://support.huaweicloud.com/intl/en-us/usermanual-iam/iam_01_0012.html"
+ }
+ ]
+ },
+ {
+ "Id": "1.8",
+ "Description": "Ensure disabled IAM users are reviewed",
+ "Checks": [
+ "iam_user_disabled"
+ ],
+ "Attributes": [
+ {
+ "Section": "1 Identity and Access Management",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "Ensure disabled IAM users are identified and reviewed for potential removal.",
+ "RationaleStatement": "Disabled user accounts may retain permissions and could be re-enabled by an attacker. Regular review ensures stale accounts are removed.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "1. Log on to the Huawei Cloud console. 2. Go to IAM & Security > Users. 3. Review disabled users. 4. Remove accounts that are no longer needed.",
+ "AuditProcedure": "Run prowler to identify disabled IAM users.",
+ "AdditionalInformation": "",
+ "References": "https://support.huaweicloud.com/intl/en-us/usermanual-iam/iam_01_0033.html"
+ }
+ ]
+ },
+ {
+ "Id": "2.1",
+ "Description": "Ensure OBS buckets are not publicly accessible",
+ "Checks": [
+ "obs_bucket_public_access"
+ ],
+ "Attributes": [
+ {
+ "Section": "2 Storage",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "Ensure OBS buckets do not allow public read or write access.",
+ "RationaleStatement": "Publicly accessible buckets expose data to anyone on the internet, which can lead to data breaches and unauthorized access.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "1. Log on to the Huawei Cloud console. 2. Go to Object Storage Service. 3. Select each bucket. 4. Review and modify the bucket ACL to remove public access. 5. Click OK.",
+ "AuditProcedure": "Run prowler to check if any OBS buckets are publicly accessible.",
+ "AdditionalInformation": "",
+ "References": "https://support.huaweicloud.com/intl/en-us/usermanual-obs/obs_03_0113.html"
+ }
+ ]
+ },
+ {
+ "Id": "2.2",
+ "Description": "Ensure EVS volumes have encryption enabled",
+ "Checks": [
+ "evs_volume_encryption"
+ ],
+ "Attributes": [
+ {
+ "Section": "2 Storage",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "Ensure all EVS volumes have encryption enabled.",
+ "RationaleStatement": "Encrypting EVS volumes protects data at rest against unauthorized access if the physical storage media is compromised.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "1. Log on to the Huawei Cloud console. 2. Go to Elastic Volume Service. 3. For each unencrypted volume, create an encrypted volume and migrate data. 4. Delete the unencrypted volume.",
+ "AuditProcedure": "Run prowler to check if all EVS volumes have encryption enabled.",
+ "AdditionalInformation": "",
+ "References": "https://support.huaweicloud.com/intl/en-us/usermanual-evs/evs_01_0044.html"
+ }
+ ]
+ },
+ {
+ "Id": "3.1",
+ "Description": "Ensure default security groups restrict all traffic",
+ "Checks": [
+ "vpc_default_security_group_restricts_all_traffic"
+ ],
+ "Attributes": [
+ {
+ "Section": "3 Network Security",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "Ensure default security groups do not allow unrestricted inbound or outbound traffic.",
+ "RationaleStatement": "Default security groups with open rules expose resources to traffic from any source, increasing the attack surface.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "1. Log on to the Huawei Cloud console. 2. Go to VPC > Security Groups. 3. Select the default security group. 4. Remove any rules with 0.0.0.0/0 or ::/0 as source/destination. 5. Add restrictive rules as needed.",
+ "AuditProcedure": "Run prowler to check if default security groups restrict all traffic.",
+ "AdditionalInformation": "",
+ "References": "https://support.huaweicloud.com/intl/en-us/vpc_faq/vpc_faq_0036.html"
+ }
+ ]
+ },
+ {
+ "Id": "3.2",
+ "Description": "Ensure security groups do not allow open ingress on sensitive ports",
+ "Checks": [
+ "vpc_security_group_open_ingress"
+ ],
+ "Attributes": [
+ {
+ "Section": "3 Network Security",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "Ensure security groups do not allow open ingress (0.0.0.0/0) on sensitive ports (SSH, RDP, MySQL, Redis, MongoDB).",
+ "RationaleStatement": "Exposing sensitive ports to the internet allows attackers to attempt brute force attacks, exploitation, or unauthorized access to services.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "1. Log on to the Huawei Cloud console. 2. Go to VPC > Security Groups. 3. For each security group, review ingress rules. 4. Remove or restrict rules that allow 0.0.0.0/0 on sensitive ports. 5. Use bastion host or VPN for access instead.",
+ "AuditProcedure": "Run prowler to check if any security groups allow open ingress on sensitive ports.",
+ "AdditionalInformation": "",
+ "References": "https://support.huaweicloud.com/intl/en-us/vpc_faq/vpc_faq_0036.html"
+ }
+ ]
+ },
+ {
+ "Id": "3.3",
+ "Description": "Ensure WAF is enabled",
+ "Checks": [
+ "waf_enabled"
+ ],
+ "Attributes": [
+ {
+ "Section": "3 Network Security",
+ "Profile": "Level 2",
+ "AssessmentStatus": "Automated",
+ "Description": "Ensure Web Application Firewall (WAF) is enabled to protect web applications from common attacks.",
+ "RationaleStatement": "WAF protects web applications from common web exploits such as SQL injection, XSS, and CSRF attacks.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "1. Log on to the Huawei Cloud console. 2. Go to Web Application Firewall. 3. Create or configure a WAF policy. 4. Enable WAF for your web applications.",
+ "AuditProcedure": "Run prowler to check if WAF is enabled.",
+ "AdditionalInformation": "",
+ "References": "https://support.huaweicloud.com/intl/en-us/usermanual-waf/waf_01_0001.html"
+ }
+ ]
+ },
+ {
+ "Id": "4.1",
+ "Description": "Ensure ECS instances do not have public IP addresses",
+ "Checks": [
+ "ecs_instance_public_ip"
+ ],
+ "Attributes": [
+ {
+ "Section": "4 Compute",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "Ensure ECS instances do not have public IP addresses unless required.",
+ "RationaleStatement": "Public IP addresses expose instances to the internet, increasing the attack surface. Use a bastion host or VPN for access instead.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "1. Log on to the Huawei Cloud console. 2. Go to Elastic Cloud Server. 3. For each instance with a public IP, release the EIP if not required. 4. Use a bastion host or VPN for access.",
+ "AuditProcedure": "Run prowler to check if any ECS instances have public IP addresses.",
+ "AdditionalInformation": "",
+ "References": "https://support.huaweicloud.com/intl/en-us/usermanual-ecs/ecs_03_0304.html"
+ }
+ ]
+ },
+ {
+ "Id": "5.1",
+ "Description": "Ensure RDS instances have backup enabled",
+ "Checks": [
+ "rds_backup_enabled"
+ ],
+ "Attributes": [
+ {
+ "Section": "5 Database",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "Ensure all RDS instances have automated backup enabled.",
+ "RationaleStatement": "Automated backups ensure data can be recovered in case of data loss, corruption, or disaster.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "1. Log on to the Huawei Cloud console. 2. Go to Relational Database Service. 3. For each instance, enable automated backup. 4. Configure backup retention period.",
+ "AuditProcedure": "Run prowler to check if all RDS instances have backup enabled.",
+ "AdditionalInformation": "",
+ "References": "https://support.huaweicloud.com/intl/en-us/usermanual-rds/rds_05_0037.html"
+ }
+ ]
+ },
+ {
+ "Id": "5.2",
+ "Description": "Ensure RDS instances are not publicly accessible",
+ "Checks": [
+ "rds_public_access"
+ ],
+ "Attributes": [
+ {
+ "Section": "5 Database",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "Ensure RDS instances are not publicly accessible.",
+ "RationaleStatement": "Publicly accessible databases expose data to anyone on the internet, significantly increasing the risk of unauthorized access and data breaches.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "1. Log on to the Huawei Cloud console. 2. Go to Relational Database Service. 3. For each instance, remove the public IP address. 4. Configure VPC-only access.",
+ "AuditProcedure": "Run prowler to check if any RDS instances are publicly accessible.",
+ "AdditionalInformation": "",
+ "References": "https://support.huaweicloud.com/intl/en-us/usermanual-rds/rds_03_0077.html"
+ }
+ ]
+ },
+ {
+ "Id": "6.1",
+ "Description": "Ensure ELB load balancers are not publicly exposed",
+ "Checks": [
+ "elb_public_exposure"
+ ],
+ "Attributes": [
+ {
+ "Section": "6 Load Balancing",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "Ensure ELB load balancers are not publicly exposed unless required.",
+ "RationaleStatement": "Publicly exposed load balancers can be targeted by DDoS attacks and unauthorized access attempts.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "1. Log on to the Huawei Cloud console. 2. Go to Elastic Load Balance. 3. Review each load balancer. 4. For internal-facing services, switch to internal load balancer.",
+ "AuditProcedure": "Run prowler to check if any ELB load balancers are publicly exposed.",
+ "AdditionalInformation": "",
+ "References": "https://support.huaweicloud.com/intl/en-us/usermanual-elb/elb_01_0001.html"
+ }
+ ]
+ },
+ {
+ "Id": "7.1",
+ "Description": "Ensure CTS tracking is enabled",
+ "Checks": [
+ "cts_enabled"
+ ],
+ "Attributes": [
+ {
+ "Section": "7 Logging and Monitoring",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "Ensure Cloud Trace Service (CTS) tracking is enabled for auditing and compliance.",
+ "RationaleStatement": "CTS tracking records all API calls and configuration changes, providing an audit trail for security analysis and compliance.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "1. Log on to the Huawei Cloud console. 2. Go to Cloud Trace Service. 3. Create or enable a tracker. 4. Configure the tracker to record all management and data events.",
+ "AuditProcedure": "Run prowler to check if CTS tracking is enabled.",
+ "AdditionalInformation": "",
+ "References": "https://support.huaweicloud.com/intl/en-us/usermanual-cts/cts_01_0003.html"
+ }
+ ]
+ },
+ {
+ "Id": "8.1",
+ "Description": "Ensure KMS keys have rotation enabled",
+ "Checks": [
+ "kms_key_rotation_enabled"
+ ],
+ "Attributes": [
+ {
+ "Section": "8 Key Management",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "Ensure all KMS keys have automatic key rotation enabled.",
+ "RationaleStatement": "Key rotation regularly replaces cryptographic material, reducing the risk of key compromise over time.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "1. Log on to the Huawei Cloud console. 2. Go to Key Management Service. 3. Select each key. 4. Click the Rotation tab. 5. Enable rotation and set the rotation period.",
+ "AuditProcedure": "Run prowler to check if all KMS keys have rotation enabled.",
+ "AdditionalInformation": "",
+ "References": "https://support.huaweicloud.com/intl/en-us/usermanual-kms/kms_01_0019.html"
+ }
+ ]
+ },
+ {
+ "Id": "8.2",
+ "Description": "Ensure KMS keys are not in pending deletion state",
+ "Checks": [
+ "kms_key_not_pending_deletion"
+ ],
+ "Attributes": [
+ {
+ "Section": "8 Key Management",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "Ensure KMS keys are not in pending deletion state, which could lead to data loss.",
+ "RationaleStatement": "Keys pending deletion will be permanently deleted after the waiting period, making all data encrypted with those keys unrecoverable.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "1. Log on to the Huawei Cloud console. 2. Go to Key Management Service. 3. Review keys in pending deletion state. 4. Cancel deletion for keys that are still needed.",
+ "AuditProcedure": "Run prowler to check if any KMS keys are in pending deletion state.",
+ "AdditionalInformation": "",
+ "References": "https://support.huaweicloud.com/intl/en-us/usermanual-kms/kms_01_0018.html"
+ }
+ ]
+ }
+ ]
+}
diff --git a/prowler/config/config.py b/prowler/config/config.py
index bee2984db3..021741e5c2 100644
--- a/prowler/config/config.py
+++ b/prowler/config/config.py
@@ -49,7 +49,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
-prowler_version = "5.36.0"
+prowler_version = "5.37.0"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
@@ -81,6 +81,7 @@ class Provider(str, Enum):
OKTA = "okta"
STACKIT = "stackit"
LINODE = "linode"
+ HUAWEICLOUD = "huaweicloud"
E2ENETWORKS = "e2enetworks"
diff --git a/prowler/config/config.yaml b/prowler/config/config.yaml
index aba7fd6481..5bb629ee88 100644
--- a/prowler/config/config.yaml
+++ b/prowler/config/config.yaml
@@ -63,6 +63,8 @@ aws:
max_security_group_rules: 50
# aws.ec2_instance_older_than_specific_days --> by default is 6 months (180 days)
max_ec2_instance_age_in_days: 180
+ # aws.ec2_instance_stopped_older_than_specific_days --> by default is 30 days
+ max_ec2_instance_stopped_days: 30
# aws.ec2_securitygroup_allow_ingress_from_internet_to_any_port
# allowed network interface types for security groups open to the Internet
ec2_allowed_interface_types:
diff --git a/prowler/config/schema/aws.py b/prowler/config/schema/aws.py
index 7ac12d60de..92729d0fa0 100644
--- a/prowler/config/schema/aws.py
+++ b/prowler/config/schema/aws.py
@@ -213,6 +213,15 @@ class AWSProviderConfig(ProviderConfigBase):
"per NIST CM-3 — anything older is a security smell)."
),
)
+ max_ec2_instance_stopped_days: Optional[int] = Field(
+ default=None,
+ ge=1,
+ le=1095,
+ description=(
+ "Days an EC2 instance can remain stopped before being flagged. "
+ "Range: 1..1095 (3 years)."
+ ),
+ )
ec2_allowed_interface_types: Optional[list[str]] = None
ec2_allowed_instance_owners: Optional[list[str]] = None
ec2_high_risk_ports: Annotated[
diff --git a/prowler/lib/check/check.py b/prowler/lib/check/check.py
index c0c6a02e5d..a8e21c982e 100644
--- a/prowler/lib/check/check.py
+++ b/prowler/lib/check/check.py
@@ -801,6 +801,10 @@ def execute(
is_finding_muted_args["account_id"] = (
global_provider.identity.account_id
)
+ elif global_provider.type == "huaweicloud":
+ is_finding_muted_args["account_id"] = (
+ global_provider.identity.account_id
+ )
elif not is_builtin_provider(global_provider.type):
# External/custom provider — delegate identity args
is_finding_muted_args = global_provider.get_mutelist_finding_args()
diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py
index 5f92ecf481..bd346ad8b3 100644
--- a/prowler/lib/check/models.py
+++ b/prowler/lib/check/models.py
@@ -903,6 +903,31 @@ class CheckReportAlibabaCloud(Check_Report):
self.region = getattr(resource, "region", "")
+@dataclass
+class CheckReportHuaweiCloud(Check_Report):
+ """Contains the Huawei Cloud Check's finding information."""
+
+ resource_id: str
+ resource_arn: str
+ region: str
+ resource_name: str
+
+ def __init__(self, metadata: Dict, resource: Any) -> None:
+ """Initialize the Huawei Cloud Check's finding information.
+
+ Args:
+ metadata: The metadata of the check.
+ resource: Basic information about the resource.
+ """
+ super().__init__(metadata, resource)
+ self.resource_id = (
+ getattr(resource, "id", None) or getattr(resource, "name", None) or ""
+ )
+ self.resource_arn = getattr(resource, "arn", "")
+ self.region = getattr(resource, "region", "")
+ self.resource_name = getattr(resource, "name", "") or self.resource_id
+
+
@dataclass
class Check_Report_Kubernetes(Check_Report):
# TODO change class name to CheckReportKubernetes
diff --git a/prowler/lib/cli/parser.py b/prowler/lib/cli/parser.py
index 38661e6445..68c08e8f6b 100644
--- a/prowler/lib/cli/parser.py
+++ b/prowler/lib/cli/parser.py
@@ -53,6 +53,7 @@ class ProwlerArgumentParser:
"scaleway",
"stackit",
"linode",
+ "huaweicloud",
}
all_providers = set(Provider.get_available_providers())
new_providers = sorted(all_providers - known_providers)
@@ -75,10 +76,10 @@ class ProwlerArgumentParser:
self.parser = argparse.ArgumentParser(
prog="prowler",
formatter_class=RawTextHelpFormatter,
- usage=f"prowler [-h] [--version] {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,linode,e2enetworks,dashboard,iac,image,llm{extra_providers_csv}}} ...",
+ usage=f"prowler [-h] [--version] {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,linode,huaweicloud,e2enetworks,dashboard,iac,image,llm{extra_providers_csv}}} ...",
epilog=f"""
Available Cloud Providers:
- {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,linode,e2enetworks{extra_providers_csv}}}
+ {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,linode,huaweicloud,e2enetworks{extra_providers_csv}}}
aws AWS Provider
azure Azure Provider
gcp GCP Provider
@@ -100,6 +101,7 @@ Available Cloud Providers:
scaleway Scaleway Provider
vercel Vercel Provider
linode Linode Provider
+ huaweicloud Huawei Cloud Provider
e2enetworks E2E Networks Provider{extra_providers_text}
diff --git a/prowler/lib/outputs/compliance/universal/universal_output.py b/prowler/lib/outputs/compliance/universal/universal_output.py
index b1b0d9409b..5cca376482 100644
--- a/prowler/lib/outputs/compliance/universal/universal_output.py
+++ b/prowler/lib/outputs/compliance/universal/universal_output.py
@@ -26,6 +26,7 @@ PROVIDER_HEADER_MAP = {
"oraclecloud": ("TenancyId", "account_uid", "Region", "region"),
"alibabacloud": ("AccountId", "account_uid", "Region", "region"),
"nhn": ("AccountId", "account_uid", "Region", "region"),
+ "huaweicloud": ("AccountId", "account_uid", "Region", "region"),
"e2enetworks": ("ProjectId", "account_uid", "Location", "region"),
}
_DEFAULT_HEADERS = ("AccountId", "account_uid", "Region", "region")
diff --git a/prowler/lib/outputs/finding.py b/prowler/lib/outputs/finding.py
index 7231572571..dc88044692 100644
--- a/prowler/lib/outputs/finding.py
+++ b/prowler/lib/outputs/finding.py
@@ -514,6 +514,23 @@ class Finding(BaseModel):
)
output_data["region"] = check_output.region
+ elif provider.type == "huaweicloud":
+ output_data["auth_method"] = get_nested_attribute(
+ provider, "identity.identity_type"
+ )
+ output_data["account_uid"] = get_nested_attribute(
+ provider, "identity.account_id"
+ )
+ output_data["account_name"] = get_nested_attribute(
+ provider, "identity.account_name"
+ )
+ output_data["resource_name"] = check_output.resource_name
+ output_data["resource_uid"] = (
+ getattr(check_output, "resource_arn", "")
+ or check_output.resource_id
+ )
+ output_data["region"] = check_output.region
+
elif provider.type == "openstack":
output_data["auth_method"] = (
f"Username: {get_nested_attribute(provider, 'identity.username')}"
diff --git a/prowler/lib/outputs/html/html.py b/prowler/lib/outputs/html/html.py
index 3463014232..470ba207fb 100644
--- a/prowler/lib/outputs/html/html.py
+++ b/prowler/lib/outputs/html/html.py
@@ -1680,6 +1680,78 @@ class HTML(Output):
)
return ""
+ @staticmethod
+ def get_huaweicloud_assessment_summary(provider: Provider) -> str:
+ """
+ get_huaweicloud_assessment_summary gets the HTML assessment summary for the Huawei Cloud provider
+
+ Args:
+ provider (Provider): the Huawei Cloud provider object
+
+ Returns:
+ str: HTML assessment summary for the Huawei Cloud provider
+ """
+ try:
+ profile = (
+ provider.identity.profile
+ if provider.identity.profile is not None
+ else "default"
+ )
+ if isinstance(provider.identity.regions, set):
+ audited_regions = ", ".join(sorted(provider.identity.regions))
+ elif not provider.identity.regions:
+ audited_regions = "All Regions"
+ else:
+ audited_regions = ", ".join(provider.identity.regions)
+ return f"""
+
+
+
+
+
+ Account ID: {provider.identity.account_id}
+
+
+ Account Name: {provider.identity.account_name}
+
+
+ Profile: {profile}
+
+
+ Audited Regions: {audited_regions}
+
+
+
+
+
+
+
+
+
+ Domain ID: {provider.identity.domain_id}
+
+
+ User ID: {provider.identity.user_id}
+
+
+ User Name: {provider.identity.user_name}
+
+
+ Identity Type: {provider.identity.identity_type}
+
+
+
+
"""
+ except Exception as error:
+ logger.error(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
+ )
+ return ""
+
@staticmethod
def get_assessment_summary(provider: Provider) -> str:
"""
diff --git a/prowler/lib/outputs/ocsf/ocsf.py b/prowler/lib/outputs/ocsf/ocsf.py
index 53f27d0e1b..29898ce813 100644
--- a/prowler/lib/outputs/ocsf/ocsf.py
+++ b/prowler/lib/outputs/ocsf/ocsf.py
@@ -2,7 +2,7 @@ import json
import os
from datetime import datetime, timezone
from random import getrandbits
-from typing import List
+from typing import List, Optional
from py_ocsf_models.events.base_event import SeverityID, StatusID
from py_ocsf_models.events.findings.detection_finding import (
@@ -11,9 +11,11 @@ from py_ocsf_models.events.findings.detection_finding import (
)
from py_ocsf_models.events.findings.finding import ActivityID, FindingInformation
from py_ocsf_models.objects.account import Account, TypeID
+from py_ocsf_models.objects.analytic import Analytic
from py_ocsf_models.objects.cloud import Cloud
from py_ocsf_models.objects.group import Group
from py_ocsf_models.objects.metadata import Metadata
+from py_ocsf_models.objects.mitre_attack import MITREAttack, Tactic, Technique
from py_ocsf_models.objects.organization import Organization
from py_ocsf_models.objects.product import Product
from py_ocsf_models.objects.remediation import Remediation
@@ -83,6 +85,8 @@ class OCSF(Output):
activity_id=finding_activity.value,
activity_name=finding_activity.name,
finding_info=FindingInformation(
+ analytic=_build_analytic(finding),
+ attacks=_build_mitre_attacks(finding),
created_time_dt=finding.timestamp,
created_time=(
int(finding.timestamp.timestamp())
@@ -323,6 +327,56 @@ class OCSF(Output):
return status_id
+def _build_analytic(finding: Finding) -> Analytic:
+ """Build an OCSF Analytic object for the Prowler check.
+
+ Args:
+ finding (Finding): Finding generated by a Prowler check.
+
+ Returns:
+ Analytic: OCSF Analytic describing the check that generated the finding.
+ """
+ return Analytic(
+ name=finding.metadata.CheckTitle,
+ uid=finding.metadata.CheckID,
+ type_id=1,
+ type="Rule",
+ category=finding.metadata.ServiceName,
+ )
+
+
+def _build_mitre_attacks(finding: Finding) -> Optional[List[MITREAttack]]:
+ """Build OCSF MITREAttack objects from MITRE-ATTACK metadata.
+
+ Args:
+ finding (Finding): Finding with compliance metadata attached to its check metadata.
+
+ Returns:
+ Optional[List[MITREAttack]]: MITRE attacks derived from metadata, or None
+ when the finding has no MITRE-ATTACK metadata.
+ """
+ attacks = []
+ for compliance in finding.metadata.Compliance or []:
+ if compliance.Framework.upper() != "MITRE-ATTACK":
+ continue
+
+ for requirement in compliance.Requirements:
+ technique = Technique(
+ uid=requirement.Id,
+ name=requirement.Name,
+ src_url=requirement.TechniqueURL,
+ )
+ for tactic_name in requirement.Tactics:
+ attacks.append(
+ MITREAttack(
+ technique=technique,
+ tactic=Tactic(name=tactic_name),
+ )
+ )
+
+ return attacks if attacks else None
+
+
# NOTE: Copied from api/src/backend/api/uuid_utils.py (datetime_to_uuid7)
# Adapted to accept datetime/epoch inputs.
def _uuid7_from_timestamp(value) -> UUID:
diff --git a/prowler/lib/outputs/outputs.py b/prowler/lib/outputs/outputs.py
index 05421ac584..bb48333960 100644
--- a/prowler/lib/outputs/outputs.py
+++ b/prowler/lib/outputs/outputs.py
@@ -36,6 +36,8 @@ def stdout_report(finding, color, verbose, status, fix, provider=None):
details = finding.region
elif finding.check_metadata.Provider == "alibabacloud":
details = finding.region
+ elif finding.check_metadata.Provider == "huaweicloud":
+ details = finding.region
elif finding.check_metadata.Provider == "openstack":
details = finding.region
elif finding.check_metadata.Provider == "cloudflare":
diff --git a/prowler/lib/outputs/summary_table.py b/prowler/lib/outputs/summary_table.py
index 2d8da80597..15b056fb6b 100644
--- a/prowler/lib/outputs/summary_table.py
+++ b/prowler/lib/outputs/summary_table.py
@@ -129,6 +129,11 @@ def display_summary_table(
audited_entities = (
provider.identity.username or provider.identity.email or "linode"
)
+ elif provider.type == "huaweicloud":
+ entity_type = "Account"
+ audited_entities = (
+ provider.identity.account_id or provider.identity.account_name
+ )
else:
# Dynamic fallback: any external/custom provider
entity_type, audited_entities = provider.get_summary_entity()
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_stopped_older_than_specific_days/__init__.py b/prowler/providers/aws/services/ec2/ec2_instance_stopped_older_than_specific_days/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_stopped_older_than_specific_days/ec2_instance_stopped_older_than_specific_days.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_stopped_older_than_specific_days/ec2_instance_stopped_older_than_specific_days.metadata.json
new file mode 100644
index 0000000000..1885f73177
--- /dev/null
+++ b/prowler/providers/aws/services/ec2/ec2_instance_stopped_older_than_specific_days/ec2_instance_stopped_older_than_specific_days.metadata.json
@@ -0,0 +1,41 @@
+{
+ "Provider": "aws",
+ "CheckID": "ec2_instance_stopped_older_than_specific_days",
+ "CheckTitle": "EC2 instance has not been stopped longer than the configured maximum days",
+ "CheckType": [
+ "Software and Configuration Checks/AWS Security Best Practices",
+ "Software and Configuration Checks/Patch Management"
+ ],
+ "ServiceName": "ec2",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "arn:partition:ec2:region:account-id:instance/instance-id",
+ "Severity": "low",
+ "ResourceType": "AwsEc2Instance",
+ "ResourceGroup": "compute",
+ "Description": "**EC2 instances** in the `stopped` state are evaluated for how long they have remained stopped. Instances stopped beyond the configurable limit (`max_ec2_instance_stopped_days`, default `30`) are flagged. Running, pending, and other non-stopped instances pass.",
+ "Risk": "Long-stopped instances remain **unmonitored and unpatched** while still retaining EBS volumes, network configuration, IAM instance profiles, and SSH keys. This expands attack surface and incurs unnecessary storage cost. Stale AMIs and credentials attached to abandoned instances increase the chance of compromise if the instance is later started.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-lifecycle.html",
+ "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/Stop_Start.html",
+ "https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_DescribeInstances.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "aws ec2 terminate-instances --instance-ids ",
+ "NativeIaC": "",
+ "Other": "1. Sign in to the AWS Management Console and open EC2\n2. Go to Instances and select the long-stopped instance\n3. Review attached EBS volumes, tags, and ownership\n4. Choose Instance state > Terminate instance (or Start instance if still needed, then patch/rebuild)\n5. Confirm and verify the instance is terminated or returned to an actively managed lifecycle",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Establish a lifecycle policy for stopped instances:\n- Tag instances with owner and expiry\n- Terminate instances no longer needed to reclaim EBS cost\n- If retained, start regularly for patching or rebuild from a hardened AMI\n- Prefer ephemeral, autoscaled workloads over long-lived stopped hosts\n\nAdjust `max_ec2_instance_stopped_days` to match policy.",
+ "Url": "https://hub.prowler.com/check/ec2_instance_stopped_older_than_specific_days"
+ }
+ },
+ "Categories": [],
+ "DependsOn": [],
+ "RelatedTo": [
+ "ec2_instance_older_than_specific_days"
+ ],
+ "Notes": ""
+}
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_stopped_older_than_specific_days/ec2_instance_stopped_older_than_specific_days.py b/prowler/providers/aws/services/ec2/ec2_instance_stopped_older_than_specific_days/ec2_instance_stopped_older_than_specific_days.py
new file mode 100644
index 0000000000..e571ed3ee6
--- /dev/null
+++ b/prowler/providers/aws/services/ec2/ec2_instance_stopped_older_than_specific_days/ec2_instance_stopped_older_than_specific_days.py
@@ -0,0 +1,86 @@
+import re
+from datetime import datetime, timezone
+from typing import Optional
+
+from prowler.lib.check.models import Check, Check_Report_AWS
+from prowler.providers.aws.services.ec2.ec2_client import ec2_client
+
+# AWS StateTransitionReason for stopped instances, e.g.:
+# "User initiated (2016-09-14 15:07:39 GMT)"
+_STATE_TRANSITION_TIME_REGEX = re.compile(
+ r"\((\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) GMT\)"
+)
+
+
+def _parse_state_transition_time(
+ state_transition_reason: Optional[str],
+) -> Optional[datetime]:
+ """Extract the stop timestamp from EC2 StateTransitionReason."""
+ if not state_transition_reason:
+ return None
+ match = _STATE_TRANSITION_TIME_REGEX.search(state_transition_reason)
+ if not match:
+ return None
+ try:
+ return datetime.strptime(match.group(1), "%Y-%m-%d %H:%M:%S").replace(
+ tzinfo=timezone.utc
+ )
+ except ValueError:
+ return None
+
+
+class ec2_instance_stopped_older_than_specific_days(Check):
+ """Ensure EC2 instances are not stopped longer than a configured number of days.
+
+ Evaluates each instance's stop duration using StateTransitionReason.
+ - PASS: Instance is not stopped, or has been stopped for at most the threshold.
+ - FAIL: Instance has been stopped longer than max_ec2_instance_stopped_days
+ (default 30).
+ """
+
+ def execute(self) -> list[Check_Report_AWS]:
+ """Execute the check logic.
+
+ Returns:
+ A list of reports containing the result of the check.
+ """
+ findings = []
+
+ # max_ec2_instance_stopped_days, default: 30 days
+ max_ec2_instance_stopped_days = ec2_client.audit_config.get(
+ "max_ec2_instance_stopped_days", 30
+ )
+ for instance in ec2_client.instances:
+ report = Check_Report_AWS(metadata=self.metadata(), resource=instance)
+ report.resource_id = instance.id
+ report.resource_arn = instance.arn
+ report.resource_tags = instance.tags
+ report.status = "PASS"
+ report.status_extended = f"EC2 Instance {instance.id} is not stopped."
+ if instance.state == "stopped":
+ stop_time = _parse_state_transition_time(
+ instance.state_transition_reason
+ )
+ if not stop_time:
+ report.status_extended = (
+ f"EC2 Instance {instance.id} is stopped but stop time "
+ f"could not be determined."
+ )
+ else:
+ days_stopped = (datetime.now(timezone.utc) - stop_time).days
+ report.status_extended = (
+ f"EC2 Instance {instance.id} has not been stopped longer "
+ f"than {max_ec2_instance_stopped_days} days "
+ f"({days_stopped} days)."
+ )
+ if days_stopped > max_ec2_instance_stopped_days:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"EC2 Instance {instance.id} has been stopped longer "
+ f"than {max_ec2_instance_stopped_days} days "
+ f"({days_stopped} days)."
+ )
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/aws/services/ec2/ec2_service.py b/prowler/providers/aws/services/ec2/ec2_service.py
index 1c1055ba78..bb5444c3c1 100644
--- a/prowler/providers/aws/services/ec2/ec2_service.py
+++ b/prowler/providers/aws/services/ec2/ec2_service.py
@@ -100,6 +100,9 @@ class EC2(AWSService):
type=instance["InstanceType"],
image_id=instance["ImageId"],
launch_time=instance["LaunchTime"],
+ state_transition_reason=instance.get(
+ "StateTransitionReason"
+ ),
private_dns=instance["PrivateDnsName"],
private_ip=instance.get("PrivateIpAddress"),
public_dns=instance.get("PublicDnsName"),
@@ -761,6 +764,7 @@ class Instance(BaseModel):
type: str
image_id: str
launch_time: datetime
+ state_transition_reason: Optional[str] = None
private_dns: str
private_ip: Optional[str]
public_dns: Optional[str]
diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/__init__.py b/prowler/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/sagemaker_notebook_instance_no_secrets.metadata.json b/prowler/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/sagemaker_notebook_instance_no_secrets.metadata.json
new file mode 100644
index 0000000000..161abe5d30
--- /dev/null
+++ b/prowler/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/sagemaker_notebook_instance_no_secrets.metadata.json
@@ -0,0 +1,41 @@
+{
+ "Provider": "aws",
+ "CheckID": "sagemaker_notebook_instance_no_secrets",
+ "CheckTitle": "SageMaker notebook instance lifecycle configuration contains no hardcoded secrets",
+ "CheckType": [
+ "Software and Configuration Checks/AWS Security Best Practices",
+ "Sensitive Data Identifications/Passwords",
+ "Effects/Data Exposure"
+ ],
+ "ServiceName": "sagemaker",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
+ "ResourceType": "AwsSageMakerNotebookInstance",
+ "ResourceGroup": "ai_ml",
+ "Description": "**SageMaker notebook instance lifecycle configuration scripts** (`OnCreate` and `OnStart`) are analyzed for **embedded secrets**, detecting patterns like API keys, passwords, tokens, and connection strings. Findings reference the lifecycle hook and line numbers where potential secrets appear.",
+ "Risk": "**Hardcoded secrets** in lifecycle configuration scripts can be read by anyone with SageMaker access to the notebook instance, letting attackers reuse the credentials to access databases, APIs, or cloud resources, enabling data exfiltration and unauthorized changes.\n\nRotation is harder, increasing dwell time and blast radius of compromises.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/sagemaker/latest/dg/notebook-lifecycle-config.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "aws sagemaker update-notebook-instance-lifecycle-config --notebook-instance-lifecycle-config-name --on-start Content=",
+ "NativeIaC": "",
+ "Other": "1. Create a secret in AWS Secrets Manager for the hardcoded value.\n2. Update the notebook instance IAM role to allow secretsmanager:GetSecretValue on that secret.\n3. Edit the lifecycle script to fetch the secret at runtime instead of hardcoding it.\n4. Update the notebook instance lifecycle configuration.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Use AWS Secrets Manager or Parameter Store to store secrets and retrieve them at runtime in lifecycle scripts; never hardcode them.",
+ "Url": "https://hub.prowler.com/check/sagemaker_notebook_instance_no_secrets"
+ }
+ },
+ "Categories": [
+ "secrets",
+ "gen-ai"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/sagemaker_notebook_instance_no_secrets.py b/prowler/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/sagemaker_notebook_instance_no_secrets.py
new file mode 100644
index 0000000000..9b975596e4
--- /dev/null
+++ b/prowler/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/sagemaker_notebook_instance_no_secrets.py
@@ -0,0 +1,131 @@
+from prowler.lib.check.models import Check, Check_Report_AWS
+from prowler.lib.utils.utils import (
+ SecretsScanError,
+ annotate_verified_secrets,
+ detect_secrets_scan_batch,
+)
+from prowler.providers.aws.services.sagemaker.sagemaker_client import (
+ sagemaker_client,
+)
+
+
+class sagemaker_notebook_instance_no_secrets(Check):
+ """Check for hardcoded secrets in SageMaker notebook instance lifecycle scripts.
+
+ Scans the OnCreate and OnStart lifecycle configuration scripts of each
+ SageMaker notebook instance for hardcoded secrets such as API keys,
+ passwords, tokens, and connection strings. The scripts are fetched and
+ decoded by the SageMaker service; this check only consumes that data.
+ """
+
+ def execute(self):
+ """Execute the sagemaker_notebook_instance_no_secrets check.
+
+ Returns:
+ list[Check_Report_AWS]: One report per SageMaker notebook
+ instance, with status PASS, FAIL, or MANUAL.
+ """
+ findings = []
+ notebook_instances = sagemaker_client.sagemaker_notebook_instances
+ if not notebook_instances:
+ return findings
+
+ secrets_ignore_patterns = sagemaker_client.audit_config.get(
+ "secrets_ignore_patterns", []
+ )
+ validate = sagemaker_client.audit_config.get("secrets_validate", False)
+
+ # Instances that actually contribute a script to the batch. Only these
+ # (plus instances whose describe/decode failed) may be marked MANUAL on
+ # a batch scan failure; instances with nothing to scan must PASS.
+ scanned_resources = {
+ notebook_instance.arn
+ for notebook_instance in notebook_instances
+ if notebook_instance.lifecycle_scripts
+ }
+
+ def payloads():
+ for notebook_instance in notebook_instances:
+ for fragment, script in notebook_instance.lifecycle_scripts.items():
+ yield (notebook_instance.arn, fragment), script
+
+ scan_error = None
+ try:
+ batch_results = detect_secrets_scan_batch(
+ payloads(),
+ excluded_secrets=secrets_ignore_patterns,
+ validate=validate,
+ )
+ except SecretsScanError as error:
+ batch_results = {}
+ scan_error = error
+
+ findings_by_instance = {}
+ for (
+ resource_id,
+ fragment,
+ ), fragment_findings in batch_results.items():
+ findings_by_instance.setdefault(resource_id, {})[
+ fragment
+ ] = fragment_findings
+
+ for notebook_instance in notebook_instances:
+ report = Check_Report_AWS(
+ metadata=self.metadata(), resource=notebook_instance
+ )
+
+ # MANUAL when the instance could not be fully scanned: either the
+ # lifecycle config describe/decode failed, or the batch scan failed
+ # for an instance that actually had scripts queued for scanning.
+ batch_failed = (
+ scan_error is not None and notebook_instance.arn in scanned_resources
+ )
+ if notebook_instance.lifecycle_scan_failed or batch_failed:
+ report.status = "MANUAL"
+ report.status_extended = (
+ f"Could not fully scan SageMaker notebook instance "
+ f"{notebook_instance.name} lifecycle configuration for "
+ f"secrets; manual review is required."
+ )
+ findings.append(report)
+ continue
+
+ report.status = "PASS"
+ if not notebook_instance.lifecycle_config_name:
+ report.status_extended = (
+ f"SageMaker notebook instance {notebook_instance.name} "
+ f"does not have a lifecycle configuration."
+ )
+ else:
+ report.status_extended = (
+ f"No secrets found in SageMaker notebook instance "
+ f"{notebook_instance.name} lifecycle configuration."
+ )
+
+ fragments_with_secrets = findings_by_instance.get(notebook_instance.arn)
+
+ if fragments_with_secrets:
+ all_secrets = []
+ secrets_findings = []
+
+ for fragment, fragment_findings in fragments_with_secrets.items():
+ all_secrets.extend(fragment_findings)
+ secrets_string = ", ".join(
+ f"{secret['type']} on line {secret['line_number']}"
+ for secret in fragment_findings
+ )
+ secrets_findings.append(f"{fragment}: {secrets_string}")
+
+ final_output_string = "; ".join(secrets_findings)
+ report.status = "FAIL"
+ report.status_extended = (
+ f"Potential {'secrets' if len(secrets_findings) > 1 else 'secret'} "
+ f"found in SageMaker notebook instance "
+ f"{notebook_instance.name} lifecycle configuration -> "
+ f"{final_output_string}."
+ )
+ annotate_verified_secrets(report, all_secrets)
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_service.py b/prowler/providers/aws/services/sagemaker/sagemaker_service.py
index 20ea4c0280..6303ebbbf2 100644
--- a/prowler/providers/aws/services/sagemaker/sagemaker_service.py
+++ b/prowler/providers/aws/services/sagemaker/sagemaker_service.py
@@ -1,3 +1,4 @@
+import base64
from typing import Optional
from botocore.client import ClientError
@@ -37,6 +38,11 @@ class SageMaker(AWSService):
self.__threading_call__(
self._describe_notebook_instance, self.sagemaker_notebook_instances
)
+ # Runs after _describe_notebook_instance so lifecycle_config_name is set.
+ self.__threading_call__(
+ self._describe_notebook_instance_lifecycle_config,
+ self.sagemaker_notebook_instances,
+ )
self.__threading_call__(
self._describe_training_job, self.sagemaker_training_jobs
)
@@ -224,11 +230,61 @@ class SageMaker(AWSService):
notebook_instance.direct_internet_access = True
if "KmsKeyId" in describe_notebook_instance:
notebook_instance.kms_key_id = describe_notebook_instance["KmsKeyId"]
+ if "NotebookInstanceLifecycleConfigName" in describe_notebook_instance:
+ notebook_instance.lifecycle_config_name = describe_notebook_instance[
+ "NotebookInstanceLifecycleConfigName"
+ ]
except Exception as error:
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
+ def _describe_notebook_instance_lifecycle_config(self, notebook_instance):
+ """Fetch and decode a notebook instance's lifecycle scripts.
+
+ Reads the ``OnCreate`` and ``OnStart`` scripts from
+ ``DescribeNotebookInstanceLifecycleConfig`` and stores the base64-decoded
+ content on ``notebook_instance.lifecycle_scripts`` keyed by
+ ``"[]"``. Instances without a lifecycle configuration are
+ skipped. Any describe or decode failure sets
+ ``notebook_instance.lifecycle_scan_failed`` to True so the consuming
+ check can report ``MANUAL`` instead of a false ``PASS``.
+
+ Args:
+ notebook_instance: NotebookInstance model to enrich in-place.
+ """
+ if not notebook_instance.lifecycle_config_name:
+ return
+ logger.info("SageMaker - describing notebook instance lifecycle config...")
+ try:
+ regional_client = self.regional_clients[notebook_instance.region]
+ lifecycle_config = regional_client.describe_notebook_instance_lifecycle_config(
+ NotebookInstanceLifecycleConfigName=notebook_instance.lifecycle_config_name
+ )
+ except Exception as error:
+ notebook_instance.lifecycle_scan_failed = True
+ logger.error(
+ f"{notebook_instance.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+ return
+
+ scripts = {}
+ for hook_name in ("OnCreate", "OnStart"):
+ for script_index, script in enumerate(lifecycle_config.get(hook_name, [])):
+ content_b64 = script.get("Content")
+ if not content_b64:
+ continue
+ try:
+ scripts[f"{hook_name}[{script_index}]"] = base64.b64decode(
+ content_b64
+ ).decode("utf-8", errors="ignore")
+ except Exception as error:
+ notebook_instance.lifecycle_scan_failed = True
+ logger.error(
+ f"{notebook_instance.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+ notebook_instance.lifecycle_scripts = scripts
+
def _describe_model(self, model):
logger.info("SageMaker - describing models...")
try:
@@ -497,6 +553,13 @@ class NotebookInstance(BaseModel):
subnet_id: str = None
direct_internet_access: bool = None
kms_key_id: str = None
+ lifecycle_config_name: str = None
+ # Decoded lifecycle scripts keyed by "[]" (e.g. "OnStart[0]"),
+ # populated by _describe_notebook_instance_lifecycle_config.
+ lifecycle_scripts: dict = {}
+ # True if the lifecycle configuration could not be fully described/decoded,
+ # so the secrets check reports MANUAL instead of a false PASS.
+ lifecycle_scan_failed: bool = False
tags: Optional[list] = []
diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py
index d4793adbe7..2e81bad121 100644
--- a/prowler/providers/common/provider.py
+++ b/prowler/providers/common/provider.py
@@ -676,6 +676,21 @@ class Provider(ABC):
fixer_config=fixer_config,
regions=getattr(arguments, "region", None),
)
+ elif arguments.provider == "huaweicloud":
+ # Credentials are read from the HUAWEICLOUD_* (or HW_*) env
+ # vars by the provider itself; there are no credential CLI
+ # flags to avoid leaking secrets.
+ provider_class(
+ cloud=getattr(arguments, "cloud", None),
+ regions=(
+ set(arguments.regions)
+ if getattr(arguments, "regions", None)
+ else None
+ ),
+ config_path=arguments.config_file,
+ mutelist_path=arguments.mutelist_file,
+ fixer_config=fixer_config,
+ )
else:
# Dynamic fallback: any external/custom provider.
# Honor the from_cli_args type hint (-> Provider): if the
diff --git a/prowler/providers/gcp/services/compute/compute_firewall_rdp_access_from_the_internet_allowed/compute_firewall_rdp_access_from_the_internet_allowed.py b/prowler/providers/gcp/services/compute/compute_firewall_rdp_access_from_the_internet_allowed/compute_firewall_rdp_access_from_the_internet_allowed.py
index af99daeec5..be860ea155 100644
--- a/prowler/providers/gcp/services/compute/compute_firewall_rdp_access_from_the_internet_allowed/compute_firewall_rdp_access_from_the_internet_allowed.py
+++ b/prowler/providers/gcp/services/compute/compute_firewall_rdp_access_from_the_internet_allowed/compute_firewall_rdp_access_from_the_internet_allowed.py
@@ -31,7 +31,7 @@ class compute_firewall_rdp_access_from_the_internet_allowed(Check):
break
elif int(port) == 3389:
opened_port = True
- break
+ break
if (
"0.0.0.0/0" in firewall.source_ranges
and firewall.direction == "INGRESS"
diff --git a/prowler/providers/gcp/services/compute/compute_firewall_ssh_access_from_the_internet_allowed/compute_firewall_ssh_access_from_the_internet_allowed.py b/prowler/providers/gcp/services/compute/compute_firewall_ssh_access_from_the_internet_allowed/compute_firewall_ssh_access_from_the_internet_allowed.py
index e4881568cf..a00158cd1d 100644
--- a/prowler/providers/gcp/services/compute/compute_firewall_ssh_access_from_the_internet_allowed/compute_firewall_ssh_access_from_the_internet_allowed.py
+++ b/prowler/providers/gcp/services/compute/compute_firewall_ssh_access_from_the_internet_allowed/compute_firewall_ssh_access_from_the_internet_allowed.py
@@ -31,7 +31,7 @@ class compute_firewall_ssh_access_from_the_internet_allowed(Check):
break
elif int(port) == 22:
opened_port = True
- break
+ break
if (
"0.0.0.0/0" in firewall.source_ranges
and firewall.direction == "INGRESS"
diff --git a/prowler/providers/huaweicloud/__init__.py b/prowler/providers/huaweicloud/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/config.py b/prowler/providers/huaweicloud/config.py
new file mode 100644
index 0000000000..21ff8e5d0e
--- /dev/null
+++ b/prowler/providers/huaweicloud/config.py
@@ -0,0 +1,113 @@
+"""Huawei Cloud Provider Configuration Constants"""
+
+HUAWEICLOUD_DEFAULT_REGION = "cn-north-4"
+ROLE_SESSION_NAME = "ProwlerAssessmentSession"
+
+# Huawei Cloud SDK Configuration
+HUAWEICLOUD_SDK_READ_TIMEOUT = 60 # seconds
+HUAWEICLOUD_SDK_CONNECT_TIMEOUT = 10 # seconds
+
+# Huawei Cloud Regions - Based on Huawei Cloud documentation
+# Source: https://developer.huaweicloud.com/intl/en-us/endpoint
+HUAWEICLOUD_REGIONS = {
+ # China Regions
+ "cn-north-1": "China (Beijing-1)",
+ "cn-north-4": "China (Beijing-4)",
+ "cn-east-2": "China (Shanghai-2)",
+ "cn-east-3": "China (Shanghai-1)",
+ "cn-east-4": "China (Shanghai-4)",
+ "cn-south-1": "China (Guangzhou)",
+ "cn-south-2": "China (Guangzhou-2)",
+ "cn-south-4": "China (Guangzhou-4)",
+ "cn-southwest-2": "China (Guiyang)",
+ "cn-southwest-3": "China (Guiyang-3)",
+ "cn-north-9": "China (Ulanqab)",
+ "cn-north-2": "China (Beijing-2)",
+ "cn-north-11": "China (Ulanqab-11)",
+ "cn-north-12": "China (Ulanqab-12)",
+ "cn-east-5": "China (Shanghai-5)",
+ # Asia-Pacific Regions
+ "ap-southeast-1": "Hong Kong",
+ "ap-southeast-2": "Singapore",
+ "ap-southeast-3": "Thailand",
+ "ap-southeast-4": "Malaysia",
+ "ap-southeast-5": "Indonesia (Jakarta)",
+ "my-kualalumpur-1": "Malaysia (Kuala Lumpur)",
+ # Africa Regions
+ "af-south-1": "South Africa",
+ "af-north-1": "Egypt (Cairo)",
+ # Americas Regions
+ "sa-brazil-1": "Brazil",
+ "la-north-2": "Mexico",
+ "la-south-2": "Chile (Santiago)",
+ "na-mexico-1": "Mexico (Mexico City)",
+ # Europe Regions
+ "eu-west-0": "Ireland",
+ "eu-west-101": "Ireland (Dublin)",
+ # Middle East Regions
+ "me-east-1": "UAE (Dubai)",
+ "ae-ad-1": "UAE (Abu Dhabi)",
+ "tr-west-1": "Türkiye (Istanbul)",
+ # Russia Regions
+ "ru-moscow-1": "Russia (Moscow-1)",
+}
+
+# Global services that don't require region specification
+HUAWEICLOUD_GLOBAL_SERVICES = [
+ "iam", # Identity and Access Management
+ "bss", # Billing and Subscription Service
+ "organizations", # Organizations
+]
+
+# Service endpoints mapping for services that don't follow standard pattern
+# Format: service_name: endpoint_template
+HUAWEICLOUD_SERVICE_ENDPOINTS = {
+ # Standard pattern is {service}.{region}.myhuaweicloud.com
+ # Some services may have different patterns
+ "iam": "iam.myhuaweicloud.com", # IAM is global
+ "bss": "bss.myhuaweicloud.com", # BSS is global
+ "organizations": "organizations.myhuaweicloud.com", # Organizations is global
+}
+
+# Huawei Cloud service names mapping to SDK package names
+HUAWEICLOUD_SERVICE_SDK_MAPPING = {
+ "obs": "huaweicloudsdkobs",
+ "ecs": "huaweicloudsdkecs",
+ "vpc": "huaweicloudsdkvpc",
+ "iam": "huaweicloudsdkiam",
+ "rds": "huaweicloudsdkrds",
+ "cts": "huaweicloudsdkcts",
+ "kms": "huaweicloudsdkkms",
+ "waf": "huaweicloudsdkwaf",
+ "elb": "huaweicloudsdkelb",
+ "evs": "huaweicloudsdkevs",
+ "eip": "huaweicloudsdkeip",
+ "ims": "huaweicloudsdkims",
+ "dns": "huaweicloudsdkdns",
+ "antiddos": "huaweicloudsdkantiddos",
+ "cbr": "huaweicloudsdkcbr",
+ "cce": "huaweicloudsdkcce",
+ "ces": "huaweicloudsdkces",
+ "css": "huaweicloudsdkcss",
+ "dcs": "huaweicloudsdkdcs",
+ "ddm": "huaweicloudsdkddm",
+ "dds": "huaweicloudsdkdds",
+ "dgc": "huaweicloudsdkdgc",
+ "dli": "huaweicloudsdkdli",
+ "dms": "huaweicloudsdkdms",
+ "drs": "huaweicloudsdkdrs",
+ "dws": "huaweicloudsdkdws",
+ "functiongraph": "huaweicloudsdkfunctiongraph",
+ "ges": "huaweicloudsdkges",
+ "hss": "huaweicloudsdkhss",
+ "live": "huaweicloudsdklive",
+ "lts": "huaweicloudsdklts",
+ "mrs": "huaweicloudsdkmrs",
+ "nat": "huaweicloudsdknat",
+ "rms": "huaweicloudsdkrms",
+ "rocketmq": "huaweicloudsdkrocketmq",
+ "servicestage": "huaweicloudsdkservicestage",
+ "smn": "huaweicloudsdksmn",
+ "sms": "huaweicloudsdksms",
+ "vpn": "huaweicloudsdkvpn",
+}
diff --git a/prowler/providers/huaweicloud/exceptions/__init__.py b/prowler/providers/huaweicloud/exceptions/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/exceptions/exceptions.py b/prowler/providers/huaweicloud/exceptions/exceptions.py
new file mode 100644
index 0000000000..28bfb56822
--- /dev/null
+++ b/prowler/providers/huaweicloud/exceptions/exceptions.py
@@ -0,0 +1,132 @@
+from prowler.exceptions.exceptions import ProwlerException
+
+
+# Exceptions codes from 19000 to 19099 are reserved for Huawei Cloud exceptions
+class HuaweiCloudBaseException(ProwlerException):
+ """Base class for Huawei Cloud errors."""
+
+ HUAWEICLOUD_ERROR_CODES = {
+ (19000, "HuaweiCloudCredentialsError"): {
+ "message": "Huawei Cloud credentials not found or invalid",
+ "remediation": "Provide valid Huawei Cloud credentials via the HUAWEICLOUD_ACCESS_KEY_ID and HUAWEICLOUD_SECRET_ACCESS_KEY environment variables.",
+ },
+ (19001, "HuaweiCloudAuthenticationError"): {
+ "message": "Huawei Cloud authentication failed",
+ "remediation": "Verify the Access Key ID, Secret Access Key and Project/Domain ID, and ensure the credentials have the required IAM read permissions.",
+ },
+ (19002, "HuaweiCloudSetUpSessionError"): {
+ "message": "Huawei Cloud session setup failed",
+ "remediation": "Review the Huawei Cloud SDK initialization parameters and credentials.",
+ },
+ (19003, "HuaweiCloudIdentityError"): {
+ "message": "Unable to retrieve Huawei Cloud identity or account information",
+ "remediation": "Ensure the credentials allow access to the IAM Keystone APIs (list auth domains/projects and show user).",
+ },
+ (19004, "HuaweiCloudInvalidRegionError"): {
+ "message": "One or more requested Huawei Cloud regions are invalid",
+ "remediation": "Pass a valid Huawei Cloud region id to --region. See https://developer.huaweicloud.com/intl/en-us/endpoint for the current list.",
+ },
+ (19005, "HuaweiCloudInvalidProviderIdError"): {
+ "message": "The provided Huawei Cloud account id does not match the authenticated account",
+ "remediation": "Ensure the credentials belong to the expected Huawei Cloud account id.",
+ },
+ (19006, "HuaweiCloudServiceError"): {
+ "message": "Huawei Cloud service error",
+ "remediation": "Review the requested service and region, and check the Huawei Cloud API documentation for more details.",
+ },
+ (19007, "HuaweiCloudAssumeRoleError"): {
+ "message": "Failed to assume the Huawei Cloud agency",
+ "remediation": "Verify HUAWEICLOUD_AGENCY_NAME and the target account (HUAWEICLOUD_ASSUME_DOMAIN_ID or HUAWEICLOUD_ASSUME_DOMAIN_NAME), and ensure the agency delegates the required permissions to the authenticated account.",
+ },
+ }
+
+ def __init__(self, code, file=None, original_exception=None, message=None):
+ provider = "HuaweiCloud"
+ error_info = self.HUAWEICLOUD_ERROR_CODES.get((code, self.__class__.__name__))
+ if error_info is None:
+ error_info = {
+ "message": message or "Unknown Huawei Cloud error",
+ "remediation": "Check the Huawei Cloud API documentation for more details.",
+ }
+ elif message:
+ error_info = error_info.copy()
+ error_info["message"] = message
+ super().__init__(
+ code=code,
+ source=provider,
+ file=file,
+ original_exception=original_exception,
+ error_info=error_info,
+ )
+
+
+class HuaweiCloudCredentialsError(HuaweiCloudBaseException):
+ """Exception for Huawei Cloud credential errors."""
+
+ def __init__(self, file=None, original_exception=None, message=None):
+ super().__init__(
+ 19000, file=file, original_exception=original_exception, message=message
+ )
+
+
+class HuaweiCloudAuthenticationError(HuaweiCloudBaseException):
+ """Exception for Huawei Cloud authentication errors."""
+
+ def __init__(self, file=None, original_exception=None, message=None):
+ super().__init__(
+ 19001, file=file, original_exception=original_exception, message=message
+ )
+
+
+class HuaweiCloudSetUpSessionError(HuaweiCloudBaseException):
+ """Exception for Huawei Cloud session setup errors."""
+
+ def __init__(self, file=None, original_exception=None, message=None):
+ super().__init__(
+ 19002, file=file, original_exception=original_exception, message=message
+ )
+
+
+class HuaweiCloudIdentityError(HuaweiCloudBaseException):
+ """Exception for Huawei Cloud identity errors."""
+
+ def __init__(self, file=None, original_exception=None, message=None):
+ super().__init__(
+ 19003, file=file, original_exception=original_exception, message=message
+ )
+
+
+class HuaweiCloudInvalidRegionError(HuaweiCloudBaseException):
+ """Exception for invalid Huawei Cloud region filters."""
+
+ def __init__(self, file=None, original_exception=None, message=None):
+ super().__init__(
+ 19004, file=file, original_exception=original_exception, message=message
+ )
+
+
+class HuaweiCloudInvalidProviderIdError(HuaweiCloudBaseException):
+ """Exception for Huawei Cloud account/provider id mismatch."""
+
+ def __init__(self, file=None, original_exception=None, message=None):
+ super().__init__(
+ 19005, file=file, original_exception=original_exception, message=message
+ )
+
+
+class HuaweiCloudServiceError(HuaweiCloudBaseException):
+ """Exception for Huawei Cloud service errors."""
+
+ def __init__(self, file=None, original_exception=None, message=None):
+ super().__init__(
+ 19006, file=file, original_exception=original_exception, message=message
+ )
+
+
+class HuaweiCloudAssumeRoleError(HuaweiCloudBaseException):
+ """Exception for Huawei Cloud agency (assume-role) errors."""
+
+ def __init__(self, file=None, original_exception=None, message=None):
+ super().__init__(
+ 19007, file=file, original_exception=original_exception, message=message
+ )
diff --git a/prowler/providers/huaweicloud/huaweicloud_provider.py b/prowler/providers/huaweicloud/huaweicloud_provider.py
new file mode 100644
index 0000000000..04591bc550
--- /dev/null
+++ b/prowler/providers/huaweicloud/huaweicloud_provider.py
@@ -0,0 +1,957 @@
+import os
+import pathlib
+
+from colorama import Fore, Style
+
+from prowler.config.config import (
+ default_config_file_path,
+ get_default_mute_file_path,
+ load_and_validate_config_file,
+)
+from prowler.lib.logger import logger
+from prowler.lib.utils.utils import print_boxes
+from prowler.providers.common.models import Audit_Metadata, Connection
+from prowler.providers.common.provider import Provider
+from prowler.providers.huaweicloud.config import (
+ HUAWEICLOUD_DEFAULT_REGION,
+ HUAWEICLOUD_REGIONS,
+)
+from prowler.providers.huaweicloud.exceptions.exceptions import (
+ HuaweiCloudAssumeRoleError,
+ HuaweiCloudAuthenticationError,
+ HuaweiCloudCredentialsError,
+ HuaweiCloudIdentityError,
+ HuaweiCloudInvalidProviderIdError,
+ HuaweiCloudInvalidRegionError,
+ HuaweiCloudSetUpSessionError,
+)
+from prowler.providers.huaweicloud.lib.mutelist.mutelist import HuaweiCloudMutelist
+from prowler.providers.huaweicloud.models import (
+ HuaweiCloudCallerIdentity,
+ HuaweiCloudCredentials,
+ HuaweiCloudIdentityInfo,
+ HuaweiCloudSession,
+ _endpoint_host,
+ _iam_endpoint_for_region,
+)
+
+
+class HuaweicloudProvider(Provider):
+ """
+ HuaweicloudProvider class is the main class for the Huawei Cloud provider.
+
+ This class is responsible for initializing the Huawei Cloud provider, setting up the session,
+ validating the credentials, and setting the identity.
+
+ Attributes:
+ _type (str): The provider type.
+ _identity (HuaweiCloudIdentityInfo): The Huawei Cloud provider identity information.
+ _session (HuaweiCloudSession): The Huawei Cloud provider session.
+ _audit_resources (list): The list of resources to audit.
+ _audit_config (dict): The audit configuration.
+ _enabled_regions (set): The set of enabled regions.
+ _mutelist (HuaweiCloudMutelist): The Huawei Cloud provider mutelist.
+ audit_metadata (Audit_Metadata): The audit metadata.
+ """
+
+ _type: str = "huaweicloud"
+ _identity: HuaweiCloudIdentityInfo
+ _session: HuaweiCloudSession
+ _audit_resources: list = []
+ _audit_config: dict
+ _fixer_config: dict
+ _regions: list = []
+ _mutelist: HuaweiCloudMutelist
+ audit_metadata: Audit_Metadata
+
+ def __init__(
+ self,
+ access_key_id: str = None,
+ secret_access_key: str = None,
+ domain_id: str = None,
+ security_token: str = None,
+ agency_name: str = None,
+ assume_domain_id: str = None,
+ assume_domain_name: str = None,
+ cloud: str = None,
+ regions: list = None,
+ config_path: str = None,
+ config_content: dict = None,
+ mutelist_path: str = None,
+ mutelist_content: dict = None,
+ fixer_config: dict = {},
+ ):
+ """
+ Initialize the HuaweicloudProvider.
+
+ Credentials are read from environment variables. The credential
+ arguments below exist for programmatic use only (they fall back to the
+ environment variables when not provided) and are never populated from
+ the CLI.
+
+ Args:
+ access_key_id: Huawei Cloud Access Key ID
+ secret_access_key: Huawei Cloud Secret Access Key
+ domain_id: Huawei Cloud Domain ID
+ security_token: Security Token (for temporary credentials)
+ agency_name: Name of the agency to assume in the target account
+ assume_domain_id: Domain ID of the target (delegating) account
+ assume_domain_name: Domain name of the target (delegating) account
+ cloud: Huawei Cloud instance to scan (international, europe, china)
+ when no explicit regions are given; expands to that cloud's regions
+ regions: List of Huawei Cloud region IDs to audit
+ config_path: Path to the configuration file
+ config_content: Content of the configuration file
+ mutelist_path: Path to the mutelist file
+ mutelist_content: Content of the mutelist file
+ fixer_config: Fixer configuration dictionary
+
+ Raises:
+ HuaweiCloudSetUpSessionError: If an error occurs during the setup process.
+ HuaweiCloudAuthenticationError: If authentication fails.
+
+ Usage:
+ - Huawei Cloud credentials are set via environment variables:
+ - export HUAWEICLOUD_ACCESS_KEY_ID=
+ - export HUAWEICLOUD_SECRET_ACCESS_KEY=
+ - export HUAWEICLOUD_DOMAIN_ID=
+ The per-region project_id is resolved automatically by the SDK.
+ - To assume an agency in a target account, additionally set:
+ - export HUAWEICLOUD_AGENCY_NAME=
+ - export HUAWEICLOUD_ASSUME_DOMAIN_ID=
+ (or HUAWEICLOUD_ASSUME_DOMAIN_NAME=)
+ - To create a new Huawei Cloud provider object:
+ - huaweicloud = HuaweicloudProvider()
+ - huaweicloud = HuaweicloudProvider(regions=["cn-north-4", "cn-east-3"])
+ """
+ logger.info("Initializing Huawei Cloud Provider ...")
+
+ # The --region flag takes precedence; otherwise fall back to the
+ # HUAWEICLOUD_REGION (or HW_REGION) env var, then the --cloud selector
+ # (or HUAWEICLOUD_CLOUD), which expands to every region of that Huawei
+ # Cloud instance so non-China accounts do not need to list regions.
+ regions = self._resolve_regions(regions, cloud)
+
+ # Resolve the validation region up front so it can be used both for
+ # credential validation and for agency assumption. The default
+ # (cn-north-4) is a China region that non-China accounts cannot reach,
+ # and the region must expose an IAM endpoint (some dedicated regions
+ # do not) for either operation to work.
+ validation_region = self._validation_region(regions)
+
+ logger.info("Setting up Huawei Cloud session ...")
+ self._session = self.setup_session(
+ access_key_id=access_key_id,
+ secret_access_key=secret_access_key,
+ domain_id=domain_id,
+ security_token=security_token,
+ agency_name=agency_name,
+ assume_domain_id=assume_domain_id,
+ assume_domain_name=assume_domain_name,
+ region=validation_region,
+ )
+ logger.info("Huawei Cloud session configured successfully")
+
+ # Validate credentials against a region the account can actually reach.
+ logger.info(f"Validating credentials in region {validation_region} ...")
+ caller_identity = self.validate_credentials(
+ session=self._session,
+ region=validation_region,
+ )
+ logger.info("Credentials validated")
+
+ profile_region = self.get_profile_region()
+
+ self._identity = self.set_identity(
+ caller_identity=caller_identity,
+ profile="default",
+ regions=set(),
+ profile_region=profile_region,
+ )
+
+ self._regions = self.get_regions_to_audit(regions)
+
+ if config_content:
+ self._audit_config = config_content
+ else:
+ if not config_path:
+ config_path = default_config_file_path
+ self._audit_config = load_and_validate_config_file(self._type, config_path)
+
+ self._fixer_config = fixer_config
+
+ if mutelist_content:
+ self._mutelist = HuaweiCloudMutelist(
+ mutelist_content=mutelist_content,
+ )
+ else:
+ if not mutelist_path:
+ mutelist_path = get_default_mute_file_path(self.type)
+ self._mutelist = HuaweiCloudMutelist(
+ mutelist_path=mutelist_path,
+ )
+
+ self._audit_resources = []
+
+ self.audit_metadata = Audit_Metadata(
+ services_scanned=0,
+ expected_checks=[],
+ completed_checks=0,
+ audit_progress=0,
+ )
+
+ Provider.set_global_provider(self)
+
+ @property
+ def type(self) -> str:
+ return self._type
+
+ @property
+ def identity(self) -> HuaweiCloudIdentityInfo:
+ return self._identity
+
+ @property
+ def session(self):
+ return self._session
+
+ @property
+ def audit_config(self) -> dict:
+ return self._audit_config
+
+ @property
+ def fixer_config(self) -> dict:
+ return self._fixer_config
+
+ @property
+ def audit_resources(self) -> list:
+ return self._audit_resources
+
+ @property
+ def mutelist(self) -> HuaweiCloudMutelist:
+ return self._mutelist
+
+ @property
+ def regions(self) -> list:
+ return self._regions
+
+ @property
+ def enabled_regions(self) -> set:
+ return set([r.region_id for r in self._regions])
+
+ # Huawei Cloud runs separate clouds. International and China share the .com
+ # endpoints (China regions are the cn-* ones); Europe uses the .eu
+ # endpoints. An account belongs to a single cloud and can only reach that
+ # cloud's regions.
+ CLOUDS = ("international", "europe", "china")
+ CLOUD_ALIASES = {
+ "eu": "europe",
+ "intl": "international",
+ "com": "international",
+ "cn": "china",
+ }
+
+ @staticmethod
+ def _regions_for_cloud(cloud):
+ """Return the region ids that belong to a Huawei Cloud instance.
+
+ The cloud each region belongs to is derived from its IAM endpoint (.eu
+ for Europe, .com otherwise) and the cn-* prefix (China), so the mapping
+ stays accurate as the SDK's region list changes.
+ """
+ cloud = HuaweicloudProvider.CLOUD_ALIASES.get(cloud, cloud)
+ result = []
+ for region in HUAWEICLOUD_REGIONS:
+ endpoint = _iam_endpoint_for_region(region) or ""
+ is_europe = _endpoint_host(endpoint).endswith(".myhuaweicloud.eu")
+ is_china = region.startswith("cn-")
+ if cloud == "europe" and is_europe:
+ result.append(region)
+ elif cloud == "china" and is_china:
+ result.append(region)
+ elif cloud == "international" and not is_europe and not is_china:
+ result.append(region)
+ return sorted(result)
+
+ @staticmethod
+ def _resolve_regions(regions, cloud=None):
+ """Resolve the regions to audit.
+
+ Precedence: the --region flag (``regions``) wins; then the
+ HUAWEICLOUD_REGION (or HW_REGION) environment variable (one or more
+ comma/space-separated region ids); then the --cloud selector (or
+ HUAWEICLOUD_CLOUD / HW_CLOUD), which expands to every region of that
+ Huawei Cloud instance.
+ """
+ if regions:
+ return regions
+ env_region = os.environ.get("HUAWEICLOUD_REGION") or os.environ.get("HW_REGION")
+ if env_region:
+ return env_region.replace(",", " ").split()
+ cloud = (
+ cloud or os.environ.get("HUAWEICLOUD_CLOUD") or os.environ.get("HW_CLOUD")
+ )
+ if cloud:
+ cloud_regions = HuaweicloudProvider._regions_for_cloud(
+ cloud.strip().lower()
+ )
+ if cloud_regions:
+ return cloud_regions
+ return regions
+
+ @staticmethod
+ def _validation_region(regions):
+ """Pick a region to validate credentials against.
+
+ Credential validation builds an IAM client, so the region must expose
+ an IAM endpoint. Some Huawei Cloud regions (e.g. dedicated ones) are
+ not in the IAM SDK; when only such regions are requested, validate
+ against an IAM-capable region in the same cloud so the right endpoint
+ is used.
+ """
+ if not regions:
+ return HUAWEICLOUD_DEFAULT_REGION
+ for region in sorted(regions):
+ if _iam_endpoint_for_region(region):
+ return region
+ # None of the requested regions expose IAM. Fall back to an IAM-capable
+ # region in the same cloud (inferred from the cn- prefix; Europe's only
+ # region is IAM-capable, so it is already handled above).
+ cloud = "china" if sorted(regions)[0].startswith("cn-") else "international"
+ for region in HuaweicloudProvider._regions_for_cloud(cloud):
+ if _iam_endpoint_for_region(region):
+ return region
+ return HUAWEICLOUD_DEFAULT_REGION
+
+ @staticmethod
+ def setup_session(
+ access_key_id: str = None,
+ secret_access_key: str = None,
+ domain_id: str = None,
+ security_token: str = None,
+ agency_name: str = None,
+ assume_domain_id: str = None,
+ assume_domain_name: str = None,
+ region: str = None,
+ ) -> HuaweiCloudSession:
+ """
+ Set up the Huawei Cloud session.
+
+ Each argument falls back to its environment variable when not provided.
+ When an agency name is supplied (HUAWEICLOUD_AGENCY_NAME) the base
+ credentials are used to assume the agency in the target account
+ (HUAWEICLOUD_ASSUME_DOMAIN_ID or HUAWEICLOUD_ASSUME_DOMAIN_NAME) and the
+ session uses the resulting temporary credentials.
+
+ Args:
+ access_key_id: Huawei Cloud Access Key ID
+ secret_access_key: Huawei Cloud Secret Access Key
+ domain_id: Huawei Cloud Domain ID
+ security_token: Security Token (for temporary credentials)
+ agency_name: Name of the agency to assume in the target account
+ assume_domain_id: Domain ID of the target (delegating) account
+ assume_domain_name: Domain name of the target (delegating) account
+
+ Returns:
+ HuaweiCloudSession object
+
+ Raises:
+ HuaweiCloudSetUpSessionError: If session setup fails
+ HuaweiCloudCredentialsError: If no credentials are found
+ HuaweiCloudAssumeRoleError: If assuming the agency fails
+ """
+ try:
+ logger.debug("Creating Huawei Cloud session ...")
+
+ if not access_key_id:
+ if "HUAWEICLOUD_ACCESS_KEY_ID" in os.environ:
+ access_key_id = os.environ["HUAWEICLOUD_ACCESS_KEY_ID"]
+ elif "HW_ACCESS_KEY" in os.environ:
+ access_key_id = os.environ["HW_ACCESS_KEY"]
+
+ if not secret_access_key:
+ if "HUAWEICLOUD_SECRET_ACCESS_KEY" in os.environ:
+ secret_access_key = os.environ["HUAWEICLOUD_SECRET_ACCESS_KEY"]
+ elif "HW_SECRET_KEY" in os.environ:
+ secret_access_key = os.environ["HW_SECRET_KEY"]
+
+ if not domain_id:
+ if "HUAWEICLOUD_DOMAIN_ID" in os.environ:
+ domain_id = os.environ["HUAWEICLOUD_DOMAIN_ID"]
+ elif "HW_DOMAIN_ID" in os.environ:
+ domain_id = os.environ["HW_DOMAIN_ID"]
+
+ if not security_token and "HUAWEICLOUD_SECURITY_TOKEN" in os.environ:
+ security_token = os.environ["HUAWEICLOUD_SECURITY_TOKEN"]
+
+ if not agency_name:
+ agency_name = os.environ.get("HUAWEICLOUD_AGENCY_NAME")
+ if not assume_domain_id:
+ assume_domain_id = os.environ.get("HUAWEICLOUD_ASSUME_DOMAIN_ID")
+ if not assume_domain_name:
+ assume_domain_name = os.environ.get("HUAWEICLOUD_ASSUME_DOMAIN_NAME")
+
+ if not access_key_id or not secret_access_key:
+ raise HuaweiCloudCredentialsError(
+ file=pathlib.Path(__file__).name,
+ )
+
+ credentials = HuaweiCloudCredentials(
+ ak=access_key_id,
+ sk=secret_access_key,
+ security_token=security_token,
+ domain_id=domain_id,
+ )
+
+ if agency_name:
+ credentials = HuaweicloudProvider.assume_agency(
+ credentials=credentials,
+ agency_name=agency_name,
+ assume_domain_id=assume_domain_id,
+ assume_domain_name=assume_domain_name,
+ region=region or HUAWEICLOUD_DEFAULT_REGION,
+ )
+
+ return HuaweiCloudSession(credentials)
+
+ except (HuaweiCloudCredentialsError, HuaweiCloudAssumeRoleError):
+ raise
+ except Exception as error:
+ logger.critical(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+ raise HuaweiCloudSetUpSessionError(
+ file=pathlib.Path(__file__).name,
+ original_exception=error,
+ )
+
+ @staticmethod
+ def assume_agency(
+ credentials: HuaweiCloudCredentials,
+ agency_name: str,
+ assume_domain_id: str = None,
+ assume_domain_name: str = None,
+ region: str = HUAWEICLOUD_DEFAULT_REGION,
+ ) -> HuaweiCloudCredentials:
+ """
+ Assume a Huawei Cloud agency in the target account.
+
+ Uses the base credentials to call CreateTemporaryAccessKeyByAgency and
+ returns temporary credentials scoped to the agency in the target
+ (delegating) account.
+
+ Args:
+ credentials: The base Huawei Cloud credentials.
+ agency_name: The agency to assume.
+ assume_domain_id: Domain ID of the target (delegating) account.
+ assume_domain_name: Domain name of the target (delegating) account.
+ region: The region used for the IAM call.
+
+ Returns:
+ HuaweiCloudCredentials: Temporary credentials for the agency.
+
+ Raises:
+ HuaweiCloudAssumeRoleError: If the target account is not specified
+ or the agency assumption fails.
+ """
+ if not assume_domain_id and not assume_domain_name:
+ raise HuaweiCloudAssumeRoleError(
+ file=pathlib.Path(__file__).name,
+ message="To assume an agency, set HUAWEICLOUD_ASSUME_DOMAIN_ID or HUAWEICLOUD_ASSUME_DOMAIN_NAME to the target account.",
+ )
+
+ try:
+ from huaweicloudsdkcore.auth.credentials import BasicCredentials
+ from huaweicloudsdkiam.v3 import (
+ AgencyAuth,
+ AgencyAuthIdentity,
+ CreateTemporaryAccessKeyByAgencyRequest,
+ CreateTemporaryAccessKeyByAgencyRequestBody,
+ IamClient,
+ IdentityAssumerole,
+ )
+ from huaweicloudsdkiam.v3.region.iam_region import IamRegion
+
+ basic_creds = BasicCredentials(
+ ak=credentials.ak,
+ sk=credentials.sk,
+ )
+ iam_endpoint = _iam_endpoint_for_region(region)
+ if iam_endpoint:
+ basic_creds.iam_endpoint = iam_endpoint
+ if credentials.domain_id:
+ basic_creds.domain_id = credentials.domain_id
+
+ iam_client = (
+ IamClient.new_builder()
+ .with_credentials(basic_creds)
+ .with_region(IamRegion.value_of(region))
+ .build()
+ )
+
+ # Huawei caps duration at 24h (86400s). Use the max so long-running
+ # scans don't hit token expiry mid-run.
+ assume_role = IdentityAssumerole(
+ agency_name=agency_name,
+ duration_seconds=86400,
+ )
+ if assume_domain_id:
+ assume_role.domain_id = assume_domain_id
+ else:
+ assume_role.domain_name = assume_domain_name
+
+ body = CreateTemporaryAccessKeyByAgencyRequestBody(
+ auth=AgencyAuth(
+ identity=AgencyAuthIdentity(
+ methods=["assume_role"],
+ assume_role=assume_role,
+ )
+ )
+ )
+
+ response = iam_client.create_temporary_access_key_by_agency(
+ CreateTemporaryAccessKeyByAgencyRequest(body=body)
+ )
+ temp = response.credential
+
+ logger.info(
+ f"Assumed Huawei Cloud agency '{agency_name}' in target account "
+ f"{assume_domain_id or assume_domain_name}"
+ )
+
+ expiration = None
+ expires_at = getattr(temp, "expires_at", None)
+ if expires_at:
+ try:
+ from datetime import datetime
+
+ expiration = datetime.fromisoformat(
+ str(expires_at).replace("Z", "+00:00")
+ )
+ except (TypeError, ValueError) as parse_error:
+ logger.debug(
+ f"Could not parse agency credential expiration '{expires_at}': {parse_error}"
+ )
+
+ return HuaweiCloudCredentials(
+ ak=temp.access,
+ sk=temp.secret,
+ security_token=temp.securitytoken,
+ domain_id=assume_domain_id or credentials.domain_id,
+ expiration=expiration,
+ )
+
+ except HuaweiCloudAssumeRoleError:
+ raise
+ except Exception as error:
+ logger.error(
+ f"Could not assume Huawei Cloud agency '{agency_name}': {error}"
+ )
+ raise HuaweiCloudAssumeRoleError(
+ file=pathlib.Path(__file__).name,
+ original_exception=error,
+ )
+
+ @staticmethod
+ def validate_credentials(
+ session: HuaweiCloudSession,
+ region: str = HUAWEICLOUD_DEFAULT_REGION,
+ ) -> HuaweiCloudCallerIdentity:
+ """
+ Validates the Huawei Cloud credentials using IAM API.
+
+ Args:
+ session: The Huawei Cloud session object.
+ region: The region to use for validation.
+
+ Returns:
+ HuaweiCloudCallerIdentity: An object containing the caller identity information.
+
+ Raises:
+ HuaweiCloudAuthenticationError: If credentials are invalid.
+ HuaweiCloudIdentityError: If the account identity cannot be resolved.
+ """
+ try:
+ from huaweicloudsdkcore.auth.credentials import BasicCredentials
+ from huaweicloudsdkiam.v3 import (
+ IamClient,
+ KeystoneListAuthDomainsRequest,
+ KeystoneListAuthProjectsRequest,
+ )
+ from huaweicloudsdkiam.v3.region.iam_region import IamRegion
+
+ creds = session.get_credentials()
+
+ basic_creds = BasicCredentials(ak=creds.ak, sk=creds.sk)
+ # Resolve projects against the region's own IAM endpoint so Huawei
+ # Cloud Europe (.eu) accounts are not rejected by the default .com
+ # global endpoint.
+ iam_endpoint = _iam_endpoint_for_region(region)
+ if iam_endpoint:
+ basic_creds.iam_endpoint = iam_endpoint
+ if creds.security_token:
+ basic_creds.security_token = creds.security_token
+ if creds.domain_id:
+ basic_creds.domain_id = creds.domain_id
+
+ iam_client = (
+ IamClient.new_builder()
+ .with_credentials(basic_creds)
+ .with_region(IamRegion.value_of(region))
+ .build()
+ )
+
+ iam_client.keystone_list_auth_projects(KeystoneListAuthProjectsRequest())
+
+ domain_id = creds.domain_id or ""
+ user_id = ""
+ user_name = ""
+ account_id = domain_id
+ account_name = ""
+
+ try:
+ domain_response = iam_client.keystone_list_auth_domains(
+ KeystoneListAuthDomainsRequest()
+ )
+ if hasattr(domain_response, "domains") and domain_response.domains:
+ for domain in domain_response.domains:
+ if not domain_id:
+ domain_id = getattr(domain, "id", "")
+ if not account_name:
+ account_name = getattr(domain, "name", "")
+ except Exception as domain_error:
+ logger.debug(f"Could not list auth domains: {domain_error}")
+
+ try:
+ from huaweicloudsdkiam.v3 import ShowUserRequest
+
+ user_response = iam_client.show_user(ShowUserRequest(user_id="self"))
+ if hasattr(user_response, "user") and user_response.user:
+ user_id = getattr(user_response.user, "id", "")
+ user_name = getattr(user_response.user, "name", "")
+ except Exception as user_error:
+ logger.debug(f"Could not get current user info: {user_error}")
+
+ if not account_id:
+ account_id = domain_id
+
+ if not account_id:
+ raise HuaweiCloudIdentityError(
+ file=pathlib.Path(__file__).name,
+ message="Could not determine the Huawei Cloud account or domain id from IAM",
+ )
+
+ logger.debug(
+ f"Huawei Cloud IAM validation - Domain ID: {domain_id}, Account ID: {account_id}, User: {user_name}"
+ )
+
+ return HuaweiCloudCallerIdentity(
+ domain_id=domain_id,
+ user_id=user_id,
+ user_name=user_name,
+ account_id=account_id,
+ account_name=account_name,
+ type="user",
+ )
+
+ except (HuaweiCloudAuthenticationError, HuaweiCloudIdentityError):
+ raise
+ except Exception as iam_error:
+ logger.error(f"Could not validate credentials with IAM: {iam_error}")
+ raise HuaweiCloudAuthenticationError(
+ file=pathlib.Path(__file__).name,
+ original_exception=iam_error,
+ )
+
+ @staticmethod
+ def get_profile_region() -> str:
+ """
+ Get the profile region.
+
+ Returns:
+ str: The profile region
+ """
+ return HUAWEICLOUD_DEFAULT_REGION
+
+ @staticmethod
+ def set_identity(
+ caller_identity: HuaweiCloudCallerIdentity,
+ profile: str,
+ regions: set,
+ profile_region: str,
+ ) -> HuaweiCloudIdentityInfo:
+ """
+ Set the Huawei Cloud provider identity information.
+
+ Args:
+ caller_identity: The Huawei Cloud caller identity information.
+ profile: The profile name.
+ regions: A set of regions to audit.
+ profile_region: The profile region.
+
+ Returns:
+ HuaweiCloudIdentityInfo: The Huawei Cloud provider identity information.
+ """
+ logger.info(
+ f"Huawei Cloud Caller Identity Account ID: {caller_identity.account_id}"
+ )
+ logger.info(
+ f"Huawei Cloud Caller Identity Domain ID: {caller_identity.domain_id}"
+ )
+
+ return HuaweiCloudIdentityInfo(
+ account_id=caller_identity.account_id,
+ account_name=caller_identity.account_name,
+ domain_id=caller_identity.domain_id,
+ user_id=caller_identity.user_id,
+ user_name=caller_identity.user_name,
+ identity_type=caller_identity.type,
+ regions=regions,
+ profile=profile,
+ profile_region=profile_region,
+ )
+
+ def get_regions_to_audit(self, regions: list = None) -> list:
+ """
+ get_regions_to_audit returns the list of regions to audit.
+
+ Args:
+ regions: List of Huawei Cloud region IDs to audit.
+
+ Returns:
+ list: The list of HuaweiCloudRegion objects to audit.
+
+ Raises:
+ HuaweiCloudInvalidRegionError: If none of the requested regions are valid.
+ """
+ from prowler.providers.huaweicloud.models import HuaweiCloudRegion
+
+ region_list = []
+
+ if regions:
+ for region_id in regions:
+ if region_id in HUAWEICLOUD_REGIONS:
+ region_list.append(
+ HuaweiCloudRegion(
+ region_id=region_id,
+ region_name=HUAWEICLOUD_REGIONS.get(region_id, region_id),
+ )
+ )
+ else:
+ logger.warning(f"Invalid region: {region_id}. Skipping.")
+ if not region_list:
+ raise HuaweiCloudInvalidRegionError(
+ file=pathlib.Path(__file__).name,
+ message=f"None of the requested regions are valid: {regions}",
+ )
+ else:
+ for region_id, region_name in HUAWEICLOUD_REGIONS.items():
+ region_list.append(
+ HuaweiCloudRegion(
+ region_id=region_id,
+ region_name=region_name,
+ )
+ )
+
+ logger.info(f"Found {len(region_list)} regions to audit")
+
+ if hasattr(self, "_identity") and self._identity:
+ self._identity.regions = set([r.region_id for r in region_list])
+
+ return region_list
+
+ def setup_audit_config(self, input_config: dict) -> dict:
+ """
+ Set up the audit configuration.
+
+ Args:
+ input_config: Input configuration dictionary
+
+ Returns:
+ Audit configuration dictionary
+ """
+ audit_config = {
+ "shodan_api_key": None,
+ **input_config,
+ }
+ return audit_config
+
+ def print_credentials(self):
+ """
+ Print the Huawei Cloud credentials.
+ """
+ regions_str = (
+ ", ".join([r.region_id for r in self._regions])
+ if self._regions
+ else "default regions"
+ )
+
+ report_lines = [
+ f"Huawei Cloud Account: {Fore.YELLOW}{self.identity.account_id}{Style.RESET_ALL}",
+ f"Domain ID: {Fore.YELLOW}{self.identity.domain_id}{Style.RESET_ALL}",
+ f"User Name: {Fore.YELLOW}{self.identity.user_name}{Style.RESET_ALL}",
+ f"Regions: {Fore.YELLOW}{regions_str}{Style.RESET_ALL}",
+ ]
+
+ report_title = (
+ f"{Style.BRIGHT}Using the Huawei Cloud credentials below:{Style.RESET_ALL}"
+ )
+ print_boxes(report_lines, report_title)
+
+ @staticmethod
+ def test_connection(
+ access_key_id: str = None,
+ secret_access_key: str = None,
+ domain_id: str = None,
+ security_token: str = None,
+ raise_on_exception: bool = True,
+ provider_id: str = None,
+ ) -> Connection:
+ """
+ Test the connection to Huawei Cloud with the provided credentials.
+
+ Args:
+ access_key_id: Huawei Cloud Access Key ID
+ secret_access_key: Huawei Cloud Secret Access Key
+ domain_id: Huawei Cloud Domain ID
+ security_token: Security Token (for temporary credentials)
+ raise_on_exception: Whether to raise an exception if an error occurs
+ provider_id: The expected account ID to validate against
+
+ Returns:
+ Connection: An object that contains the result of the test connection operation.
+ """
+ try:
+ session = HuaweicloudProvider.setup_session(
+ access_key_id=access_key_id,
+ secret_access_key=secret_access_key,
+ domain_id=domain_id,
+ security_token=security_token,
+ )
+
+ caller_identity = HuaweicloudProvider.validate_credentials(
+ session=session,
+ region=HUAWEICLOUD_DEFAULT_REGION,
+ )
+
+ if provider_id and caller_identity.account_id != provider_id:
+ raise HuaweiCloudInvalidProviderIdError(
+ file=pathlib.Path(__file__).name,
+ message=f"Provider ID mismatch: expected '{provider_id}', got '{caller_identity.account_id}'",
+ )
+
+ logger.info(
+ f"Successfully connected to Huawei Cloud account: {caller_identity.account_id}"
+ )
+
+ return Connection(is_connected=True)
+
+ except HuaweiCloudSetUpSessionError as setup_error:
+ logger.error(
+ f"{setup_error.__class__.__name__}[{setup_error.__traceback__.tb_lineno}]: {setup_error}"
+ )
+ if raise_on_exception:
+ raise setup_error
+ return Connection(error=setup_error)
+
+ except HuaweiCloudAuthenticationError as auth_error:
+ logger.error(
+ f"{auth_error.__class__.__name__}[{auth_error.__traceback__.tb_lineno}]: {auth_error}"
+ )
+ if raise_on_exception:
+ raise auth_error
+ return Connection(error=auth_error)
+
+ except HuaweiCloudInvalidProviderIdError as provider_id_error:
+ logger.error(
+ f"{provider_id_error.__class__.__name__}[{provider_id_error.__traceback__.tb_lineno}]: {provider_id_error}"
+ )
+ if raise_on_exception:
+ raise provider_id_error
+ return Connection(error=provider_id_error)
+
+ except Exception as error:
+ logger.critical(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+ if raise_on_exception:
+ raise error
+ return Connection(error=error)
+
+ def generate_regional_clients(self, service: str) -> dict:
+ """
+ generate_regional_clients returns a dict with regional clients for the given service.
+
+ Args:
+ service: The service name (e.g., 'ecs', 'vpc', 'obs').
+
+ Returns:
+ dict: A dictionary with region keys and Huawei Cloud service client values.
+ """
+ try:
+ regional_clients = {}
+
+ for region in self._regions:
+ try:
+ client = self._session.client(service, region.region_id)
+ if client:
+ client.region = region.region_id
+ regional_clients[region.region_id] = client
+ except Exception as error:
+ logger.error(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+
+ return regional_clients
+
+ except Exception as error:
+ logger.error(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+ return {}
+
+ def get_default_region(self, service: str) -> str:
+ """
+ Get the default region for a service.
+
+ Returns the first enabled region whose client the service can actually
+ build. Not every region is offered by every service (for example, some
+ regions have no OBS or KMS endpoint), so the alphabetically-first region
+ may be unusable for a given service; probing avoids picking it.
+
+ Args:
+ service: The service name
+
+ Returns:
+ The default region ID
+ """
+ candidates = (
+ sorted(self.enabled_regions)
+ if self.enabled_regions
+ else [HUAWEICLOUD_DEFAULT_REGION]
+ )
+ for region in candidates:
+ try:
+ self._session.client(service, region)
+ return region
+ except Exception:
+ continue
+ return candidates[0]
+
+ def get_checks_to_execute_by_audit_resources(self):
+ """
+ Get the checks to execute based on audit resources.
+
+ Returns:
+ Set of check names to execute
+ """
+ return set()
+
+ @staticmethod
+ def get_regions() -> dict:
+ """
+ Get the available Huawei Cloud regions.
+
+ Returns:
+ dict: A dictionary of region IDs and region names.
+ """
+ return HUAWEICLOUD_REGIONS
diff --git a/prowler/providers/huaweicloud/lib/__init__.py b/prowler/providers/huaweicloud/lib/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/lib/arguments/__init__.py b/prowler/providers/huaweicloud/lib/arguments/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/lib/arguments/arguments.py b/prowler/providers/huaweicloud/lib/arguments/arguments.py
new file mode 100644
index 0000000000..53f023b736
--- /dev/null
+++ b/prowler/providers/huaweicloud/lib/arguments/arguments.py
@@ -0,0 +1,54 @@
+def init_parser(self):
+ """Init the Huawei Cloud Provider CLI parser.
+
+ Huawei Cloud credentials are read exclusively from environment variables
+ to avoid leaking secrets on the command line:
+ - HUAWEICLOUD_ACCESS_KEY_ID (or HW_ACCESS_KEY)
+ - HUAWEICLOUD_SECRET_ACCESS_KEY (or HW_SECRET_KEY)
+ - HUAWEICLOUD_DOMAIN_ID (or HW_DOMAIN_ID)
+ - HUAWEICLOUD_SECURITY_TOKEN (optional, for temporary credentials)
+
+ The per-region project_id is resolved automatically by the SDK, so
+ multi-region scans work without any project configuration.
+
+ The region determines the Huawei Cloud endpoint domain (.com for China and
+ International, .eu for Huawei Cloud Europe). Set it with the --region flag
+ or the HUAWEICLOUD_REGION (or HW_REGION) environment variable; --region
+ takes precedence. Non-China accounts (International, Europe) must select a
+ region they can reach, e.g. eu-west-101 for Huawei Cloud Europe.
+
+ To scan every region of a Huawei Cloud instance without listing them, use
+ the --cloud selector (or the HUAWEICLOUD_CLOUD env var): international,
+ europe, or china. It auto-selects that cloud's regions and endpoint. An
+ explicit --region (or HUAWEICLOUD_REGION) overrides it.
+
+ To assume an agency in a target account, additionally set:
+ - HUAWEICLOUD_AGENCY_NAME
+ - HUAWEICLOUD_ASSUME_DOMAIN_ID (or HUAWEICLOUD_ASSUME_DOMAIN_NAME)
+ """
+ huaweicloud_parser = self.subparsers.add_parser(
+ "huaweicloud",
+ parents=[self.common_providers_parser],
+ help="Huawei Cloud Provider",
+ )
+
+ huaweicloud_regions_subparser = huaweicloud_parser.add_argument_group(
+ "Huawei Cloud Regions"
+ )
+ huaweicloud_regions_subparser.add_argument(
+ "--region",
+ "--filter-region",
+ "-f",
+ nargs="+",
+ dest="regions",
+ help="Huawei Cloud region IDs to run Prowler against (e.g., eu-west-101, ap-southeast-1, cn-north-4). Overrides the HUAWEICLOUD_REGION environment variable and the --cloud selector.",
+ )
+ huaweicloud_regions_subparser.add_argument(
+ "--cloud",
+ dest="cloud",
+ choices=["international", "europe", "china"],
+ default=None,
+ help="Scan every region of a Huawei Cloud instance (international, europe, or china) without listing regions. Also selects the matching endpoint (.eu for europe, .com otherwise). Overridden by --region. Defaults to the HUAWEICLOUD_CLOUD environment variable.",
+ )
+
+ huaweicloud_parser.set_defaults(provider="huaweicloud")
diff --git a/prowler/providers/huaweicloud/lib/mutelist/__init__.py b/prowler/providers/huaweicloud/lib/mutelist/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/lib/mutelist/mutelist.py b/prowler/providers/huaweicloud/lib/mutelist/mutelist.py
new file mode 100644
index 0000000000..ca5d770309
--- /dev/null
+++ b/prowler/providers/huaweicloud/lib/mutelist/mutelist.py
@@ -0,0 +1,31 @@
+from prowler.lib.check.models import CheckReportHuaweiCloud
+from prowler.lib.mutelist.mutelist import Mutelist
+from prowler.lib.outputs.utils import unroll_dict, unroll_tags
+
+
+class HuaweiCloudMutelist(Mutelist):
+ """Huawei Cloud-specific mutelist helper."""
+
+ def is_finding_muted(
+ self,
+ finding: CheckReportHuaweiCloud,
+ account_id: str,
+ ) -> bool:
+ """
+ Check if a Huawei Cloud finding is muted.
+
+ Args:
+ finding: CheckReportHuaweiCloud instance containing check metadata,
+ region, resource info, and tags.
+ account_id: The Huawei Cloud account ID to use for mutelist evaluation.
+
+ Returns:
+ True if the finding is muted, False otherwise.
+ """
+ return self.is_muted(
+ account_id,
+ finding.check_metadata.CheckID,
+ finding.region or "",
+ finding.resource_id or finding.resource_name,
+ unroll_dict(unroll_tags(finding.resource_tags)),
+ )
diff --git a/prowler/providers/huaweicloud/lib/service/__init__.py b/prowler/providers/huaweicloud/lib/service/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/lib/service/service.py b/prowler/providers/huaweicloud/lib/service/service.py
new file mode 100644
index 0000000000..bc1c902c70
--- /dev/null
+++ b/prowler/providers/huaweicloud/lib/service/service.py
@@ -0,0 +1,180 @@
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from typing import Any, Dict
+
+from prowler.lib.logger import logger
+
+MAX_WORKERS = 10
+
+
+class HuaweiCloudService:
+ """
+ The HuaweiCloudService class offers a parent class for each Huawei Cloud Service to generate:
+ - Huawei Cloud Regional Clients
+ - Shared information like the account ID, the checks audited
+ - Thread pool for the __threading_call__
+ - Handles if the service is Regional or Global
+ """
+
+ def __init__(self, service: str, provider, global_service: bool = False):
+ """
+ Initialize the HuaweiCloudService.
+
+ Args:
+ service: The service name (e.g., 'iam', 'ecs', 'vpc')
+ provider: The HuaweicloudProvider instance
+ global_service: Whether this is a global service (default: False)
+ """
+ # Audit Information
+ self.provider = provider
+ self.audited_account = provider.identity.account_id
+ self.audited_account_name = provider.identity.account_name
+ self.audit_resources = provider.audit_resources
+ self.audited_checks = provider.audit_metadata.expected_checks
+ self.audit_config = provider.audit_config
+
+ # Session
+ self.session = provider.session
+
+ # Service name
+ self.service = service.lower() if not service.islower() else service
+
+ # Thread pool for __threading_call__
+ self.thread_pool = ThreadPoolExecutor(max_workers=MAX_WORKERS)
+
+ # Generate Regional Clients
+ self.regional_clients: Dict[str, Any] = {}
+ if not global_service:
+ self.regional_clients = provider.generate_regional_clients(self.service)
+
+ # Get default region and client. get_default_region already probes for
+ # a region the service supports, but fall back defensively so a service
+ # that no enabled region offers cannot abort the whole scan at
+ # construction time.
+ self.region = provider.get_default_region(self.service)
+ try:
+ self.client = self.session.client(self.service, self.region)
+ except Exception:
+ if self.regional_clients:
+ self.region = next(iter(self.regional_clients))
+ self.client = self.regional_clients[self.region]
+ else:
+ from prowler.providers.huaweicloud.config import (
+ HUAWEICLOUD_DEFAULT_REGION,
+ )
+
+ logger.error(
+ f"{self.service.upper()} - No enabled region offers this "
+ f"service; falling back to {HUAWEICLOUD_DEFAULT_REGION}"
+ )
+ self.region = HUAWEICLOUD_DEFAULT_REGION
+ self.client = self.session.client(
+ self.service, HUAWEICLOUD_DEFAULT_REGION
+ )
+
+ def __get_session__(self):
+ """Get the session."""
+ return self.session
+
+ def __get_client__(self, region: str = None):
+ """
+ Get a client for the specified region or the default region.
+
+ Args:
+ region: The region to get the client for (optional)
+
+ Returns:
+ A client instance for the service
+ """
+ if region and region in self.regional_clients:
+ return self.regional_clients[region]
+ return self.client
+
+ @staticmethod
+ def _is_retriable_error(error: Exception) -> bool:
+ """Return True when a Huawei Cloud API error is worth retrying once."""
+ error_code = getattr(error, "error_code", "") or getattr(error, "code", "")
+ status_code = getattr(error, "status_code", None) or getattr(
+ error, "statusCode", None
+ )
+ message = str(error)
+
+ retriable_codes = {
+ "ServiceUnavailable",
+ "Throttling",
+ "Throttling.User",
+ "IAM.0064",
+ "ECS.0005",
+ }
+ retriable_substrings = (
+ "Connection reset by peer",
+ "Connection aborted",
+ "ConnectTimeoutError",
+ "ReadTimeout",
+ "timed out",
+ "temporarily unavailable",
+ )
+
+ return (
+ error_code in retriable_codes
+ or status_code in {429, 500, 502, 503, 504}
+ or any(fragment in message for fragment in retriable_substrings)
+ )
+
+ def _call_with_retries(self, func, *args, retries: int = 1, **kwargs):
+ """Call a function and retry once for transient Huawei Cloud API failures."""
+ last_error = None
+
+ for attempt in range(retries + 1):
+ try:
+ return func(*args, **kwargs)
+ except Exception as error:
+ last_error = error
+ if attempt >= retries or not self._is_retriable_error(error):
+ raise
+
+ raise last_error
+
+ def __threading_call__(self, call, iterator=None):
+ """
+ Execute a function across multiple regions or items using threads.
+
+ Args:
+ call: The function to call
+ iterator: The items to iterate over (default: regional clients)
+ """
+ # Use the provided iterator, or default to self.regional_clients
+ items = iterator if iterator is not None else self.regional_clients.values()
+ # Determine the total count for logging
+ item_count = (
+ len(list(items)) if iterator is not None else len(self.regional_clients)
+ )
+
+ # Trim leading and trailing underscores from the call's name
+ call_name = call.__name__.strip("_")
+ # Add Capitalization
+ call_name = " ".join([x.capitalize() for x in call_name.split("_")])
+
+ # Print a message based on the call's name
+ if iterator is None:
+ logger.info(
+ f"{self.service.upper()} - Starting threads for '{call_name}' function across {item_count} regions..."
+ )
+ else:
+ logger.info(
+ f"{self.service.upper()} - Starting threads for '{call_name}' function to process {item_count} items..."
+ )
+
+ # Re-create the iterator for submission if it was a generator
+ items = iterator if iterator is not None else self.regional_clients.values()
+
+ # Submit tasks to the thread pool
+ futures = [self.thread_pool.submit(call, item) for item in items]
+
+ # Wait for all tasks to complete
+ for future in as_completed(futures):
+ try:
+ future.result() # Raises exceptions from the thread, if any
+ except Exception:
+ # Per-region failures are already logged inside each called
+ # function; swallow here so one region cannot abort the scan.
+ pass
diff --git a/prowler/providers/huaweicloud/models.py b/prowler/providers/huaweicloud/models.py
new file mode 100644
index 0000000000..38cb1acaab
--- /dev/null
+++ b/prowler/providers/huaweicloud/models.py
@@ -0,0 +1,471 @@
+"""Huawei Cloud Provider Models"""
+
+from datetime import datetime
+from typing import Any, Optional
+from urllib.parse import urlparse
+
+from pydantic.v1 import BaseModel, validator
+
+from prowler.lib.logger import logger
+from prowler.providers.common.models import ProviderOutputOptions
+from prowler.providers.huaweicloud.config import (
+ HUAWEICLOUD_DEFAULT_REGION,
+ HUAWEICLOUD_SDK_CONNECT_TIMEOUT,
+ HUAWEICLOUD_SDK_READ_TIMEOUT,
+)
+from prowler.providers.huaweicloud.exceptions.exceptions import (
+ HuaweiCloudServiceError,
+)
+
+
+def _iam_endpoint_for_region(region: str):
+ """Return the IAM endpoint for a region, or None if unknown.
+
+ Huawei Cloud runs separate clouds per TLD (International .com, Europe .eu,
+ China). The region-specific IAM endpoint (e.g. iam.eu-west-101.myhuawei
+ cloud.eu) is the only one that recognizes that cloud's accounts, so it must
+ be used for credential validation and per-region project resolution.
+ """
+ try:
+ from huaweicloudsdkiam.v3.region.iam_region import IamRegion
+
+ return IamRegion.value_of(region).endpoints[0]
+ except Exception:
+ return None
+
+
+def _endpoint_host(endpoint: str) -> str:
+ """Return the lowercased host of an endpoint URL, or "" if unparseable.
+
+ Used so cloud detection matches on the URL host's TLD suffix instead of an
+ arbitrary substring, which avoids being fooled by a lookalike host such as
+ ``iam.myhuaweicloud.com.example.eu``.
+ """
+ if not endpoint:
+ return ""
+ parsed = urlparse(endpoint if "://" in endpoint else f"//{endpoint}")
+ return (parsed.hostname or "").lower()
+
+
+def _align_endpoint_tld(region: str, endpoint: str) -> str:
+ """Align a service endpoint's TLD to the region's cloud.
+
+ The cloud a region belongs to (International/China on .com, Europe on .eu)
+ is a property of the region, and IAM is authoritative for it. Some Huawei
+ Cloud services still ship the .com endpoint for Europe (.eu) regions in
+ their bundled region metadata (e.g. ECS/VPC/ELB/EVS/WAF for eu-west-101),
+ which rejects .eu accounts with InvalidAccessKeyId. Rewrite the TLD to
+ match the region's IAM endpoint so every service targets the right cloud.
+ """
+ iam_endpoint = _iam_endpoint_for_region(region)
+ if not iam_endpoint or not endpoint:
+ return endpoint
+ iam_host = _endpoint_host(iam_endpoint)
+ if iam_host.endswith(".myhuaweicloud.eu"):
+ return endpoint.replace(".myhuaweicloud.com", ".myhuaweicloud.eu")
+ if iam_host.endswith(".myhuaweicloud.com"):
+ return endpoint.replace(".myhuaweicloud.eu", ".myhuaweicloud.com")
+ return endpoint
+
+
+def _aligned_region(region_cls, region_id: str):
+ """Return the service Region for ``region_id`` with a cloud-aligned endpoint.
+
+ Uses the service's own region metadata, but corrects the endpoint TLD when
+ the service lags behind the region's actual cloud (see _align_endpoint_tld).
+ Returns the unmodified region object when no correction is needed.
+ """
+ sdk_region = region_cls.value_of(region_id)
+ endpoint = sdk_region.endpoints[0]
+ aligned = _align_endpoint_tld(region_id, endpoint)
+ if aligned == endpoint:
+ return sdk_region
+ from huaweicloudsdkcore.region.region import Region
+
+ return Region(region_id, aligned)
+
+
+class HuaweiCloudBaseModel(BaseModel):
+ """Base model for Huawei Cloud service resources.
+
+ The Huawei Cloud SDK regularly returns optional attributes explicitly set
+ to None. Passing None to a non-optional ``str`` field (whether required or
+ with a default) raises a pydantic ValidationError, so coerce those None
+ values to the field's default (an empty string) before validation.
+ ``Optional[...]`` fields keep accepting None.
+ """
+
+ @validator("*", pre=True)
+ def _coerce_none_for_non_optional_str(cls, value, field): # noqa: vulture
+ if value is None and not field.allow_none and field.type_ is str:
+ return field.default if field.default is not None else ""
+ return value
+
+
+class HuaweiCloudCallerIdentity(BaseModel):
+ """
+ HuaweiCloudCallerIdentity stores the caller identity information from IAM.
+
+ Attributes:
+ domain_id: The Huawei Cloud domain ID
+ user_id: The Huawei Cloud user ID
+ user_name: The Huawei Cloud user name
+ account_id: The Huawei Cloud account ID (same as domain_id for most cases)
+ account_name: The Huawei Cloud account name
+ type: The type of identity (e.g., "user", "agency", "token")
+ """
+
+ domain_id: str
+ user_id: str
+ user_name: str
+ account_id: str
+ account_name: str
+ type: str = "user"
+
+
+class HuaweiCloudIdentityInfo(BaseModel):
+ """
+ HuaweiCloudIdentityInfo stores the Huawei Cloud account identity information.
+
+ Attributes:
+ account_id: The Huawei Cloud account ID
+ account_name: The Huawei Cloud account name
+ domain_id: The Huawei Cloud domain ID
+ user_id: The Huawei Cloud user ID
+ user_name: The Huawei Cloud user name
+ identity_type: The type of identity (e.g., "user", "agency", "token")
+ regions: Set of regions to be audited
+ profile: The profile name used for authentication
+ profile_region: The default region from the profile
+ """
+
+ account_id: str
+ account_name: str
+ domain_id: str
+ user_id: str
+ user_name: str
+ identity_type: str = "user"
+ regions: set[str]
+ profile: Optional[str] = None
+ profile_region: Optional[str] = None
+
+
+class HuaweiCloudCredentials(BaseModel):
+ """
+ HuaweiCloudCredentials stores the Huawei Cloud credentials.
+
+ Attributes:
+ ak: The Access Key ID
+ sk: The Secret Access Key
+ security_token: The Security Token (for temporary credentials)
+ domain_id: The Huawei Cloud domain ID
+ expiration: The expiration time for temporary credentials
+ """
+
+ ak: str
+ sk: str
+ security_token: Optional[str] = None
+ domain_id: Optional[str] = None
+ expiration: Optional[datetime] = None
+
+
+class HuaweiCloudRegion(BaseModel):
+ """
+ HuaweiCloudRegion stores information about a Huawei Cloud region.
+
+ Attributes:
+ region_id: The region identifier (e.g., cn-north-4, ap-southeast-1)
+ region_name: The human-readable region name
+ region_endpoint: The API endpoint for the region
+ """
+
+ region_id: str
+ region_name: str
+ region_endpoint: Optional[str] = None
+
+
+class HuaweiCloudSession:
+ """
+ HuaweiCloudSession stores the Huawei Cloud session and credentials.
+
+ This class provides methods to get credentials and create service clients.
+ """
+
+ def __init__(
+ self,
+ credentials: HuaweiCloudCredentials,
+ region: str = None,
+ ):
+ """
+ Initialize the Huawei Cloud session.
+
+ Args:
+ credentials: The Huawei Cloud credentials
+ region: The default region for the session
+ """
+ self._credentials = credentials
+ self._region = region or HUAWEICLOUD_DEFAULT_REGION
+ self._regional_clients = {}
+
+ @property
+ def credentials(self) -> HuaweiCloudCredentials:
+ """Get the Huawei Cloud credentials."""
+ return self._credentials
+
+ @property
+ def region(self) -> str:
+ """Get the default region."""
+ return self._region
+
+ @region.setter
+ def region(self, value: str):
+ """Set the default region."""
+ self._region = value
+
+ def get_credentials(self) -> HuaweiCloudCredentials:
+ """
+ Get the Huawei Cloud credentials.
+
+ Returns:
+ HuaweiCloudCredentials object
+ """
+ return self._credentials
+
+ def client(self, service: str, region: str = None) -> Any:
+ """
+ Create a service client for the given service and region.
+
+ Args:
+ service: The service name (e.g., 'ecs', 'vpc', 'obs')
+ region: The region (optional, some services are global)
+
+ Returns:
+ A client instance for the specified service
+
+ Raises:
+ HuaweiCloudServiceError: If the service is not supported
+ """
+ # Import Huawei Cloud SDK dynamically based on service
+ try:
+ if service == "obs":
+ from huaweicloudsdkobs.v1 import ObsClient
+ from huaweicloudsdkobs.v1.obs_credentials import ObsCredentials
+ from huaweicloudsdkobs.v1.region.obs_region import ObsRegion
+
+ client_region = region or self._region
+ obs_creds = ObsCredentials(
+ ak=self._credentials.ak,
+ sk=self._credentials.sk,
+ securityToken=getattr(self._credentials, "security_token", None),
+ )
+ return (
+ ObsClient.new_builder()
+ .with_credentials(obs_creds)
+ .with_http_config(self._http_config())
+ .with_region(ObsRegion.value_of(client_region))
+ .build()
+ )
+
+ elif service == "ecs":
+ from huaweicloudsdkecs.v2 import EcsClient
+ from huaweicloudsdkecs.v2.region.ecs_region import EcsRegion
+
+ client_region = region or self._region
+ return (
+ EcsClient.new_builder()
+ .with_credentials(self._get_basic_credentials(client_region))
+ .with_http_config(self._http_config())
+ .with_region(_aligned_region(EcsRegion, client_region))
+ .build()
+ )
+
+ elif service == "vpc":
+ from huaweicloudsdkvpc.v2 import VpcClient
+ from huaweicloudsdkvpc.v2.region.vpc_region import VpcRegion
+
+ client_region = region or self._region
+ return (
+ VpcClient.new_builder()
+ .with_credentials(self._get_basic_credentials(client_region))
+ .with_http_config(self._http_config())
+ .with_region(_aligned_region(VpcRegion, client_region))
+ .build()
+ )
+
+ elif service == "iam":
+ from huaweicloudsdkiam.v3 import IamClient
+ from huaweicloudsdkiam.v3.region.iam_region import IamRegion
+
+ # IAM is a global service, but we still need a region for the client
+ client_region = region or self._region
+ return (
+ IamClient.new_builder()
+ .with_credentials(self._get_basic_credentials(client_region))
+ .with_http_config(self._http_config())
+ .with_region(_aligned_region(IamRegion, client_region))
+ .build()
+ )
+
+ elif service == "rds":
+ from huaweicloudsdkrds.v3 import RdsClient
+ from huaweicloudsdkrds.v3.region.rds_region import RdsRegion
+
+ client_region = region or self._region
+ return (
+ RdsClient.new_builder()
+ .with_credentials(self._get_basic_credentials(client_region))
+ .with_http_config(self._http_config())
+ .with_region(_aligned_region(RdsRegion, client_region))
+ .build()
+ )
+
+ elif service == "cts":
+ from huaweicloudsdkcts.v3 import CtsClient
+ from huaweicloudsdkcts.v3.region.cts_region import CtsRegion
+
+ client_region = region or self._region
+ return (
+ CtsClient.new_builder()
+ .with_credentials(self._get_basic_credentials(client_region))
+ .with_http_config(self._http_config())
+ .with_region(_aligned_region(CtsRegion, client_region))
+ .build()
+ )
+
+ elif service == "kms":
+ from huaweicloudsdkkms.v2 import KmsClient
+ from huaweicloudsdkkms.v2.region.kms_region import KmsRegion
+
+ client_region = region or self._region
+ return (
+ KmsClient.new_builder()
+ .with_credentials(self._get_basic_credentials(client_region))
+ .with_http_config(self._http_config())
+ .with_region(_aligned_region(KmsRegion, client_region))
+ .build()
+ )
+
+ elif service == "waf":
+ from huaweicloudsdkwaf.v1 import WafClient
+ from huaweicloudsdkwaf.v1.region.waf_region import WafRegion
+
+ client_region = region or self._region
+ return (
+ WafClient.new_builder()
+ .with_credentials(self._get_basic_credentials(client_region))
+ .with_http_config(self._http_config())
+ .with_region(_aligned_region(WafRegion, client_region))
+ .build()
+ )
+
+ elif service == "elb":
+ from huaweicloudsdkelb.v3 import ElbClient
+ from huaweicloudsdkelb.v3.region.elb_region import ElbRegion
+
+ client_region = region or self._region
+ return (
+ ElbClient.new_builder()
+ .with_credentials(self._get_basic_credentials(client_region))
+ .with_http_config(self._http_config())
+ .with_region(_aligned_region(ElbRegion, client_region))
+ .build()
+ )
+
+ elif service == "evs":
+ from huaweicloudsdkevs.v2 import EvsClient
+ from huaweicloudsdkevs.v2.region.evs_region import EvsRegion
+
+ client_region = region or self._region
+ return (
+ EvsClient.new_builder()
+ .with_credentials(self._get_basic_credentials(client_region))
+ .with_http_config(self._http_config())
+ .with_region(_aligned_region(EvsRegion, client_region))
+ .build()
+ )
+
+ else:
+ raise HuaweiCloudServiceError(
+ message=f"Huawei Cloud service '{service}' is not supported"
+ )
+
+ except HuaweiCloudServiceError:
+ raise
+ except ImportError as e:
+ logger.error(
+ f"Failed to import Huawei Cloud SDK for service '{service}': {e}"
+ )
+ raise
+ except Exception as e:
+ logger.error(
+ f"Failed to create Huawei Cloud client for service '{service}': {e}"
+ )
+ raise
+
+ @staticmethod
+ def _http_config():
+ """Build an HttpConfig with the provider's connect/read timeouts."""
+ from huaweicloudsdkcore.http.http_config import HttpConfig
+
+ config = HttpConfig.get_default_config()
+ config.timeout = (
+ HUAWEICLOUD_SDK_CONNECT_TIMEOUT,
+ HUAWEICLOUD_SDK_READ_TIMEOUT,
+ )
+ return config
+
+ def _get_basic_credentials(self, region: str = None):
+ """Get Huawei Cloud BasicCredentials from stored credentials.
+
+ The project_id is intentionally left unset: the SDK resolves the
+ correct project_id for each region automatically (cached per region),
+ which is required for multi-region scans since each region has its own
+ project. Pinning a single project_id would break every other region.
+
+ Args:
+ region: The region the resulting client targets. Defaults to the
+ session's region. It selects the IAM endpoint used for project
+ auto-resolution, so multi-region scans point each regional
+ client at its own region's endpoint.
+ """
+ from huaweicloudsdkcore.auth.credentials import BasicCredentials
+
+ creds = self._credentials
+
+ basic_creds = BasicCredentials(ak=creds.ak, sk=creds.sk)
+
+ # Point the SDK's per-region project auto-resolution at the region's
+ # own IAM endpoint. It otherwise defaults to the .com (International)
+ # global endpoint, which rejects Huawei Cloud Europe (.eu) accounts.
+ iam_endpoint = _iam_endpoint_for_region(region or self._region)
+ if iam_endpoint:
+ basic_creds.iam_endpoint = iam_endpoint
+
+ # security_token is a settable property (for temporary credentials)
+ if creds.security_token:
+ basic_creds.security_token = creds.security_token
+
+ return basic_creds
+
+
+class HuaweiCloudOutputOptions(ProviderOutputOptions):
+ """
+ HuaweiCloudOutputOptions extends ProviderOutputOptions for Huawei Cloud specific output options.
+ """
+
+ def __init__(self, arguments, bulk_checks_metadata, identity):
+ # Call parent class init
+ super().__init__(arguments, bulk_checks_metadata)
+
+ # Set default output filename if not provided
+ if (
+ not hasattr(arguments, "output_filename")
+ or arguments.output_filename is None
+ ):
+ from prowler.config.config import output_file_timestamp
+
+ self.output_filename = (
+ f"prowler-output-{identity.account_id}-{output_file_timestamp}"
+ )
+ else:
+ self.output_filename = arguments.output_filename
diff --git a/prowler/providers/huaweicloud/services/__init__.py b/prowler/providers/huaweicloud/services/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/cts/__init__.py b/prowler/providers/huaweicloud/services/cts/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/cts/cts_client.py b/prowler/providers/huaweicloud/services/cts/cts_client.py
new file mode 100644
index 0000000000..c984030327
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/cts/cts_client.py
@@ -0,0 +1,4 @@
+from prowler.providers.common.provider import Provider
+from prowler.providers.huaweicloud.services.cts.cts_service import CTS
+
+cts_client = CTS(Provider.get_global_provider())
diff --git a/prowler/providers/huaweicloud/services/cts/cts_enabled/__init__.py b/prowler/providers/huaweicloud/services/cts/cts_enabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/cts/cts_enabled/cts_enabled.metadata.json b/prowler/providers/huaweicloud/services/cts/cts_enabled/cts_enabled.metadata.json
new file mode 100644
index 0000000000..5e7b88b8c3
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/cts/cts_enabled/cts_enabled.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "huaweicloud",
+ "CheckID": "cts_enabled",
+ "CheckTitle": "CTS tracker is enabled",
+ "CheckType": [],
+ "ServiceName": "cts",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "monitoring",
+ "Description": "Ensure that **Cloud Trace Service (CTS)** tracker is enabled to record all API calls and operations performed in the **Huawei Cloud** account.",
+ "Risk": "Without **CTS** enabled, there is no audit trail of API calls and operations, making it difficult to detect, investigate, and respond to security incidents or unauthorized access.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://support.huaweicloud.com/intl/en-us/usermanual-cts/cts_03_0002.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "hcloud CTS CreateTracker --tracker_name=\"system\" --tracker_type=\"system\"",
+ "NativeIaC": "",
+ "Other": "1. Log on to the **Huawei Cloud console**.\n2. Navigate to **Cloud Trace Service**.\n3. Click the **Trace Management** tab.\n4. Click **Enable CTS**.\n5. Configure the **OBS** bucket for trace file storage.\n6. Click **OK**.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Enable the CTS system tracker to record all API calls and operations in the Huawei Cloud account.",
+ "Url": "https://hub.prowler.com/check/cts_enabled"
+ }
+ },
+ "Categories": [
+ "logging"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/huaweicloud/services/cts/cts_enabled/cts_enabled.py b/prowler/providers/huaweicloud/services/cts/cts_enabled/cts_enabled.py
new file mode 100644
index 0000000000..43c2fa75f7
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/cts/cts_enabled/cts_enabled.py
@@ -0,0 +1,46 @@
+from prowler.lib.check.models import Check, CheckReportHuaweiCloud
+from prowler.providers.huaweicloud.services.cts.cts_client import cts_client
+
+
+class cts_enabled(Check):
+ """Check if CTS tracker is enabled."""
+
+ def execute(self) -> list[CheckReportHuaweiCloud]:
+ findings = []
+
+ if not cts_client.trackers:
+ report = CheckReportHuaweiCloud(metadata=self.metadata(), resource={})
+ report.region = cts_client.region
+ report.resource_id = f"{cts_client.audited_account}-cts-tracker"
+ report.resource_name = report.resource_id
+ report.resource_arn = (
+ f"HUAWEICLOUD::CTS::{cts_client.audited_account}:tracker"
+ )
+ report.status = "FAIL"
+ report.status_extended = (
+ "No CTS tracker found. Cloud Trace Service is not enabled."
+ )
+ findings.append(report)
+ else:
+ for tracker in cts_client.trackers:
+ report = CheckReportHuaweiCloud(
+ metadata=self.metadata(), resource=tracker
+ )
+ report.region = tracker.region
+ report.resource_id = tracker.id
+ report.resource_arn = f"huaweicloud:cts:{tracker.region}:{cts_client.audited_account}:tracker/{tracker.id}"
+
+ if tracker.is_enabled:
+ report.status = "PASS"
+ report.status_extended = (
+ f"CTS tracker {tracker.name} ({tracker.id}) is enabled."
+ )
+ else:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"CTS tracker {tracker.name} ({tracker.id}) is not enabled."
+ )
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/huaweicloud/services/cts/cts_service.py b/prowler/providers/huaweicloud/services/cts/cts_service.py
new file mode 100644
index 0000000000..3ed33a0e3b
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/cts/cts_service.py
@@ -0,0 +1,70 @@
+from typing import List
+
+from prowler.lib.logger import logger
+from prowler.providers.huaweicloud.lib.service.service import HuaweiCloudService
+from prowler.providers.huaweicloud.models import HuaweiCloudBaseModel
+
+
+class CTS(HuaweiCloudService):
+ """
+ CTS (Cloud Trace Service) service class for Huawei Cloud.
+
+ This class provides methods to interact with Huawei Cloud CTS service
+ to retrieve trackers and their configuration.
+ """
+
+ def __init__(self, provider):
+ super().__init__(__class__.__name__, provider)
+
+ self.trackers: List[Tracker] = []
+
+ self.__threading_call__(self._list_trackers)
+
+ def _list_trackers(self, regional_client):
+ """List all CTS trackers in the region."""
+ region = getattr(regional_client, "region", "unknown")
+ logger.info(f"CTS - Listing Trackers in {region}...")
+
+ try:
+ from huaweicloudsdkcts.v3 import ListTrackersRequest
+
+ request = ListTrackersRequest()
+ response = self._call_with_retries(regional_client.list_trackers, request)
+
+ if response and response.trackers:
+ for tracker_data in response.trackers:
+ obs_info = getattr(tracker_data, "obs_info", None)
+ self.trackers.append(
+ Tracker(
+ id=getattr(tracker_data, "id", None) or "",
+ name=getattr(tracker_data, "tracker_name", None) or "",
+ tracker_type=getattr(tracker_data, "tracker_type", ""),
+ is_enabled=getattr(tracker_data, "status", "") == "enabled",
+ bucket_name=(
+ getattr(obs_info, "bucket_name", "") if obs_info else ""
+ ),
+ file_prefix_name=(
+ getattr(obs_info, "file_prefix_name", "")
+ if obs_info
+ else ""
+ ),
+ region=region,
+ )
+ )
+
+ except Exception as error:
+ logger.error(
+ f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+
+
+class Tracker(HuaweiCloudBaseModel):
+ """CTS Tracker model."""
+
+ id: str
+ name: str
+ tracker_type: str = ""
+ is_enabled: bool = False
+ bucket_name: str = ""
+ file_prefix_name: str = ""
+ region: str = ""
diff --git a/prowler/providers/huaweicloud/services/ecs/__init__.py b/prowler/providers/huaweicloud/services/ecs/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/ecs/ecs_client.py b/prowler/providers/huaweicloud/services/ecs/ecs_client.py
new file mode 100644
index 0000000000..be88d8b1a7
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/ecs/ecs_client.py
@@ -0,0 +1,4 @@
+from prowler.providers.common.provider import Provider
+from prowler.providers.huaweicloud.services.ecs.ecs_service import ECS
+
+ecs_client = ECS(Provider.get_global_provider())
diff --git a/prowler/providers/huaweicloud/services/ecs/ecs_instance_key_pair/__init__.py b/prowler/providers/huaweicloud/services/ecs/ecs_instance_key_pair/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/ecs/ecs_instance_key_pair/ecs_instance_key_pair.metadata.json b/prowler/providers/huaweicloud/services/ecs/ecs_instance_key_pair/ecs_instance_key_pair.metadata.json
new file mode 100644
index 0000000000..3786458e76
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/ecs/ecs_instance_key_pair/ecs_instance_key_pair.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "huaweicloud",
+ "CheckID": "ecs_instance_key_pair",
+ "CheckTitle": "ECS instances should use SSH key pairs for authentication",
+ "CheckType": [],
+ "ServiceName": "ecs",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "compute",
+ "Description": "Ensure that **Elastic Cloud Server (ECS)** instances use `SSH` **key pairs** instead of password-based authentication for secure access.",
+ "Risk": "**ECS** instances without `SSH` **key pairs** may rely on password-based authentication, which is more susceptible to **brute-force attacks** and **unauthorized access**.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://support.huaweicloud.com/intl/en-us/usermanual-ecs/ecs_03_0120.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "hcloud ECS ReinstallServerWithCloudInit --server_id= --os-reinstall.keyname=",
+ "NativeIaC": "",
+ "Other": "1. Log on to the **Huawei Cloud** console\n2. Navigate to **Elastic Cloud Server**\n3. Select the instance\n4. Click **More** > **Manage Key Pair**\n5. Bind an existing **key pair** or create a new one\n6. Disable password-based login",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Bind SSH key pairs to all ECS instances and disable password-based authentication.",
+ "Url": "https://hub.prowler.com/check/ecs_instance_key_pair"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/huaweicloud/services/ecs/ecs_instance_key_pair/ecs_instance_key_pair.py b/prowler/providers/huaweicloud/services/ecs/ecs_instance_key_pair/ecs_instance_key_pair.py
new file mode 100644
index 0000000000..f1ae8fefe5
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/ecs/ecs_instance_key_pair/ecs_instance_key_pair.py
@@ -0,0 +1,26 @@
+from prowler.lib.check.models import Check, CheckReportHuaweiCloud
+from prowler.providers.huaweicloud.services.ecs.ecs_client import ecs_client
+
+
+class ecs_instance_key_pair(Check):
+ """Ensure ECS instances use SSH key pairs for authentication."""
+
+ def execute(self) -> list[CheckReportHuaweiCloud]:
+ findings = []
+
+ for instance in ecs_client.instances.values():
+ report = CheckReportHuaweiCloud(metadata=self.metadata(), resource=instance)
+ report.region = instance.region
+ report.resource_id = instance.id
+ report.resource_arn = f"huaweicloud:ecs:{instance.region}:{ecs_client.audited_account}:instance/{instance.id}"
+
+ if instance.key_name:
+ report.status = "PASS"
+ report.status_extended = f"ECS instance {instance.name} ({instance.id}) uses SSH key pair '{instance.key_name}' for authentication."
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"ECS instance {instance.name} ({instance.id}) does not use an SSH key pair for authentication."
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/huaweicloud/services/ecs/ecs_instance_no_default_security_group/__init__.py b/prowler/providers/huaweicloud/services/ecs/ecs_instance_no_default_security_group/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/ecs/ecs_instance_no_default_security_group/ecs_instance_no_default_security_group.metadata.json b/prowler/providers/huaweicloud/services/ecs/ecs_instance_no_default_security_group/ecs_instance_no_default_security_group.metadata.json
new file mode 100644
index 0000000000..29f6aecc18
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/ecs/ecs_instance_no_default_security_group/ecs_instance_no_default_security_group.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "huaweicloud",
+ "CheckID": "ecs_instance_no_default_security_group",
+ "CheckTitle": "ECS instances should not use the default security group",
+ "CheckType": [],
+ "ServiceName": "ecs",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "compute",
+ "Description": "Ensure that **Elastic Cloud Server (ECS)** instances do not use the **default security group**, which may have overly permissive rules.",
+ "Risk": "The **default security group** in **Huawei Cloud** may allow unrestricted traffic. Using it for **ECS** instances increases the risk of **unauthorized network access**.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://support.huaweicloud.com/intl/en-us/usermanual-vpc/vpc_Sg_0001.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "hcloud ECS NovaDisassociateSecurityGroup --server_id= --removeSecurityGroup.name=\"default\"",
+ "NativeIaC": "",
+ "Other": "1. Log on to the **Huawei Cloud** console\n2. Navigate to **Elastic Cloud Server**\n3. Select the instance\n4. Click **More** > **Manage Security Group**\n5. Remove the **default security group**\n6. Attach a custom **security group** with restrictive rules",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Create custom security groups with least-privilege rules and replace the default security group on all ECS instances.",
+ "Url": "https://hub.prowler.com/check/ecs_instance_no_default_security_group"
+ }
+ },
+ "Categories": [
+ "internet-exposed"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/huaweicloud/services/ecs/ecs_instance_no_default_security_group/ecs_instance_no_default_security_group.py b/prowler/providers/huaweicloud/services/ecs/ecs_instance_no_default_security_group/ecs_instance_no_default_security_group.py
new file mode 100644
index 0000000000..a9b3f0e07b
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/ecs/ecs_instance_no_default_security_group/ecs_instance_no_default_security_group.py
@@ -0,0 +1,32 @@
+from prowler.lib.check.models import Check, CheckReportHuaweiCloud
+from prowler.providers.huaweicloud.services.ecs.ecs_client import ecs_client
+
+
+class ecs_instance_no_default_security_group(Check):
+ """Ensure ECS instances do not use the default security group."""
+
+ def execute(self) -> list[CheckReportHuaweiCloud]:
+ findings = []
+
+ for instance in ecs_client.instances.values():
+ report = CheckReportHuaweiCloud(metadata=self.metadata(), resource=instance)
+ report.region = instance.region
+ report.resource_id = instance.id
+ report.resource_arn = f"huaweicloud:ecs:{instance.region}:{ecs_client.audited_account}:instance/{instance.id}"
+
+ default_sgs = [
+ sg_id
+ for sg_id, sg_name in instance.security_groups.items()
+ if sg_name == "default" or sg_id == "default"
+ ]
+
+ if default_sgs:
+ report.status = "FAIL"
+ report.status_extended = f"ECS instance {instance.name} ({instance.id}) uses the default security group: {', '.join(default_sgs)}."
+ else:
+ report.status = "PASS"
+ report.status_extended = f"ECS instance {instance.name} ({instance.id}) does not use the default security group."
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/huaweicloud/services/ecs/ecs_instance_public_ip/__init__.py b/prowler/providers/huaweicloud/services/ecs/ecs_instance_public_ip/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/ecs/ecs_instance_public_ip/ecs_instance_public_ip.metadata.json b/prowler/providers/huaweicloud/services/ecs/ecs_instance_public_ip/ecs_instance_public_ip.metadata.json
new file mode 100644
index 0000000000..25bb00e1ed
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/ecs/ecs_instance_public_ip/ecs_instance_public_ip.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "huaweicloud",
+ "CheckID": "ecs_instance_public_ip",
+ "CheckTitle": "ECS instances should not have public IP addresses",
+ "CheckType": [],
+ "ServiceName": "ecs",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "compute",
+ "Description": "Ensure that **Elastic Cloud Server (ECS)** instances do not have **public IP** addresses assigned, reducing exposure to the internet.",
+ "Risk": "**ECS** instances with **public IP** addresses are directly accessible from the internet, which increases the **attack surface** and risk of **unauthorized access**, **data exfiltration**, or exploitation.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://support.huaweicloud.com/intl/en-us/usermanual-ecs/ecs_03_0701.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "hcloud EIP DisassociatePublicips --publicip_id=",
+ "NativeIaC": "",
+ "Other": "1. Log on to the **Huawei Cloud** console\n2. Navigate to **Elastic Cloud Server**\n3. Select the instance with a **public IP**\n4. Unbind the `EIP` from the instance\n5. Use a **NAT gateway** or `VPN` for outbound connectivity if needed",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Remove public IP addresses from ECS instances. Use NAT Gateways or VPN connections for instances that require outbound internet access, and Load Balancers for inbound access.",
+ "Url": "https://hub.prowler.com/check/ecs_instance_public_ip"
+ }
+ },
+ "Categories": [
+ "internet-exposed"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/huaweicloud/services/ecs/ecs_instance_public_ip/ecs_instance_public_ip.py b/prowler/providers/huaweicloud/services/ecs/ecs_instance_public_ip/ecs_instance_public_ip.py
new file mode 100644
index 0000000000..7cdbfec110
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/ecs/ecs_instance_public_ip/ecs_instance_public_ip.py
@@ -0,0 +1,26 @@
+from prowler.lib.check.models import Check, CheckReportHuaweiCloud
+from prowler.providers.huaweicloud.services.ecs.ecs_client import ecs_client
+
+
+class ecs_instance_public_ip(Check):
+ """Ensure ECS instances do not have public IP addresses."""
+
+ def execute(self) -> list[CheckReportHuaweiCloud]:
+ findings = []
+
+ for instance in ecs_client.instances.values():
+ report = CheckReportHuaweiCloud(metadata=self.metadata(), resource=instance)
+ report.region = instance.region
+ report.resource_id = instance.id
+ report.resource_arn = f"huaweicloud:ecs:{instance.region}:{ecs_client.audited_account}:instance/{instance.id}"
+
+ if instance.public_ip:
+ report.status = "FAIL"
+ report.status_extended = f"ECS instance {instance.name} ({instance.id}) has a public IP: {instance.public_ip}."
+ else:
+ report.status = "PASS"
+ report.status_extended = f"ECS instance {instance.name} ({instance.id}) does not have a public IP."
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/huaweicloud/services/ecs/ecs_instance_security_groups_attached/__init__.py b/prowler/providers/huaweicloud/services/ecs/ecs_instance_security_groups_attached/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/ecs/ecs_instance_security_groups_attached/ecs_instance_security_groups_attached.metadata.json b/prowler/providers/huaweicloud/services/ecs/ecs_instance_security_groups_attached/ecs_instance_security_groups_attached.metadata.json
new file mode 100644
index 0000000000..e86919d7e9
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/ecs/ecs_instance_security_groups_attached/ecs_instance_security_groups_attached.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "huaweicloud",
+ "CheckID": "ecs_instance_security_groups_attached",
+ "CheckTitle": "ECS instances should have security groups attached",
+ "CheckType": [],
+ "ServiceName": "ecs",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "compute",
+ "Description": "Ensure that **Elastic Cloud Server (ECS)** instances have at least one **security group** attached to control network traffic.",
+ "Risk": "**ECS** instances without **security groups** have no network-level access control, potentially allowing unrestricted **inbound** or **outbound** traffic.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://support.huaweicloud.com/intl/en-us/usermanual-ecs/ecs_03_0306.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "hcloud ECS NovaAssociateSecurityGroup --server_id= --addSecurityGroup.name=",
+ "NativeIaC": "",
+ "Other": "1. Log on to the **Huawei Cloud** console\n2. Navigate to **Elastic Cloud Server**\n3. Select the instance\n4. Click **More** > **Manage Security Group**\n5. Attach an appropriate **security group**",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Attach at least one properly configured security group to every ECS instance.",
+ "Url": "https://hub.prowler.com/check/ecs_instance_security_groups_attached"
+ }
+ },
+ "Categories": [
+ "internet-exposed"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/huaweicloud/services/ecs/ecs_instance_security_groups_attached/ecs_instance_security_groups_attached.py b/prowler/providers/huaweicloud/services/ecs/ecs_instance_security_groups_attached/ecs_instance_security_groups_attached.py
new file mode 100644
index 0000000000..4839ba1fa9
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/ecs/ecs_instance_security_groups_attached/ecs_instance_security_groups_attached.py
@@ -0,0 +1,29 @@
+from prowler.lib.check.models import Check, CheckReportHuaweiCloud
+from prowler.providers.huaweicloud.services.ecs.ecs_client import ecs_client
+
+
+class ecs_instance_security_groups_attached(Check):
+ """Ensure ECS instances have security groups attached."""
+
+ def execute(self) -> list[CheckReportHuaweiCloud]:
+ findings = []
+
+ for instance in ecs_client.instances.values():
+ report = CheckReportHuaweiCloud(metadata=self.metadata(), resource=instance)
+ report.region = instance.region
+ report.resource_id = instance.id
+ report.resource_arn = f"huaweicloud:ecs:{instance.region}:{ecs_client.audited_account}:instance/{instance.id}"
+
+ if instance.security_groups:
+ sg_names = ", ".join(
+ name or sg_id for sg_id, name in instance.security_groups.items()
+ )
+ report.status = "PASS"
+ report.status_extended = f"ECS instance {instance.name} ({instance.id}) has security group(s) attached: {sg_names}."
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"ECS instance {instance.name} ({instance.id}) does not have any security groups attached."
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/huaweicloud/services/ecs/ecs_service.py b/prowler/providers/huaweicloud/services/ecs/ecs_service.py
new file mode 100644
index 0000000000..384bf7a816
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/ecs/ecs_service.py
@@ -0,0 +1,137 @@
+from typing import Dict, Optional
+
+from prowler.lib.logger import logger
+from prowler.lib.scan_filters.scan_filters import is_resource_filtered
+from prowler.providers.huaweicloud.lib.service.service import HuaweiCloudService
+from prowler.providers.huaweicloud.models import HuaweiCloudBaseModel
+
+
+class ECS(HuaweiCloudService):
+ """
+ ECS (Elastic Cloud Server) service class for Huawei Cloud.
+
+ This class provides methods to interact with Huawei Cloud ECS service
+ to retrieve instances and their details.
+ """
+
+ def __init__(self, provider):
+ super().__init__(__class__.__name__, provider, global_service=False)
+
+ self.instances = {}
+
+ self.__threading_call__(self._list_servers_details)
+
+ def _list_servers_details(self, regional_client):
+ """List all ECS instances in the region."""
+ region = getattr(regional_client, "region", "unknown")
+ logger.info(f"ECS - Listing Servers in {region}...")
+
+ try:
+ from huaweicloudsdkecs.v2 import ListServersDetailsRequest
+
+ request = ListServersDetailsRequest()
+ request.limit = 50
+ offset = 1
+
+ while True:
+ request.offset = offset
+ response = self._call_with_retries(
+ regional_client.list_servers_details, request
+ )
+
+ if response and response.servers:
+ for server_data in response.servers:
+ if not self.audit_resources or is_resource_filtered(
+ server_data.id, self.audit_resources
+ ):
+ public_ip = ""
+ if (
+ hasattr(server_data, "access_i_pv4")
+ and server_data.access_i_pv4
+ ):
+ public_ip = server_data.access_i_pv4
+ elif (
+ hasattr(server_data, "addresses")
+ and server_data.addresses
+ ):
+ public_ip = self._extract_floating_ip(
+ server_data.addresses
+ )
+
+ security_groups = {}
+ if (
+ hasattr(server_data, "security_groups")
+ and server_data.security_groups
+ ):
+ for sg in server_data.security_groups:
+ sg_name = getattr(sg, "name", "")
+ sg_id = getattr(sg, "id", sg_name)
+ if sg_id:
+ security_groups[sg_id] = sg_name
+
+ self.instances[server_data.id] = Instance(
+ id=server_data.id,
+ name=getattr(server_data, "name", server_data.id),
+ region=region,
+ status=getattr(server_data, "status", None) or "",
+ flavor=getattr(server_data, "flavor", None),
+ public_ip=public_ip,
+ vpc_id=self._extract_vpc_id(server_data),
+ enterprise_project_id=getattr(
+ server_data, "enterprise_project_id", None
+ )
+ or "",
+ created_at=getattr(server_data, "created", None),
+ key_name=getattr(server_data, "key_name", None) or "",
+ security_groups=security_groups,
+ )
+
+ if len(response.servers) < 50:
+ break
+ offset += 50
+ else:
+ break
+
+ except Exception as error:
+ logger.error(
+ f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+
+ @staticmethod
+ def _extract_floating_ip(addresses):
+ """Extract floating (public) IP from server addresses dict."""
+ if not addresses:
+ return ""
+ for network_name, addr_list in addresses.items():
+ if addr_list:
+ for addr in addr_list:
+ ip_type = getattr(addr, "os_ext_ip_stype", "")
+ if ip_type == "floating":
+ return getattr(addr, "addr", "")
+ return ""
+
+ @staticmethod
+ def _extract_vpc_id(server_data):
+ """Extract VPC ID from server metadata or network interfaces."""
+ metadata = getattr(server_data, "metadata", None)
+ if metadata and isinstance(metadata, dict):
+ vpc_id = metadata.get("__vpc_id", "")
+ if vpc_id:
+ return vpc_id
+ return ""
+
+
+class Instance(HuaweiCloudBaseModel):
+ """ECS Instance model."""
+
+ id: str
+ name: str
+ region: str
+ status: str
+ flavor: Optional[object] = None
+ public_ip: str = ""
+ vpc_id: str = ""
+ enterprise_project_id: str = ""
+ created_at: Optional[str] = None
+ key_name: str = ""
+ security_groups: Dict[str, str] = {}
diff --git a/prowler/providers/huaweicloud/services/elb/__init__.py b/prowler/providers/huaweicloud/services/elb/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/elb/elb_client.py b/prowler/providers/huaweicloud/services/elb/elb_client.py
new file mode 100644
index 0000000000..8966d0451f
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/elb/elb_client.py
@@ -0,0 +1,4 @@
+from prowler.providers.common.provider import Provider
+from prowler.providers.huaweicloud.services.elb.elb_service import ELB
+
+elb_client = ELB(Provider.get_global_provider())
diff --git a/prowler/providers/huaweicloud/services/elb/elb_public_exposure/__init__.py b/prowler/providers/huaweicloud/services/elb/elb_public_exposure/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/elb/elb_public_exposure/elb_public_exposure.metadata.json b/prowler/providers/huaweicloud/services/elb/elb_public_exposure/elb_public_exposure.metadata.json
new file mode 100644
index 0000000000..5245a3b74c
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/elb/elb_public_exposure/elb_public_exposure.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "huaweicloud",
+ "CheckID": "elb_public_exposure",
+ "CheckTitle": "ELB load balancers should not have public IP addresses",
+ "CheckType": [],
+ "ServiceName": "elb",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "network",
+ "Description": "Ensure that **Elastic Load Balancer (ELB)** instances do not have public IP addresses unless explicitly required, to reduce the attack surface.",
+ "Risk": "**ELB** load balancers with public IP addresses are accessible from the internet, increasing the attack surface and potentially exposing internal services to unauthorized access.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://support.huaweicloud.com/intl/en-us/usermanual-elb/elb_ug_jt_0009.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "hcloud EIP DisassociatePublicips --publicip_id=",
+ "NativeIaC": "",
+ "Other": "1. Log on to the **Huawei Cloud console**.\n2. Navigate to **Elastic Load Balance**.\n3. Select the **load balancer**.\n4. Disassociate the public IP or recreate the **load balancer** with an internal IP.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Avoid assigning public IP addresses to ELB load balancers unless they are intended to be internet-facing.",
+ "Url": "https://hub.prowler.com/check/elb_public_exposure"
+ }
+ },
+ "Categories": [
+ "internet-exposed"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/huaweicloud/services/elb/elb_public_exposure/elb_public_exposure.py b/prowler/providers/huaweicloud/services/elb/elb_public_exposure/elb_public_exposure.py
new file mode 100644
index 0000000000..7b839907e5
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/elb/elb_public_exposure/elb_public_exposure.py
@@ -0,0 +1,34 @@
+from prowler.lib.check.models import Check, CheckReportHuaweiCloud
+from prowler.providers.huaweicloud.services.elb.elb_client import elb_client
+
+
+class elb_public_exposure(Check):
+ """Check if ELB load balancers have public IP addresses exposed."""
+
+ def execute(self) -> list[CheckReportHuaweiCloud]:
+ findings = []
+
+ for load_balancer in elb_client.load_balancers:
+ report = CheckReportHuaweiCloud(
+ metadata=self.metadata(), resource=load_balancer
+ )
+ report.region = load_balancer.region
+ report.resource_id = load_balancer.id
+ report.resource_arn = f"huaweicloud:elb:{load_balancer.region}:{elb_client.audited_account}:loadbalancer/{load_balancer.id}"
+
+ if load_balancer.is_public:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"ELB load balancer {load_balancer.name} ({load_balancer.id}) "
+ f"has a public IP address {load_balancer.public_ip}."
+ )
+ else:
+ report.status = "PASS"
+ report.status_extended = (
+ f"ELB load balancer {load_balancer.name} ({load_balancer.id}) "
+ f"does not have a public IP address."
+ )
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/huaweicloud/services/elb/elb_service.py b/prowler/providers/huaweicloud/services/elb/elb_service.py
new file mode 100644
index 0000000000..a301db02e5
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/elb/elb_service.py
@@ -0,0 +1,80 @@
+from typing import List
+
+from prowler.lib.logger import logger
+from prowler.providers.huaweicloud.lib.service.service import HuaweiCloudService
+from prowler.providers.huaweicloud.models import HuaweiCloudBaseModel
+
+
+class ELB(HuaweiCloudService):
+ """
+ ELB (Elastic Load Balancer) service class for Huawei Cloud.
+
+ This class provides methods to interact with Huawei Cloud ELB service
+ to retrieve load balancers and their listeners.
+ """
+
+ def __init__(self, provider):
+ super().__init__(__class__.__name__, provider)
+
+ self.load_balancers: List[LoadBalancer] = []
+
+ self.__threading_call__(self._list_load_balancers)
+
+ def _list_load_balancers(self, regional_client):
+ """List all ELB load balancers in the region."""
+ region = getattr(regional_client, "region", "unknown")
+ logger.info(f"ELB - Listing Load Balancers in {region}...")
+
+ try:
+ from huaweicloudsdkelb.v3 import ListLoadBalancersRequest
+
+ request = ListLoadBalancersRequest()
+ response = self._call_with_retries(
+ regional_client.list_load_balancers, request
+ )
+
+ if response and response.loadbalancers:
+ for lb_data in response.loadbalancers:
+ vip_address = getattr(lb_data, "vip_address", "") or ""
+
+ # Public exposure is indicated by bound public IPs
+ # (publicips) or EIPs (eips) on the load balancer.
+ public_ip = ""
+ for public_ip_info in getattr(lb_data, "publicips", None) or []:
+ address = getattr(public_ip_info, "publicip_address", "")
+ if address:
+ public_ip = address
+ break
+ if not public_ip:
+ for eip_info in getattr(lb_data, "eips", None) or []:
+ address = getattr(eip_info, "eip_address", "")
+ if address:
+ public_ip = address
+ break
+
+ self.load_balancers.append(
+ LoadBalancer(
+ id=getattr(lb_data, "id", None) or "",
+ name=getattr(lb_data, "name", None) or "",
+ vip_address=vip_address,
+ public_ip=public_ip,
+ is_public=bool(public_ip),
+ region=region,
+ )
+ )
+
+ except Exception as error:
+ logger.error(
+ f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+
+
+class LoadBalancer(HuaweiCloudBaseModel):
+ """ELB Load Balancer model."""
+
+ id: str
+ name: str
+ vip_address: str = ""
+ public_ip: str = ""
+ is_public: bool = False
+ region: str = ""
diff --git a/prowler/providers/huaweicloud/services/evs/__init__.py b/prowler/providers/huaweicloud/services/evs/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/evs/evs_client.py b/prowler/providers/huaweicloud/services/evs/evs_client.py
new file mode 100644
index 0000000000..1196a9b1a5
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/evs/evs_client.py
@@ -0,0 +1,4 @@
+from prowler.providers.common.provider import Provider
+from prowler.providers.huaweicloud.services.evs.evs_service import EVS
+
+evs_client = EVS(Provider.get_global_provider())
diff --git a/prowler/providers/huaweicloud/services/evs/evs_service.py b/prowler/providers/huaweicloud/services/evs/evs_service.py
new file mode 100644
index 0000000000..82748705bf
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/evs/evs_service.py
@@ -0,0 +1,73 @@
+from typing import List
+
+from prowler.lib.logger import logger
+from prowler.providers.huaweicloud.lib.service.service import HuaweiCloudService
+from prowler.providers.huaweicloud.models import HuaweiCloudBaseModel
+
+
+class EVS(HuaweiCloudService):
+ """
+ EVS (Elastic Volume Service) service class for Huawei Cloud.
+
+ This class provides methods to interact with Huawei Cloud EVS service
+ to retrieve disk volumes and their encryption status.
+ """
+
+ def __init__(self, provider):
+ super().__init__(__class__.__name__, provider)
+
+ self.volumes: List[Volume] = []
+
+ self.__threading_call__(self._list_volumes)
+
+ def _list_volumes(self, regional_client):
+ """List all EVS volumes in the region."""
+ region = getattr(regional_client, "region", "unknown")
+ logger.info(f"EVS - Listing Volumes in {region}...")
+
+ try:
+ from huaweicloudsdkevs.v2 import ListVolumesRequest
+
+ page_size = 1000
+ offset = 0
+ while True:
+ request = ListVolumesRequest(limit=page_size, offset=offset)
+ response = self._call_with_retries(
+ regional_client.list_volumes, request
+ )
+ if not response or not response.volumes:
+ break
+
+ for vol_data in response.volumes:
+ metadata = getattr(vol_data, "metadata", None) or {}
+ is_encrypted = bool(getattr(vol_data, "encrypted", False)) or (
+ metadata.get("__system__encrypted") == "1"
+ )
+ self.volumes.append(
+ Volume(
+ id=getattr(vol_data, "id", "") or "",
+ name=getattr(vol_data, "name", "") or "",
+ is_encrypted=is_encrypted,
+ kms_key_id=metadata.get("__system__cmkid", "") or "",
+ region=region,
+ )
+ )
+
+ if len(response.volumes) < page_size:
+ break
+ offset += page_size
+
+ except Exception as error:
+ logger.error(
+ f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+
+
+class Volume(HuaweiCloudBaseModel):
+ """EVS Volume model."""
+
+ id: str
+ name: str
+ is_encrypted: bool = False
+ kms_key_id: str = ""
+ region: str = ""
diff --git a/prowler/providers/huaweicloud/services/evs/evs_volume_encryption/__init__.py b/prowler/providers/huaweicloud/services/evs/evs_volume_encryption/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/evs/evs_volume_encryption/evs_volume_encryption.metadata.json b/prowler/providers/huaweicloud/services/evs/evs_volume_encryption/evs_volume_encryption.metadata.json
new file mode 100644
index 0000000000..44c8d20248
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/evs/evs_volume_encryption/evs_volume_encryption.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "huaweicloud",
+ "CheckID": "evs_volume_encryption",
+ "CheckTitle": "EVS volumes are encrypted",
+ "CheckType": [],
+ "ServiceName": "evs",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "storage",
+ "Description": "Ensure that all **Huawei Cloud Elastic Volume Service (EVS)** disks are **encrypted** to protect data at rest.",
+ "Risk": "Unencrypted **EVS** volumes expose sensitive **data at rest**. If a volume is compromised or improperly accessed, the data can be read without any additional protection.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://support.huaweicloud.com/intl/en-us/usermanual-evs/evs_01_0018.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "hcloud EVS CreateVolume --volume.availability_zone= --volume.size= --volume.metadata.__system__encrypted=\"1\" --volume.metadata.__system__cmkid=",
+ "NativeIaC": "",
+ "Other": "1. Log on to the **Huawei Cloud console**.\n2. Navigate to **Elastic Volume Service**.\n3. Select the unencrypted volume.\n4. Create an **encrypted** volume from a snapshot of the unencrypted volume.\n5. Replace the old volume with the new encrypted volume.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Enable encryption for all EVS volumes using a KMS key.",
+ "Url": "https://hub.prowler.com/check/evs_volume_encryption"
+ }
+ },
+ "Categories": [
+ "encryption"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/huaweicloud/services/evs/evs_volume_encryption/evs_volume_encryption.py b/prowler/providers/huaweicloud/services/evs/evs_volume_encryption/evs_volume_encryption.py
new file mode 100644
index 0000000000..b588dac654
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/evs/evs_volume_encryption/evs_volume_encryption.py
@@ -0,0 +1,30 @@
+from prowler.lib.check.models import Check, CheckReportHuaweiCloud
+from prowler.providers.huaweicloud.services.evs.evs_client import evs_client
+
+
+class evs_volume_encryption(Check):
+ """Check if EVS volumes are encrypted."""
+
+ def execute(self) -> list[CheckReportHuaweiCloud]:
+ findings = []
+
+ for volume in evs_client.volumes:
+ report = CheckReportHuaweiCloud(metadata=self.metadata(), resource=volume)
+ report.region = volume.region
+ report.resource_id = volume.id
+ report.resource_arn = f"huaweicloud:evs:{volume.region}:{evs_client.audited_account}:volume/{volume.id}"
+
+ if volume.is_encrypted:
+ report.status = "PASS"
+ report.status_extended = (
+ f"EVS volume {volume.name} ({volume.id}) is encrypted."
+ )
+ else:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"EVS volume {volume.name} ({volume.id}) is not encrypted."
+ )
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/huaweicloud/services/iam/__init__.py b/prowler/providers/huaweicloud/services/iam/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/iam/iam_account_password_policy/__init__.py b/prowler/providers/huaweicloud/services/iam/iam_account_password_policy/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/iam/iam_account_password_policy/iam_account_password_policy.metadata.json b/prowler/providers/huaweicloud/services/iam/iam_account_password_policy/iam_account_password_policy.metadata.json
new file mode 100644
index 0000000000..747f1d4109
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/iam/iam_account_password_policy/iam_account_password_policy.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "huaweicloud",
+ "CheckID": "iam_account_password_policy",
+ "CheckTitle": "IAM password policy requires a minimum length of 14 or greater",
+ "CheckType": [],
+ "ServiceName": "iam",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "**Huawei Cloud IAM** password policies can be used to enforce **password complexity** requirements. It is recommended that the **password policy** require a minimum of **14 or greater characters** for any password. Longer passwords provide exponentially more security against automated password cracking, as the keyspace increases dramatically with each additional character.",
+ "Risk": "Short passwords significantly reduce the effort required for **brute force attacks**. Passwords shorter than **14 characters** can be cracked much faster, potentially compromising **confidentiality** of user accounts. This can lead to unauthorized access to cloud resources and sensitive data, affecting the **integrity** and **availability** of the environment.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://support.huaweicloud.com/intl/en-us/usermanual-iam/iam_01_0605.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "hcloud IAM UpdateDomainPasswordPolicy --domain_id= --password_policy.minimum_password_length=14",
+ "NativeIaC": "",
+ "Other": "1. Log on to the **Huawei Cloud console**.\n2. Choose **IAM & Security**.\n3. Click the **Password Policy** tab.\n4. Set **Minimum Password Length** to `14` or greater.\n5. Click **OK**.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Configure the IAM password policy to require a minimum password length of 14 characters or greater.",
+ "Url": "https://hub.prowler.com/check/iam_account_password_policy"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/huaweicloud/services/iam/iam_account_password_policy/iam_account_password_policy.py b/prowler/providers/huaweicloud/services/iam/iam_account_password_policy/iam_account_password_policy.py
new file mode 100644
index 0000000000..76f8f05708
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/iam/iam_account_password_policy/iam_account_password_policy.py
@@ -0,0 +1,31 @@
+from prowler.lib.check.models import Check, CheckReportHuaweiCloud
+from prowler.providers.huaweicloud.services.iam.iam_client import iam_client
+
+
+class iam_account_password_policy(Check):
+ """Check if Huawei Cloud IAM password policy requires minimum length of 14 or greater."""
+
+ def execute(self) -> list[CheckReportHuaweiCloud]:
+ findings = []
+
+ if iam_client.password_policy:
+ report = CheckReportHuaweiCloud(
+ metadata=self.metadata(), resource=iam_client.password_policy
+ )
+ report.region = iam_client.region
+ report.resource_id = f"{iam_client.audited_account}-password-policy"
+ report.resource_name = report.resource_id
+ report.resource_arn = (
+ f"HUAWEICLOUD::IAM::{iam_client.audited_account}:password-policy"
+ )
+
+ if iam_client.password_policy.minimum_password_length >= 14:
+ report.status = "PASS"
+ report.status_extended = f"IAM password policy requires minimum length of {iam_client.password_policy.minimum_password_length} characters."
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"IAM password policy requires minimum length of {iam_client.password_policy.minimum_password_length} characters, which is less than the recommended 14 characters."
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/huaweicloud/services/iam/iam_client.py b/prowler/providers/huaweicloud/services/iam/iam_client.py
new file mode 100644
index 0000000000..881d622952
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/iam/iam_client.py
@@ -0,0 +1,4 @@
+from prowler.providers.common.provider import Provider
+from prowler.providers.huaweicloud.services.iam.iam_service import IAM
+
+iam_client = IAM(Provider.get_global_provider())
diff --git a/prowler/providers/huaweicloud/services/iam/iam_password_policy_char_combination/__init__.py b/prowler/providers/huaweicloud/services/iam/iam_password_policy_char_combination/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/iam/iam_password_policy_char_combination/iam_password_policy_char_combination.metadata.json b/prowler/providers/huaweicloud/services/iam/iam_password_policy_char_combination/iam_password_policy_char_combination.metadata.json
new file mode 100644
index 0000000000..faeab68472
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/iam/iam_password_policy_char_combination/iam_password_policy_char_combination.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "huaweicloud",
+ "CheckID": "iam_password_policy_char_combination",
+ "CheckTitle": "IAM password policy requires at least 3 character types",
+ "CheckType": [],
+ "ServiceName": "iam",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "**Huawei Cloud IAM** password policies can enforce **character complexity** by requiring multiple character types (`uppercase`, `lowercase`, `digits`, `special characters`). It is recommended that at least **3 character types** be required to increase password strength.",
+ "Risk": "Passwords with limited character types are more susceptible to **brute force** and **dictionary attacks**. Requiring only `1` or `2` character types significantly reduces the effective keyspace, making passwords easier to crack.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://support.huaweicloud.com/intl/en-us/usermanual-iam/iam_01_0605.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "hcloud IAM UpdateDomainPasswordPolicy --domain_id= --password_policy.password_char_combination=3",
+ "NativeIaC": "",
+ "Other": "1. Log on to the **Huawei Cloud console**.\n2. Choose **IAM & Security**.\n3. Click the **Password Policy** tab.\n4. Set **Password Character Combination** to `3` or greater.\n5. Click **OK**.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Configure the IAM password policy to require at least 3 character types in passwords.",
+ "Url": "https://hub.prowler.com/check/iam_password_policy_char_combination"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/huaweicloud/services/iam/iam_password_policy_char_combination/iam_password_policy_char_combination.py b/prowler/providers/huaweicloud/services/iam/iam_password_policy_char_combination/iam_password_policy_char_combination.py
new file mode 100644
index 0000000000..cfbc233414
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/iam/iam_password_policy_char_combination/iam_password_policy_char_combination.py
@@ -0,0 +1,31 @@
+from prowler.lib.check.models import Check, CheckReportHuaweiCloud
+from prowler.providers.huaweicloud.services.iam.iam_client import iam_client
+
+
+class iam_password_policy_char_combination(Check):
+ """Check if Huawei Cloud IAM password policy requires at least 3 character types."""
+
+ def execute(self) -> list[CheckReportHuaweiCloud]:
+ findings = []
+
+ if iam_client.password_policy:
+ report = CheckReportHuaweiCloud(
+ metadata=self.metadata(), resource=iam_client.password_policy
+ )
+ report.region = iam_client.region
+ report.resource_id = f"{iam_client.audited_account}-password-policy"
+ report.resource_name = report.resource_id
+ report.resource_arn = (
+ f"HUAWEICLOUD::IAM::{iam_client.audited_account}:password-policy"
+ )
+
+ if iam_client.password_policy.password_char_combination >= 3:
+ report.status = "PASS"
+ report.status_extended = f"IAM password policy requires at least {iam_client.password_policy.password_char_combination} character types in passwords."
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"IAM password policy only requires {iam_client.password_policy.password_char_combination} character type(s), which is less than the recommended 3 (uppercase, lowercase, digits, special characters)."
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/huaweicloud/services/iam/iam_password_policy_expires_passwords/__init__.py b/prowler/providers/huaweicloud/services/iam/iam_password_policy_expires_passwords/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/iam/iam_password_policy_expires_passwords/iam_password_policy_expires_passwords.metadata.json b/prowler/providers/huaweicloud/services/iam/iam_password_policy_expires_passwords/iam_password_policy_expires_passwords.metadata.json
new file mode 100644
index 0000000000..025804d679
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/iam/iam_password_policy_expires_passwords/iam_password_policy_expires_passwords.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "huaweicloud",
+ "CheckID": "iam_password_policy_expires_passwords",
+ "CheckTitle": "IAM password policy requires passwords to expire",
+ "CheckType": [],
+ "ServiceName": "iam",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "**Huawei Cloud IAM** password policies can enforce **password expiration**. It is recommended that the **password validity period** be set to a non-zero value so that passwords must be rotated periodically, reducing the window of opportunity for compromised credentials.",
+ "Risk": "Without **password expiration**, compromised passwords can be used indefinitely. Passwords that never expire increase the risk of long-term **credential exposure**, especially if credentials are leaked but not detected immediately.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://support.huaweicloud.com/intl/en-us/usermanual-iam/iam_01_0605.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "hcloud IAM UpdateDomainPasswordPolicy --domain_id= --password_policy.password_validity_period=90",
+ "NativeIaC": "",
+ "Other": "1. Log on to the **Huawei Cloud console**.\n2. Choose **IAM & Security**.\n3. Click the **Password Policy** tab.\n4. Set **Password Validity Period** to a non-zero value (e.g., `90 days`).\n5. Click **OK**.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Configure the IAM password policy to require password expiration by setting a non-zero password validity period.",
+ "Url": "https://hub.prowler.com/check/iam_password_policy_expires_passwords"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/huaweicloud/services/iam/iam_password_policy_expires_passwords/iam_password_policy_expires_passwords.py b/prowler/providers/huaweicloud/services/iam/iam_password_policy_expires_passwords/iam_password_policy_expires_passwords.py
new file mode 100644
index 0000000000..4fdeba76f8
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/iam/iam_password_policy_expires_passwords/iam_password_policy_expires_passwords.py
@@ -0,0 +1,31 @@
+from prowler.lib.check.models import Check, CheckReportHuaweiCloud
+from prowler.providers.huaweicloud.services.iam.iam_client import iam_client
+
+
+class iam_password_policy_expires_passwords(Check):
+ """Check if Huawei Cloud IAM password policy requires passwords to expire."""
+
+ def execute(self) -> list[CheckReportHuaweiCloud]:
+ findings = []
+
+ if iam_client.password_policy:
+ report = CheckReportHuaweiCloud(
+ metadata=self.metadata(), resource=iam_client.password_policy
+ )
+ report.region = iam_client.region
+ report.resource_id = f"{iam_client.audited_account}-password-policy"
+ report.resource_name = report.resource_id
+ report.resource_arn = (
+ f"HUAWEICLOUD::IAM::{iam_client.audited_account}:password-policy"
+ )
+
+ if iam_client.password_policy.password_validity_period > 0:
+ report.status = "PASS"
+ report.status_extended = f"IAM password policy requires passwords to expire after {iam_client.password_policy.password_validity_period} days."
+ else:
+ report.status = "FAIL"
+ report.status_extended = "IAM password policy does not require passwords to expire (password_validity_period is 0)."
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/huaweicloud/services/iam/iam_password_policy_minimum_age/__init__.py b/prowler/providers/huaweicloud/services/iam/iam_password_policy_minimum_age/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/iam/iam_password_policy_minimum_age/iam_password_policy_minimum_age.metadata.json b/prowler/providers/huaweicloud/services/iam/iam_password_policy_minimum_age/iam_password_policy_minimum_age.metadata.json
new file mode 100644
index 0000000000..834f2fc3f0
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/iam/iam_password_policy_minimum_age/iam_password_policy_minimum_age.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "huaweicloud",
+ "CheckID": "iam_password_policy_minimum_age",
+ "CheckTitle": "IAM password policy enforces a minimum password age",
+ "CheckType": [],
+ "ServiceName": "iam",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "low",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "**Huawei Cloud IAM** password policies can enforce a **minimum password age**, preventing users from changing passwords too frequently. This works in conjunction with **password reuse prevention** to ensure users cannot cycle through disallowed passwords to reuse an old one.",
+ "Risk": "Without a **minimum password age**, users can immediately change their password multiple times to exhaust the **reuse prevention** list and set their password back to a previously used value, effectively bypassing reuse prevention controls.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://support.huaweicloud.com/intl/en-us/usermanual-iam/iam_01_0605.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "hcloud IAM UpdateDomainPasswordPolicy --domain_id= --password_policy.minimum_password_age=60",
+ "NativeIaC": "",
+ "Other": "1. Log on to the **Huawei Cloud console**.\n2. Choose **IAM & Security**.\n3. Click the **Password Policy** tab.\n4. Set **Minimum Password Age** to at least `1 day`.\n5. Click **OK**.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Configure the IAM password policy to enforce a minimum password age of at least 1 day.",
+ "Url": "https://hub.prowler.com/check/iam_password_policy_minimum_age"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/huaweicloud/services/iam/iam_password_policy_minimum_age/iam_password_policy_minimum_age.py b/prowler/providers/huaweicloud/services/iam/iam_password_policy_minimum_age/iam_password_policy_minimum_age.py
new file mode 100644
index 0000000000..08fa40f299
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/iam/iam_password_policy_minimum_age/iam_password_policy_minimum_age.py
@@ -0,0 +1,31 @@
+from prowler.lib.check.models import Check, CheckReportHuaweiCloud
+from prowler.providers.huaweicloud.services.iam.iam_client import iam_client
+
+
+class iam_password_policy_minimum_age(Check):
+ """Check if Huawei Cloud IAM password policy enforces a minimum password age."""
+
+ def execute(self) -> list[CheckReportHuaweiCloud]:
+ findings = []
+
+ if iam_client.password_policy:
+ report = CheckReportHuaweiCloud(
+ metadata=self.metadata(), resource=iam_client.password_policy
+ )
+ report.region = iam_client.region
+ report.resource_id = f"{iam_client.audited_account}-password-policy"
+ report.resource_name = report.resource_id
+ report.resource_arn = (
+ f"HUAWEICLOUD::IAM::{iam_client.audited_account}:password-policy"
+ )
+
+ if iam_client.password_policy.minimum_password_age > 0:
+ report.status = "PASS"
+ report.status_extended = f"IAM password policy enforces a minimum password age of {iam_client.password_policy.minimum_password_age} days."
+ else:
+ report.status = "FAIL"
+ report.status_extended = "IAM password policy does not enforce a minimum password age (minimum_password_age is 0), allowing users to change passwords immediately and bypass reuse prevention."
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/huaweicloud/services/iam/iam_password_policy_reuse_prevention/__init__.py b/prowler/providers/huaweicloud/services/iam/iam_password_policy_reuse_prevention/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/iam/iam_password_policy_reuse_prevention/iam_password_policy_reuse_prevention.metadata.json b/prowler/providers/huaweicloud/services/iam/iam_password_policy_reuse_prevention/iam_password_policy_reuse_prevention.metadata.json
new file mode 100644
index 0000000000..aed6238422
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/iam/iam_password_policy_reuse_prevention/iam_password_policy_reuse_prevention.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "huaweicloud",
+ "CheckID": "iam_password_policy_reuse_prevention",
+ "CheckTitle": "IAM password policy prevents reuse of at least 3 previous passwords",
+ "CheckType": [],
+ "ServiceName": "iam",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "**Huawei Cloud IAM** password policies can prevent users from reusing recent passwords. It is recommended that the policy disallow reuse of at least the last **3 passwords** to ensure users choose new passwords upon rotation.",
+ "Risk": "Without **password reuse prevention**, users can cycle between a small set of passwords, effectively bypassing **password rotation** policies. This reduces the security benefit of **password expiration** and increases the risk of **credential compromise**.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://support.huaweicloud.com/intl/en-us/usermanual-iam/iam_01_0605.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "hcloud IAM UpdateDomainPasswordPolicy --domain_id= --password_policy.number_of_recent_passwords_disallowed=5",
+ "NativeIaC": "",
+ "Other": "1. Log on to the **Huawei Cloud console**.\n2. Choose **IAM & Security**.\n3. Click the **Password Policy** tab.\n4. Set **Number of Recent Passwords Disallowed** to `3` or greater.\n5. Click **OK**.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Configure the IAM password policy to disallow reuse of at least the last 3 passwords.",
+ "Url": "https://hub.prowler.com/check/iam_password_policy_reuse_prevention"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/huaweicloud/services/iam/iam_password_policy_reuse_prevention/iam_password_policy_reuse_prevention.py b/prowler/providers/huaweicloud/services/iam/iam_password_policy_reuse_prevention/iam_password_policy_reuse_prevention.py
new file mode 100644
index 0000000000..476c183803
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/iam/iam_password_policy_reuse_prevention/iam_password_policy_reuse_prevention.py
@@ -0,0 +1,31 @@
+from prowler.lib.check.models import Check, CheckReportHuaweiCloud
+from prowler.providers.huaweicloud.services.iam.iam_client import iam_client
+
+
+class iam_password_policy_reuse_prevention(Check):
+ """Check if Huawei Cloud IAM password policy prevents password reuse (at least 3 previous passwords)."""
+
+ def execute(self) -> list[CheckReportHuaweiCloud]:
+ findings = []
+
+ if iam_client.password_policy:
+ report = CheckReportHuaweiCloud(
+ metadata=self.metadata(), resource=iam_client.password_policy
+ )
+ report.region = iam_client.region
+ report.resource_id = f"{iam_client.audited_account}-password-policy"
+ report.resource_name = report.resource_id
+ report.resource_arn = (
+ f"HUAWEICLOUD::IAM::{iam_client.audited_account}:password-policy"
+ )
+
+ if iam_client.password_policy.number_of_recent_passwords_disallowed >= 3:
+ report.status = "PASS"
+ report.status_extended = f"IAM password policy disallows reuse of the last {iam_client.password_policy.number_of_recent_passwords_disallowed} passwords."
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"IAM password policy only disallows reuse of the last {iam_client.password_policy.number_of_recent_passwords_disallowed} passwords, which is less than the recommended 3."
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/huaweicloud/services/iam/iam_root_hardware_mfa_enabled/__init__.py b/prowler/providers/huaweicloud/services/iam/iam_root_hardware_mfa_enabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/iam/iam_root_hardware_mfa_enabled/iam_root_hardware_mfa_enabled.metadata.json b/prowler/providers/huaweicloud/services/iam/iam_root_hardware_mfa_enabled/iam_root_hardware_mfa_enabled.metadata.json
new file mode 100644
index 0000000000..d393e05e77
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/iam/iam_root_hardware_mfa_enabled/iam_root_hardware_mfa_enabled.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "huaweicloud",
+ "CheckID": "iam_root_hardware_mfa_enabled",
+ "CheckTitle": "Root account enforces MFA through operation protection",
+ "CheckType": [],
+ "ServiceName": "iam",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "critical",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "The **Huawei Cloud** account (**root**/**domain owner**) should enforce **MFA**. Because the domain owner is not a listable **IAM** user, **root MFA** is assessed through the account's **operation protection** policy, which requires **MFA** verification (`virtual MFA`, `SMS`, or `email`) before critical operations such as deleting resources or managing credentials can be performed.",
+ "Risk": "Without **operation protection**, a compromised **root** password enables full **account takeover** with no additional verification. An attacker could delete resources, change policies, and disable logging, harming **confidentiality**, **integrity**, and **availability**.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://support.huaweicloud.com/intl/en-us/usermanual-iam/iam_01_0002.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "hcloud IAM UpdateDomainProtectPolicy --domain_id= --protect_policy.operation_protection=true",
+ "NativeIaC": "",
+ "Other": "1. Log on to the **Huawei Cloud console** as the account administrator.\n2. Go to **Security Settings** > **Critical Operations**.\n3. In the **Operation Protection** section, click **Enable**.\n4. Choose the verification method (`virtual MFA`, `SMS`, or `email`) and save.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Enable operation protection for the account and avoid using root credentials for daily operations.",
+ "Url": "https://hub.prowler.com/check/iam_root_hardware_mfa_enabled"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/huaweicloud/services/iam/iam_root_hardware_mfa_enabled/iam_root_hardware_mfa_enabled.py b/prowler/providers/huaweicloud/services/iam/iam_root_hardware_mfa_enabled/iam_root_hardware_mfa_enabled.py
new file mode 100644
index 0000000000..e4b30e58e7
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/iam/iam_root_hardware_mfa_enabled/iam_root_hardware_mfa_enabled.py
@@ -0,0 +1,41 @@
+from prowler.lib.check.models import Check, CheckReportHuaweiCloud
+from prowler.providers.huaweicloud.services.iam.iam_client import iam_client
+
+
+class iam_root_hardware_mfa_enabled(Check):
+ """Check if the Huawei Cloud account enforces MFA on the root account.
+
+ The account/root (domain owner) is not a listable IAM user in Huawei
+ Cloud, so root MFA is assessed through the account's operation protection
+ policy, which forces MFA verification for sensitive operations.
+ """
+
+ def execute(self) -> list[CheckReportHuaweiCloud]:
+ findings = []
+
+ protection = iam_client.operation_protection
+
+ report = CheckReportHuaweiCloud(metadata=self.metadata(), resource=protection)
+ report.region = iam_client.region
+ report.resource_id = f"{iam_client.audited_account}-operation-protection"
+ report.resource_name = report.resource_id
+ report.resource_arn = (
+ f"HUAWEICLOUD::IAM::{iam_client.audited_account}:operation-protection"
+ )
+
+ if protection.enabled:
+ report.status = "PASS"
+ report.status_extended = (
+ "Root account is protected: account operation protection "
+ "(MFA verification for critical operations) is enabled."
+ )
+ else:
+ report.status = "FAIL"
+ report.status_extended = (
+ "Root account is not protected: account operation protection "
+ "(MFA verification for critical operations) is not enabled."
+ )
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/huaweicloud/services/iam/iam_service.py b/prowler/providers/huaweicloud/services/iam/iam_service.py
new file mode 100644
index 0000000000..4e4c8bd16d
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/iam/iam_service.py
@@ -0,0 +1,224 @@
+from typing import List, Optional
+
+from prowler.lib.logger import logger
+from prowler.lib.scan_filters.scan_filters import is_resource_filtered
+from prowler.providers.huaweicloud.lib.service.service import HuaweiCloudService
+from prowler.providers.huaweicloud.models import HuaweiCloudBaseModel
+
+
+class IAM(HuaweiCloudService):
+ """
+ IAM (Identity and Access Management) service class for Huawei Cloud.
+
+ This class provides methods to interact with Huawei Cloud IAM service
+ to retrieve account password policy, users, and MFA devices.
+ """
+
+ def __init__(self, provider):
+ super().__init__(__class__.__name__, provider, global_service=True)
+
+ self.password_policy = PasswordPolicy()
+ self.users: List[IAMUser] = []
+ self.mfa_devices: List[MFADevice] = []
+ self.domain_id = provider.identity.domain_id if provider.identity else ""
+ self.operation_protection = OperationProtection(account_id=self.domain_id)
+
+ self._get_password_policy()
+ self._list_users()
+ self._list_mfa_devices()
+ self._get_operation_protection()
+
+ def _get_password_policy(self):
+ """Get the domain password policy."""
+ if not self.client:
+ return
+
+ region = self.region
+ client = self.client
+ logger.info(f"IAM - Getting Password Policy from {region}...")
+
+ try:
+ from huaweicloudsdkiam.v3 import ShowDomainPasswordPolicyRequest
+
+ request = ShowDomainPasswordPolicyRequest()
+ response = self._call_with_retries(
+ client.show_domain_password_policy, request
+ )
+
+ if response and response.password_policy:
+ policy = response.password_policy
+ self.password_policy = PasswordPolicy(
+ minimum_password_length=getattr(
+ policy, "minimum_password_length", 0
+ )
+ or 0,
+ maximum_password_length=getattr(
+ policy, "maximum_password_length", 0
+ )
+ or 0,
+ minimum_password_age=getattr(policy, "minimum_password_age", 0)
+ or 0,
+ password_validity_period=getattr(
+ policy, "password_validity_period", 0
+ )
+ or 0,
+ password_char_combination=getattr(
+ policy, "password_char_combination", 0
+ )
+ or 0,
+ maximum_consecutive_identical_chars=getattr(
+ policy, "maximum_consecutive_identical_chars", 0
+ )
+ or 0,
+ number_of_recent_passwords_disallowed=getattr(
+ policy, "number_of_recent_passwords_disallowed", 0
+ )
+ or 0,
+ password_not_username_or_invert=getattr(
+ policy, "password_not_username_or_invert", False
+ )
+ or False,
+ )
+
+ except Exception as error:
+ logger.error(
+ f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+
+ def _list_users(self):
+ """List all IAM users in the domain."""
+ if not self.client:
+ return
+
+ region = self.region
+ client = self.client
+ logger.info(f"IAM - Listing Users in {region}...")
+
+ try:
+ from huaweicloudsdkiam.v3 import KeystoneListUsersRequest
+
+ request = KeystoneListUsersRequest()
+ response = self._call_with_retries(client.keystone_list_users, request)
+
+ if response and response.users:
+ for user_data in response.users:
+ if not self.audit_resources or is_resource_filtered(
+ user_data.id, self.audit_resources
+ ):
+ self.users.append(
+ IAMUser(
+ id=user_data.id,
+ name=getattr(user_data, "name", None) or user_data.id,
+ enabled=getattr(user_data, "enabled", True),
+ password_expires_at=getattr(
+ user_data, "password_expires_at", None
+ ),
+ )
+ )
+
+ except Exception as error:
+ logger.error(
+ f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+
+ def _list_mfa_devices(self):
+ """List all virtual MFA devices in the domain."""
+ if not self.client:
+ return
+
+ region = self.region
+ client = self.client
+ logger.info(f"IAM - Listing MFA Devices in {region}...")
+
+ try:
+ from huaweicloudsdkiam.v3 import ListUserMfaDevicesRequest
+
+ request = ListUserMfaDevicesRequest()
+ response = self._call_with_retries(client.list_user_mfa_devices, request)
+
+ if response and response.virtual_mfa_devices:
+ for device_data in response.virtual_mfa_devices:
+ self.mfa_devices.append(
+ MFADevice(
+ serial_number=getattr(device_data, "serial_number", None)
+ or "",
+ user_id=getattr(device_data, "user_id", None) or "",
+ )
+ )
+
+ except Exception as error:
+ logger.error(
+ f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+
+ def _get_operation_protection(self):
+ """Get the account (domain) operation protection policy.
+
+ Operation protection is Huawei Cloud's account-level control that
+ forces MFA verification for sensitive operations performed by the
+ account/root credentials. It is the reliable, queryable equivalent of
+ "root MFA" (the domain owner is not a listable IAM user).
+ """
+ if not self.client:
+ return
+
+ region = self.region
+ client = self.client
+ logger.info(f"IAM - Getting Operation Protection Policy from {region}...")
+
+ try:
+ from huaweicloudsdkiam.v3 import ShowDomainProtectPolicyRequest
+
+ request = ShowDomainProtectPolicyRequest(domain_id=self.domain_id)
+ response = self._call_with_retries(
+ client.show_domain_protect_policy, request
+ )
+
+ if response and response.protect_policy:
+ self.operation_protection = OperationProtection(
+ account_id=self.domain_id,
+ enabled=bool(
+ getattr(response.protect_policy, "operation_protection", False)
+ ),
+ )
+
+ except Exception as error:
+ logger.error(
+ f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+
+
+class PasswordPolicy(HuaweiCloudBaseModel):
+ """IAM Password Policy model."""
+
+ minimum_password_length: int = 0
+ maximum_password_length: int = 0
+ minimum_password_age: int = 0
+ password_validity_period: int = 0
+ password_char_combination: int = 0
+ maximum_consecutive_identical_chars: int = 0
+ number_of_recent_passwords_disallowed: int = 0
+ password_not_username_or_invert: bool = False
+
+
+class IAMUser(HuaweiCloudBaseModel):
+ """IAM User model."""
+
+ id: str
+ name: str
+ enabled: bool = True
+ password_expires_at: Optional[str] = None
+
+
+class MFADevice(HuaweiCloudBaseModel):
+ """IAM MFA Device model."""
+
+ serial_number: str
+ user_id: str
+
+
+class OperationProtection(HuaweiCloudBaseModel):
+ """IAM account operation protection model."""
+
+ account_id: str = ""
+ enabled: bool = False
diff --git a/prowler/providers/huaweicloud/services/iam/iam_user_disabled/__init__.py b/prowler/providers/huaweicloud/services/iam/iam_user_disabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/iam/iam_user_disabled/iam_user_disabled.metadata.json b/prowler/providers/huaweicloud/services/iam/iam_user_disabled/iam_user_disabled.metadata.json
new file mode 100644
index 0000000000..425484f7cf
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/iam/iam_user_disabled/iam_user_disabled.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "huaweicloud",
+ "CheckID": "iam_user_disabled",
+ "CheckTitle": "IAM disabled users are reviewed and removed if stale",
+ "CheckType": [],
+ "ServiceName": "iam",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "low",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "Disabled **IAM** user accounts that are no longer needed should be removed to reduce the **attack surface**. Stale disabled accounts may still have associated resources, permissions, or **access keys** that could be exploited if re-enabled.",
+ "Risk": "Disabled **IAM** users may retain permissions and **access keys** that could be re-enabled by an attacker with sufficient privileges. Keeping stale accounts increases the **attack surface** and complicates access management audits.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://support.huaweicloud.com/intl/en-us/usermanual-iam/iam_02_0004.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "hcloud IAM KeystoneDeleteUser --user_id=",
+ "NativeIaC": "",
+ "Other": "1. Log on to the **Huawei Cloud console**.\n2. Choose **IAM & Security**.\n3. Click **Users**.\n4. Review disabled users.\n5. Delete users that are no longer needed.\n6. Click **OK**.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Review disabled IAM users and remove accounts that are no longer needed.",
+ "Url": "https://hub.prowler.com/check/iam_user_disabled"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/huaweicloud/services/iam/iam_user_disabled/iam_user_disabled.py b/prowler/providers/huaweicloud/services/iam/iam_user_disabled/iam_user_disabled.py
new file mode 100644
index 0000000000..7e4960b16f
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/iam/iam_user_disabled/iam_user_disabled.py
@@ -0,0 +1,28 @@
+from prowler.lib.check.models import Check, CheckReportHuaweiCloud
+from prowler.providers.huaweicloud.services.iam.iam_client import iam_client
+
+
+class iam_user_disabled(Check):
+ """Check if Huawei Cloud IAM has disabled users (stale accounts)."""
+
+ def execute(self) -> list[CheckReportHuaweiCloud]:
+ findings = []
+
+ for user in iam_client.users:
+ report = CheckReportHuaweiCloud(metadata=self.metadata(), resource=user)
+ report.region = iam_client.region
+ report.resource_id = user.id
+ report.resource_arn = (
+ f"HUAWEICLOUD::IAM::{iam_client.audited_account}:user/{user.id}"
+ )
+
+ if user.enabled:
+ report.status = "PASS"
+ report.status_extended = f"IAM user {user.name} ({user.id}) is enabled."
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"IAM user {user.name} ({user.id}) is disabled and should be reviewed for removal if no longer needed."
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/huaweicloud/services/iam/iam_user_mfa_enabled/__init__.py b/prowler/providers/huaweicloud/services/iam/iam_user_mfa_enabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/iam/iam_user_mfa_enabled/iam_user_mfa_enabled.metadata.json b/prowler/providers/huaweicloud/services/iam/iam_user_mfa_enabled/iam_user_mfa_enabled.metadata.json
new file mode 100644
index 0000000000..796c8d3217
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/iam/iam_user_mfa_enabled/iam_user_mfa_enabled.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "huaweicloud",
+ "CheckID": "iam_user_mfa_enabled",
+ "CheckTitle": "IAM users have MFA enabled",
+ "CheckType": [],
+ "ServiceName": "iam",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "**Huawei Cloud IAM** users should have **MFA** enabled. **MFA** adds an extra layer of protection on top of a username and password. With **MFA** enabled, when a user signs in to the **Huawei Cloud console**, they are prompted for their username and password as well as for an authentication code from their **MFA device**.",
+ "Risk": "Without **MFA**, a compromised user password enables unauthorized access to cloud resources. An attacker could modify, delete, or create resources depending on the user's permissions, harming **confidentiality**, **integrity**, and **availability**.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://support.huaweicloud.com/intl/en-us/usermanual-iam/iam_01_0012.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "hcloud IAM UpdateLoginProtect --user_id= --login_protect.enabled=true --login_protect.verification_method=\"vmfa\"",
+ "NativeIaC": "",
+ "Other": "1. Log on to the **Huawei Cloud console** as an administrator.\n2. Choose **IAM & Security**.\n3. Click **Users**.\n4. Select the target user.\n5. Click the **Security Settings** tab.\n6. In the **Virtual MFA Device** section, click **Enable**.\n7. Scan the QR code with a virtual MFA application (e.g., `Google Authenticator`).\n8. Enter two consecutive verification codes to bind the **MFA device**.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Enable MFA for all IAM users and enforce MFA at the account level.",
+ "Url": "https://hub.prowler.com/check/iam_user_mfa_enabled"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/huaweicloud/services/iam/iam_user_mfa_enabled/iam_user_mfa_enabled.py b/prowler/providers/huaweicloud/services/iam/iam_user_mfa_enabled/iam_user_mfa_enabled.py
new file mode 100644
index 0000000000..dddd3a899a
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/iam/iam_user_mfa_enabled/iam_user_mfa_enabled.py
@@ -0,0 +1,34 @@
+from prowler.lib.check.models import Check, CheckReportHuaweiCloud
+from prowler.providers.huaweicloud.services.iam.iam_client import iam_client
+
+
+class iam_user_mfa_enabled(Check):
+ """Check if Huawei Cloud IAM users have MFA enabled."""
+
+ def execute(self) -> list[CheckReportHuaweiCloud]:
+ findings = []
+
+ for user in iam_client.users:
+ report = CheckReportHuaweiCloud(metadata=self.metadata(), resource=user)
+ report.region = iam_client.region
+ report.resource_id = user.id
+ report.resource_arn = (
+ f"HUAWEICLOUD::IAM::{iam_client.audited_account}:user/{user.id}"
+ )
+
+ user_mfa_devices = [
+ device for device in iam_client.mfa_devices if device.user_id == user.id
+ ]
+
+ if user_mfa_devices:
+ report.status = "PASS"
+ report.status_extended = f"IAM user {user.name} has MFA enabled."
+ else:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"IAM user {user.name} does not have MFA enabled."
+ )
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/huaweicloud/services/kms/__init__.py b/prowler/providers/huaweicloud/services/kms/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/kms/kms_client.py b/prowler/providers/huaweicloud/services/kms/kms_client.py
new file mode 100644
index 0000000000..2cf04e6c34
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/kms/kms_client.py
@@ -0,0 +1,4 @@
+from prowler.providers.common.provider import Provider
+from prowler.providers.huaweicloud.services.kms.kms_service import KMS
+
+kms_client = KMS(Provider.get_global_provider())
diff --git a/prowler/providers/huaweicloud/services/kms/kms_key_not_pending_deletion/__init__.py b/prowler/providers/huaweicloud/services/kms/kms_key_not_pending_deletion/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/kms/kms_key_not_pending_deletion/kms_key_not_pending_deletion.metadata.json b/prowler/providers/huaweicloud/services/kms/kms_key_not_pending_deletion/kms_key_not_pending_deletion.metadata.json
new file mode 100644
index 0000000000..5cea400b1a
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/kms/kms_key_not_pending_deletion/kms_key_not_pending_deletion.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "huaweicloud",
+ "CheckID": "kms_key_not_pending_deletion",
+ "CheckTitle": "KMS keys are not in pending deletion state",
+ "CheckType": [],
+ "ServiceName": "kms",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "security",
+ "Description": "**Huawei Cloud Key Management Service (KMS)** keys that are in `PendingDeletion` state should be reviewed. Keys scheduled for deletion may cause **decryption** failures for dependent resources, leading to data unavailability. Ensure deletion is intentional and all dependent resources have been migrated.",
+ "Risk": "**KMS** keys in `PendingDeletion` will become permanently unavailable after the waiting period expires. Any data encrypted with these keys will become permanently inaccessible, potentially causing **data loss** and **service outages**.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://support.huaweicloud.com/intl/en-us/usermanual-dew/dew_01_0179.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "hcloud KMS CancelKeyDeletion --key_id=",
+ "NativeIaC": "",
+ "Other": "1. Log on to the **Huawei Cloud console**.\n2. Choose **KMS**.\n3. Click **Keys**.\n4. Find the key in `PendingDeletion` state.\n5. If deletion was not intended, click **Cancel Deletion**.\n6. Confirm the action.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Review KMS keys in pending deletion state and cancel deletion if the key is still needed.",
+ "Url": "https://hub.prowler.com/check/kms_key_not_pending_deletion"
+ }
+ },
+ "Categories": [
+ "encryption"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "KMS key state '4' indicates PendingDeletion in Huawei Cloud."
+}
diff --git a/prowler/providers/huaweicloud/services/kms/kms_key_not_pending_deletion/kms_key_not_pending_deletion.py b/prowler/providers/huaweicloud/services/kms/kms_key_not_pending_deletion/kms_key_not_pending_deletion.py
new file mode 100644
index 0000000000..c8dc0a2b8d
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/kms/kms_key_not_pending_deletion/kms_key_not_pending_deletion.py
@@ -0,0 +1,28 @@
+from prowler.lib.check.models import Check, CheckReportHuaweiCloud
+from prowler.providers.huaweicloud.services.kms.kms_client import kms_client
+
+
+class kms_key_not_pending_deletion(Check):
+ """Check if KMS keys are not in pending deletion state."""
+
+ def execute(self) -> list[CheckReportHuaweiCloud]:
+ findings = []
+
+ for key in kms_client.keys:
+ report = CheckReportHuaweiCloud(metadata=self.metadata(), resource=key)
+ report.region = key.region
+ report.resource_id = key.id
+ report.resource_arn = f"huaweicloud:kms:{key.region}:{kms_client.audited_account}:key/{key.id}"
+
+ if key.state == "4":
+ report.status = "FAIL"
+ report.status_extended = (
+ f"KMS key {key.alias} ({key.id}) is in pending deletion state."
+ )
+ else:
+ report.status = "PASS"
+ report.status_extended = f"KMS key {key.alias} ({key.id}) is not in pending deletion state (state: {key.state})."
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/huaweicloud/services/kms/kms_key_rotation_enabled/__init__.py b/prowler/providers/huaweicloud/services/kms/kms_key_rotation_enabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.metadata.json b/prowler/providers/huaweicloud/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.metadata.json
new file mode 100644
index 0000000000..79d709f727
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "huaweicloud",
+ "CheckID": "kms_key_rotation_enabled",
+ "CheckTitle": "KMS keys have rotation enabled",
+ "CheckType": [],
+ "ServiceName": "kms",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "security",
+ "Description": "Ensure that **Huawei Cloud Key Management Service (KMS)** keys have automatic **key rotation** enabled to regularly rotate the cryptographic material.",
+ "Risk": "Without **key rotation**, the same cryptographic material is used indefinitely, increasing the risk of **key compromise** over time. If a key is compromised, all data encrypted with that key version is at risk.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://support.huaweicloud.com/intl/en-us/usermanual-dew/dew_01_0139.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "hcloud KMS EnableKeyRotation --key_id=",
+ "NativeIaC": "",
+ "Other": "1. Log on to the **Huawei Cloud console**.\n2. Navigate to **Key Management Service**.\n3. Select the key.\n4. Click the **Rotation** tab.\n5. Enable **rotation** and set the rotation period.\n6. Click **OK**.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Enable automatic key rotation for all KMS keys.",
+ "Url": "https://hub.prowler.com/check/kms_key_rotation_enabled"
+ }
+ },
+ "Categories": [
+ "encryption"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/huaweicloud/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.py b/prowler/providers/huaweicloud/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.py
new file mode 100644
index 0000000000..cb5c65dbef
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.py
@@ -0,0 +1,31 @@
+from prowler.lib.check.models import Check, CheckReportHuaweiCloud
+from prowler.providers.huaweicloud.services.kms.kms_client import kms_client
+
+
+class kms_key_rotation_enabled(Check):
+ """Check if KMS keys have rotation enabled."""
+
+ def execute(self) -> list[CheckReportHuaweiCloud]:
+ findings = []
+
+ for key in kms_client.keys:
+ report = CheckReportHuaweiCloud(metadata=self.metadata(), resource=key)
+ report.region = key.region
+ report.resource_id = key.id
+ report.resource_arn = f"huaweicloud:kms:{key.region}:{kms_client.audited_account}:key/{key.id}"
+
+ if key.is_rotation_enabled:
+ report.status = "PASS"
+ report.status_extended = (
+ f"KMS key {key.alias} ({key.id}) has rotation enabled "
+ f"with period {key.rotation_period}."
+ )
+ else:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"KMS key {key.alias} ({key.id}) does not have rotation enabled."
+ )
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/huaweicloud/services/kms/kms_service.py b/prowler/providers/huaweicloud/services/kms/kms_service.py
new file mode 100644
index 0000000000..2cfd80fe65
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/kms/kms_service.py
@@ -0,0 +1,102 @@
+from typing import List
+
+from prowler.lib.logger import logger
+from prowler.providers.huaweicloud.lib.service.service import HuaweiCloudService
+from prowler.providers.huaweicloud.models import HuaweiCloudBaseModel
+
+
+class KMS(HuaweiCloudService):
+ """
+ KMS (Key Management Service) service class for Huawei Cloud.
+
+ This class provides methods to interact with Huawei Cloud KMS service
+ to retrieve KMS keys and their rotation status.
+ """
+
+ def __init__(self, provider):
+ super().__init__(__class__.__name__, provider)
+
+ self.keys: List[KMSKey] = []
+
+ self.__threading_call__(self._list_keys)
+
+ def _list_keys(self, regional_client):
+ """List all KMS keys across regions."""
+ region = getattr(regional_client, "region", "unknown")
+ logger.info(f"KMS - Listing Keys in {region}...")
+
+ try:
+ from huaweicloudsdkkms.v2 import ListKeysRequest, ListKeysRequestBody
+
+ marker = None
+ while True:
+ request = ListKeysRequest(
+ body=ListKeysRequestBody(limit="200", marker=marker)
+ )
+ response = self._call_with_retries(regional_client.list_keys, request)
+ if not response or not response.key_details:
+ break
+
+ for key_data in response.key_details:
+ key_id = getattr(key_data, "key_id", None) or ""
+ is_rotation_enabled = False
+ rotation_period = ""
+
+ try:
+ from huaweicloudsdkkms.v2 import (
+ OperateKeyRequestBody,
+ ShowKeyRotationStatusRequest,
+ )
+
+ rotation_request = ShowKeyRotationStatusRequest(
+ body=OperateKeyRequestBody(key_id=key_id)
+ )
+ rotation_response = self._call_with_retries(
+ regional_client.show_key_rotation_status, rotation_request
+ )
+ if rotation_response:
+ is_rotation_enabled = getattr(
+ rotation_response, "key_rotation_enabled", False
+ )
+ rotation_period = getattr(
+ rotation_response, "rotation_interval", ""
+ )
+ except Exception as rotation_error:
+ logger.error(
+ f"{region} -- KMS rotation check failed for key {key_id}: {rotation_error}"
+ )
+
+ self.keys.append(
+ KMSKey(
+ id=key_id,
+ domain_id=getattr(key_data, "domain_id", ""),
+ alias=getattr(key_data, "key_alias", ""),
+ state=getattr(key_data, "key_state", ""),
+ is_rotation_enabled=is_rotation_enabled,
+ rotation_period=rotation_period,
+ region=region,
+ )
+ )
+
+ if getattr(response, "truncated", "false") != "true":
+ break
+ marker = getattr(response, "next_marker", None)
+ if not marker:
+ break
+
+ except Exception as error:
+ logger.error(
+ f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+
+
+class KMSKey(HuaweiCloudBaseModel):
+ """KMS Key model."""
+
+ id: str
+ domain_id: str = ""
+ alias: str = ""
+ state: str = ""
+ is_rotation_enabled: bool = False
+ rotation_period: str = ""
+ region: str = ""
diff --git a/prowler/providers/huaweicloud/services/obs/__init__.py b/prowler/providers/huaweicloud/services/obs/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/obs/obs_bucket_public_access/__init__.py b/prowler/providers/huaweicloud/services/obs/obs_bucket_public_access/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/obs/obs_bucket_public_access/obs_bucket_public_access.metadata.json b/prowler/providers/huaweicloud/services/obs/obs_bucket_public_access/obs_bucket_public_access.metadata.json
new file mode 100644
index 0000000000..acd89b8108
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/obs/obs_bucket_public_access/obs_bucket_public_access.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "huaweicloud",
+ "CheckID": "obs_bucket_public_access",
+ "CheckTitle": "OBS buckets are not publicly accessible",
+ "CheckType": [],
+ "ServiceName": "obs",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "critical",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "storage",
+ "Description": "Ensure that **Object Storage Service (OBS)** buckets are not **publicly accessible** to prevent unauthorized access to stored objects.",
+ "Risk": "Publicly accessible **OBS** buckets allow anyone on the internet to list, read, or modify stored objects, potentially exposing sensitive data to unauthorized access.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://support.huaweicloud.com/intl/en-us/usermanual-obs/obs_03_0739.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "obsutil chattri obs:// -acl=private",
+ "NativeIaC": "",
+ "Other": "1. Log on to the **Huawei Cloud console**.\n2. Navigate to **Object Storage Service**.\n3. Select the **bucket**.\n4. Click the **Permissions** tab.\n5. Remove any `public read` or `public read/write` permissions.\n6. Click **Save**.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Remove public access permissions from OBS buckets and use IAM policies for controlled access.",
+ "Url": "https://hub.prowler.com/check/obs_bucket_public_access"
+ }
+ },
+ "Categories": [
+ "internet-exposed"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/huaweicloud/services/obs/obs_bucket_public_access/obs_bucket_public_access.py b/prowler/providers/huaweicloud/services/obs/obs_bucket_public_access/obs_bucket_public_access.py
new file mode 100644
index 0000000000..b813ae37d8
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/obs/obs_bucket_public_access/obs_bucket_public_access.py
@@ -0,0 +1,30 @@
+from prowler.lib.check.models import Check, CheckReportHuaweiCloud
+from prowler.providers.huaweicloud.services.obs.obs_client import obs_client
+
+
+class obs_bucket_public_access(Check):
+ """Check if OBS buckets are not publicly accessible."""
+
+ def execute(self) -> list[CheckReportHuaweiCloud]:
+ findings = []
+
+ for bucket in obs_client.buckets:
+ report = CheckReportHuaweiCloud(metadata=self.metadata(), resource=bucket)
+ report.region = bucket.region
+ report.resource_id = bucket.name
+ report.resource_arn = f"huaweicloud:obs:{bucket.region}:{obs_client.audited_account}:bucket/{bucket.name}"
+
+ if bucket.is_public:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"OBS bucket {bucket.name} is publicly accessible."
+ )
+ else:
+ report.status = "PASS"
+ report.status_extended = (
+ f"OBS bucket {bucket.name} is not publicly accessible."
+ )
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/huaweicloud/services/obs/obs_client.py b/prowler/providers/huaweicloud/services/obs/obs_client.py
new file mode 100644
index 0000000000..1cffbdb6ab
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/obs/obs_client.py
@@ -0,0 +1,4 @@
+from prowler.providers.common.provider import Provider
+from prowler.providers.huaweicloud.services.obs.obs_service import OBS
+
+obs_client = OBS(Provider.get_global_provider())
diff --git a/prowler/providers/huaweicloud/services/obs/obs_service.py b/prowler/providers/huaweicloud/services/obs/obs_service.py
new file mode 100644
index 0000000000..c1ad33e7ff
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/obs/obs_service.py
@@ -0,0 +1,116 @@
+from typing import List
+
+from huaweicloudsdkobs.v1 import (
+ GetBucketPolicyPublicStatusRequest,
+ GetBucketPublicStatusRequest,
+ ListBucketsRequest,
+)
+
+from prowler.lib.logger import logger
+from prowler.providers.huaweicloud.lib.service.service import HuaweiCloudService
+from prowler.providers.huaweicloud.models import HuaweiCloudBaseModel
+
+
+class OBS(HuaweiCloudService):
+ """
+ OBS (Object Storage Service) service class for Huawei Cloud.
+
+ This class provides methods to interact with Huawei Cloud OBS service
+ to retrieve buckets and their configuration.
+ """
+
+ def __init__(self, provider):
+ super().__init__(__class__.__name__, provider, global_service=True)
+
+ self.buckets: List[Bucket] = []
+ self._region_clients = {}
+
+ self._list_buckets()
+
+ def _client_for_region(self, region):
+ """Return an OBS client bound to the bucket's region (cached).
+
+ Bucket-scoped operations must target the bucket's own region endpoint,
+ so a client is created per bucket region and reused. Falls back to the
+ default-region client if one cannot be created.
+ """
+ if region not in self._region_clients:
+ try:
+ self._region_clients[region] = self.session.client("obs", region)
+ except Exception as error:
+ logger.error(
+ f"OBS - Could not create client for region {region}: {error}"
+ )
+ self._region_clients[region] = None
+ return self._region_clients[region] or self.client
+
+ def _list_buckets(self):
+ """List all OBS buckets."""
+ if not self.client:
+ return
+
+ region = self.region
+ logger.info(f"OBS - Listing Buckets in {region}...")
+
+ try:
+ response = self._call_with_retries(
+ self.client.list_buckets, ListBucketsRequest()
+ )
+
+ if response and response.buckets and response.buckets.bucket:
+ for bucket_data in response.buckets.bucket:
+ bucket_name = getattr(bucket_data, "name", "") or ""
+ bucket_region = getattr(bucket_data, "location", None) or region
+ bucket_client = self._client_for_region(bucket_region)
+
+ is_public = False
+ acl = ""
+
+ try:
+ public_status = self._call_with_retries(
+ bucket_client.get_bucket_public_status,
+ GetBucketPublicStatusRequest(bucket_name=bucket_name),
+ )
+ if public_status and public_status.is_public:
+ is_public = True
+ except Exception as public_error:
+ logger.error(
+ f"OBS - Public status check failed for bucket {bucket_name}: {public_error}"
+ )
+
+ try:
+ policy_status = self._call_with_retries(
+ bucket_client.get_bucket_policy_public_status,
+ GetBucketPolicyPublicStatusRequest(bucket_name=bucket_name),
+ )
+ if policy_status and policy_status.is_public:
+ is_public = True
+ except Exception as policy_error:
+ logger.error(
+ f"OBS - Policy public status check failed for bucket {bucket_name}: {policy_error}"
+ )
+
+ acl = "public" if is_public else "private"
+
+ self.buckets.append(
+ Bucket(
+ name=bucket_name,
+ region=bucket_region,
+ is_public=is_public,
+ acl=acl,
+ )
+ )
+
+ except Exception as error:
+ logger.error(
+ f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+
+
+class Bucket(HuaweiCloudBaseModel):
+ """OBS Bucket model."""
+
+ name: str
+ region: str = ""
+ is_public: bool = False
+ acl: str = ""
diff --git a/prowler/providers/huaweicloud/services/rds/__init__.py b/prowler/providers/huaweicloud/services/rds/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/rds/rds_backup_enabled/__init__.py b/prowler/providers/huaweicloud/services/rds/rds_backup_enabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/rds/rds_backup_enabled/rds_backup_enabled.metadata.json b/prowler/providers/huaweicloud/services/rds/rds_backup_enabled/rds_backup_enabled.metadata.json
new file mode 100644
index 0000000000..a9a7f364d1
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/rds/rds_backup_enabled/rds_backup_enabled.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "huaweicloud",
+ "CheckID": "rds_backup_enabled",
+ "CheckTitle": "RDS instances have automated backup enabled",
+ "CheckType": [],
+ "ServiceName": "rds",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "database",
+ "Description": "Ensure that **Huawei Cloud Relational Database Service (RDS)** instances have **automatic backup** enabled to protect against data loss.",
+ "Risk": "Without **automatic backups**, data loss from accidental deletion, corruption, or hardware failure cannot be recovered, potentially leading to permanent **data loss**.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://support.huaweicloud.com/intl/en-us/usermanual-rds-mysql/rds_08_0047.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "hcloud RDS SetBackupPolicy --instance_id= --backup_policy.keep_days=7 --backup_policy.start_time=\"01:00-02:00\"",
+ "NativeIaC": "",
+ "Other": "1. Log on to the **Huawei Cloud console**.\n2. Navigate to **Relational Database Service**.\n3. Select the instance.\n4. Click the **Backup and Restoration** tab.\n5. Configure the **automatic backup** policy with a retention period.\n6. Click **Save**.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Enable automated backup for all RDS instances with an appropriate retention period.",
+ "Url": "https://hub.prowler.com/check/rds_backup_enabled"
+ }
+ },
+ "Categories": [
+ "resilience"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/huaweicloud/services/rds/rds_backup_enabled/rds_backup_enabled.py b/prowler/providers/huaweicloud/services/rds/rds_backup_enabled/rds_backup_enabled.py
new file mode 100644
index 0000000000..29d34b0028
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/rds/rds_backup_enabled/rds_backup_enabled.py
@@ -0,0 +1,32 @@
+from prowler.lib.check.models import Check, CheckReportHuaweiCloud
+from prowler.providers.huaweicloud.services.rds.rds_client import rds_client
+
+
+class rds_backup_enabled(Check):
+ """Check if RDS instances have automated backup enabled."""
+
+ def execute(self) -> list[CheckReportHuaweiCloud]:
+ findings = []
+
+ for instance in rds_client.instances:
+ report = CheckReportHuaweiCloud(metadata=self.metadata(), resource=instance)
+ report.region = instance.region
+ report.resource_id = instance.id
+ report.resource_arn = f"huaweicloud:rds:{instance.region}:{rds_client.audited_account}:instance/{instance.id}"
+
+ if instance.backup_enabled:
+ report.status = "PASS"
+ report.status_extended = (
+ f"RDS instance {instance.name} ({instance.id}) "
+ f"has automated backup enabled."
+ )
+ else:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"RDS instance {instance.name} ({instance.id}) "
+ f"does not have automated backup enabled."
+ )
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/huaweicloud/services/rds/rds_client.py b/prowler/providers/huaweicloud/services/rds/rds_client.py
new file mode 100644
index 0000000000..4e42996e83
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/rds/rds_client.py
@@ -0,0 +1,4 @@
+from prowler.providers.common.provider import Provider
+from prowler.providers.huaweicloud.services.rds.rds_service import RDS
+
+rds_client = RDS(Provider.get_global_provider())
diff --git a/prowler/providers/huaweicloud/services/rds/rds_instance_disk_encryption/__init__.py b/prowler/providers/huaweicloud/services/rds/rds_instance_disk_encryption/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/rds/rds_instance_disk_encryption/rds_instance_disk_encryption.metadata.json b/prowler/providers/huaweicloud/services/rds/rds_instance_disk_encryption/rds_instance_disk_encryption.metadata.json
new file mode 100644
index 0000000000..777cd97c22
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/rds/rds_instance_disk_encryption/rds_instance_disk_encryption.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "huaweicloud",
+ "CheckID": "rds_instance_disk_encryption",
+ "CheckTitle": "RDS instances should have disk encryption enabled",
+ "CheckType": [],
+ "ServiceName": "rds",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "database",
+ "Description": "Ensure that **Huawei Cloud Relational Database Service (RDS)** instances have storage disk **encryption** enabled using a **Key Management Service (KMS)** key.",
+ "Risk": "**RDS** instances without disk **encryption** store data at rest in plaintext, which may expose sensitive information if the underlying storage is compromised.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://support.huaweicloud.com/intl/en-us/usermanual-rds/rds_05_0045.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "hcloud RDS CreateInstance --instance.disk_encryption_id= --instance.name= --instance.datastore.type=",
+ "NativeIaC": "",
+ "Other": "1. Log on to the **Huawei Cloud console**\n2. Navigate to **Relational Database Service**\n3. Select the instance\n4. Click **More** > **Manage Disk Encryption**\n5. Enable disk **encryption** and select a **KMS** key",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Enable disk encryption on all RDS instances using a customer-managed KMS key.",
+ "Url": "https://hub.prowler.com/check/rds_instance_disk_encryption"
+ }
+ },
+ "Categories": [
+ "encryption"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/huaweicloud/services/rds/rds_instance_disk_encryption/rds_instance_disk_encryption.py b/prowler/providers/huaweicloud/services/rds/rds_instance_disk_encryption/rds_instance_disk_encryption.py
new file mode 100644
index 0000000000..53248a5e84
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/rds/rds_instance_disk_encryption/rds_instance_disk_encryption.py
@@ -0,0 +1,29 @@
+from prowler.lib.check.models import Check, CheckReportHuaweiCloud
+from prowler.providers.huaweicloud.services.rds.rds_client import rds_client
+
+
+class rds_instance_disk_encryption(Check):
+ """Ensure RDS instances have disk encryption enabled."""
+
+ def execute(self) -> list[CheckReportHuaweiCloud]:
+ findings = []
+
+ for instance in rds_client.instances:
+ report = CheckReportHuaweiCloud(metadata=self.metadata(), resource=instance)
+ report.region = instance.region
+ report.resource_id = instance.id
+ report.resource_arn = f"huaweicloud:rds:{instance.region}:{rds_client.audited_account}:instance/{instance.id}"
+
+ if instance.disk_encryption_id:
+ report.status = "PASS"
+ report.status_extended = (
+ f"RDS instance {instance.name} ({instance.id}) has disk encryption enabled "
+ f"with KMS key {instance.disk_encryption_id}."
+ )
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"RDS instance {instance.name} ({instance.id}) does not have disk encryption enabled."
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/huaweicloud/services/rds/rds_public_access/__init__.py b/prowler/providers/huaweicloud/services/rds/rds_public_access/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/rds/rds_public_access/rds_public_access.metadata.json b/prowler/providers/huaweicloud/services/rds/rds_public_access/rds_public_access.metadata.json
new file mode 100644
index 0000000000..cc5599ed60
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/rds/rds_public_access/rds_public_access.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "huaweicloud",
+ "CheckID": "rds_public_access",
+ "CheckTitle": "RDS instances are not publicly accessible",
+ "CheckType": [],
+ "ServiceName": "rds",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "critical",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "database",
+ "Description": "Ensure that **Huawei Cloud Relational Database Service (RDS)** instances do not have public IP addresses to prevent direct **internet access** to databases.",
+ "Risk": "**RDS** instances with public IP addresses are accessible from the internet, exposing databases to potential **brute-force attacks**, **SQL injection**, and **unauthorized data access**.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://support.huaweicloud.com/intl/en-us/usermanual-rds-mysql/rds_public_accessibility.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "hcloud EIP DisassociatePublicips --publicip_id=",
+ "NativeIaC": "",
+ "Other": "1. Log on to the **Huawei Cloud console**.\n2. Navigate to **Relational Database Service**.\n3. Select the instance.\n4. Click **More** > **Unbind EIP**.\n5. Confirm the unbinding.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Remove public IP addresses from RDS instances and use VPC peering or VPN for access.",
+ "Url": "https://hub.prowler.com/check/rds_public_access"
+ }
+ },
+ "Categories": [
+ "internet-exposed"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/huaweicloud/services/rds/rds_public_access/rds_public_access.py b/prowler/providers/huaweicloud/services/rds/rds_public_access/rds_public_access.py
new file mode 100644
index 0000000000..4483cf7e48
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/rds/rds_public_access/rds_public_access.py
@@ -0,0 +1,32 @@
+from prowler.lib.check.models import Check, CheckReportHuaweiCloud
+from prowler.providers.huaweicloud.services.rds.rds_client import rds_client
+
+
+class rds_public_access(Check):
+ """Check if RDS instances are not publicly accessible."""
+
+ def execute(self) -> list[CheckReportHuaweiCloud]:
+ findings = []
+
+ for instance in rds_client.instances:
+ report = CheckReportHuaweiCloud(metadata=self.metadata(), resource=instance)
+ report.region = instance.region
+ report.resource_id = instance.id
+ report.resource_arn = f"huaweicloud:rds:{instance.region}:{rds_client.audited_account}:instance/{instance.id}"
+
+ if instance.is_public:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"RDS instance {instance.name} ({instance.id}) "
+ f"has a public IP address {instance.public_ip}."
+ )
+ else:
+ report.status = "PASS"
+ report.status_extended = (
+ f"RDS instance {instance.name} ({instance.id}) "
+ f"does not have a public IP address."
+ )
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/huaweicloud/services/rds/rds_service.py b/prowler/providers/huaweicloud/services/rds/rds_service.py
new file mode 100644
index 0000000000..c6981a3def
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/rds/rds_service.py
@@ -0,0 +1,90 @@
+from typing import List
+
+from prowler.lib.logger import logger
+from prowler.providers.huaweicloud.lib.service.service import HuaweiCloudService
+from prowler.providers.huaweicloud.models import HuaweiCloudBaseModel
+
+
+class RDS(HuaweiCloudService):
+ """
+ RDS (Relational Database Service) service class for Huawei Cloud.
+
+ This class provides methods to interact with Huawei Cloud RDS service
+ to retrieve database instances and their configuration.
+ """
+
+ def __init__(self, provider):
+ super().__init__(__class__.__name__, provider)
+
+ self.instances: List[RDSInstance] = []
+
+ self.__threading_call__(self._list_instances)
+
+ def _list_instances(self, regional_client):
+ """List all RDS instances across regions."""
+ region = getattr(regional_client, "region", "unknown")
+ logger.info(f"RDS - Listing Instances in {region}...")
+
+ try:
+ from huaweicloudsdkrds.v3 import ListInstancesRequest
+
+ request = ListInstancesRequest()
+ response = self._call_with_retries(regional_client.list_instances, request)
+
+ if response and response.instances:
+ for inst_data in response.instances:
+ public_ips = getattr(inst_data, "public_ips", None) or []
+ public_ips = [ip for ip in public_ips if ip and ip.strip()]
+ public_ip = ", ".join(public_ips)
+
+ is_public = bool(public_ips)
+
+ backup_enabled = False
+ backup_strategy = getattr(inst_data, "backup_strategy", None)
+ if backup_strategy:
+ keep_days = getattr(backup_strategy, "keep_days", 0)
+ if keep_days and keep_days > 0:
+ backup_enabled = True
+
+ datastore = getattr(inst_data, "datastore", None)
+ engine = getattr(datastore, "type", "") if datastore else ""
+ engine_version = (
+ getattr(datastore, "version", "") if datastore else ""
+ )
+
+ self.instances.append(
+ RDSInstance(
+ id=getattr(inst_data, "id", None) or "",
+ name=getattr(inst_data, "name", None) or "",
+ status=getattr(inst_data, "status", None) or "",
+ engine=engine,
+ engine_version=engine_version,
+ public_ip=public_ip,
+ is_public=is_public,
+ backup_enabled=backup_enabled,
+ region=region,
+ disk_encryption_id=getattr(
+ inst_data, "disk_encryption_id", ""
+ ),
+ )
+ )
+
+ except Exception as error:
+ logger.error(
+ f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+
+
+class RDSInstance(HuaweiCloudBaseModel):
+ """RDS Instance model."""
+
+ id: str
+ name: str
+ status: str = ""
+ engine: str = ""
+ engine_version: str = ""
+ public_ip: str = ""
+ is_public: bool = False
+ backup_enabled: bool = False
+ region: str = ""
+ disk_encryption_id: str = ""
diff --git a/prowler/providers/huaweicloud/services/vpc/__init__.py b/prowler/providers/huaweicloud/services/vpc/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/vpc/vpc_client.py b/prowler/providers/huaweicloud/services/vpc/vpc_client.py
new file mode 100644
index 0000000000..1a1440a040
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/vpc/vpc_client.py
@@ -0,0 +1,4 @@
+from prowler.providers.common.provider import Provider
+from prowler.providers.huaweicloud.services.vpc.vpc_service import VPC
+
+vpc_client = VPC(Provider.get_global_provider())
diff --git a/prowler/providers/huaweicloud/services/vpc/vpc_default_security_group_restricts_all_traffic/__init__.py b/prowler/providers/huaweicloud/services/vpc/vpc_default_security_group_restricts_all_traffic/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/vpc/vpc_default_security_group_restricts_all_traffic/vpc_default_security_group_restricts_all_traffic.metadata.json b/prowler/providers/huaweicloud/services/vpc/vpc_default_security_group_restricts_all_traffic/vpc_default_security_group_restricts_all_traffic.metadata.json
new file mode 100644
index 0000000000..719a18b107
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/vpc/vpc_default_security_group_restricts_all_traffic/vpc_default_security_group_restricts_all_traffic.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "huaweicloud",
+ "CheckID": "vpc_default_security_group_restricts_all_traffic",
+ "CheckTitle": "Default security groups restrict all traffic",
+ "CheckType": [],
+ "ServiceName": "vpc",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "network",
+ "Description": "Ensure that **default security groups** restrict all traffic to prevent **unauthorized access** to cloud resources.",
+ "Risk": "**Security groups** with rules that allow open **CIDR** ranges (`0.0.0.0/0` or `::/0`) expose resources to traffic from any source on the internet. This significantly increases the **attack surface** and can lead to **unauthorized access**, **data exfiltration**, or **service disruption**.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://support.huaweicloud.com/intl/en-us/vpc_faq/vpc_faq_0036.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "hcloud VPC DeleteSecurityGroupRule --security_group_rule_id=",
+ "NativeIaC": "",
+ "Other": "1. Log on to the **Huawei Cloud** console\n2. Navigate to **VPC** > **Security Groups**\n3. Select the **default security group**\n4. Review and delete any rules with `0.0.0.0/0` or `::/0` as the source/destination\n5. Add restrictive rules as needed",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Remove or restrict security group rules that allow traffic from open CIDR ranges (0.0.0.0/0 or ::/0). Default security groups should not allow unrestricted inbound or outbound traffic.",
+ "Url": "https://hub.prowler.com/check/vpc_default_security_group_restricts_all_traffic"
+ }
+ },
+ "Categories": [
+ "internet-exposed"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/huaweicloud/services/vpc/vpc_default_security_group_restricts_all_traffic/vpc_default_security_group_restricts_all_traffic.py b/prowler/providers/huaweicloud/services/vpc/vpc_default_security_group_restricts_all_traffic/vpc_default_security_group_restricts_all_traffic.py
new file mode 100644
index 0000000000..a9e771582d
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/vpc/vpc_default_security_group_restricts_all_traffic/vpc_default_security_group_restricts_all_traffic.py
@@ -0,0 +1,48 @@
+from prowler.lib.check.models import Check, CheckReportHuaweiCloud
+from prowler.providers.huaweicloud.services.vpc.vpc_client import vpc_client
+from prowler.providers.huaweicloud.services.vpc.vpc_service import (
+ DEFAULT_SECURITY_GROUP_NAMES,
+ rule_source_is_open,
+)
+
+
+class vpc_default_security_group_restricts_all_traffic(Check):
+ """Check if the default security group restricts all traffic."""
+
+ def execute(self) -> list[CheckReportHuaweiCloud]:
+ findings = []
+
+ for sg in vpc_client.security_groups.values():
+ if sg.name not in DEFAULT_SECURITY_GROUP_NAMES:
+ continue
+
+ report = CheckReportHuaweiCloud(metadata=self.metadata(), resource=sg)
+ report.region = sg.region
+ report.resource_id = sg.id
+ report.resource_arn = f"huaweicloud:vpc:{sg.region}:{vpc_client.audited_account}:security-group/{sg.id}"
+
+ open_directions = []
+ for rule in sg.rules:
+ if rule.direction not in ("ingress", "egress"):
+ continue
+ if not rule_source_is_open(rule):
+ continue
+ if rule.direction not in open_directions:
+ open_directions.append(rule.direction)
+
+ if open_directions:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"Default security group {sg.name} ({sg.id}) has "
+ f"{' and '.join(open_directions)} rule(s) open to any source."
+ )
+ else:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Default security group {sg.name} ({sg.id}) does not "
+ "have any rule open to any source."
+ )
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/huaweicloud/services/vpc/vpc_security_group_all_protocols_open/__init__.py b/prowler/providers/huaweicloud/services/vpc/vpc_security_group_all_protocols_open/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/vpc/vpc_security_group_all_protocols_open/vpc_security_group_all_protocols_open.metadata.json b/prowler/providers/huaweicloud/services/vpc/vpc_security_group_all_protocols_open/vpc_security_group_all_protocols_open.metadata.json
new file mode 100644
index 0000000000..9e77bc948e
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/vpc/vpc_security_group_all_protocols_open/vpc_security_group_all_protocols_open.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "huaweicloud",
+ "CheckID": "vpc_security_group_all_protocols_open",
+ "CheckTitle": "VPC security groups should not allow ingress from 0.0.0.0/0 on all ports/protocols",
+ "CheckType": [],
+ "ServiceName": "vpc",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "network",
+ "Description": "Ensure that **Virtual Private Cloud (VPC)** **security groups** do not have `ingress` rules that allow all protocols from `0.0.0.0/0` (i.e., rules with no port range specified).",
+ "Risk": "**Security group** rules that allow all protocols from `0.0.0.0/0` expose the associated resources to unrestricted **inbound** traffic on every port, significantly increasing the **attack surface**.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://support.huaweicloud.com/intl/en-us/usermanual-vpc/vpc_Sg_0001.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "hcloud VPC DeleteSecurityGroupRule --security_group_rule_id=",
+ "NativeIaC": "",
+ "Other": "1. Log on to the **Huawei Cloud** console\n2. Navigate to **Virtual Private Cloud** > **Access Control** > **Security Groups**\n3. Select the **security group**\n4. Identify the `ingress` rule allowing all protocols from `0.0.0.0/0`\n5. Delete or restrict the rule to specific ports and IP ranges",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Replace any ingress rule allowing all protocols from 0.0.0.0/0 with specific port and IP range restrictions following least-privilege principles.",
+ "Url": "https://hub.prowler.com/check/vpc_security_group_all_protocols_open"
+ }
+ },
+ "Categories": [
+ "internet-exposed"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/huaweicloud/services/vpc/vpc_security_group_all_protocols_open/vpc_security_group_all_protocols_open.py b/prowler/providers/huaweicloud/services/vpc/vpc_security_group_all_protocols_open/vpc_security_group_all_protocols_open.py
new file mode 100644
index 0000000000..0841d66364
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/vpc/vpc_security_group_all_protocols_open/vpc_security_group_all_protocols_open.py
@@ -0,0 +1,41 @@
+from prowler.lib.check.models import Check, CheckReportHuaweiCloud
+from prowler.providers.huaweicloud.services.vpc.vpc_client import vpc_client
+from prowler.providers.huaweicloud.services.vpc.vpc_service import (
+ rule_covers_all_ports,
+ rule_source_is_open,
+)
+
+
+class vpc_security_group_all_protocols_open(Check):
+ """Check if VPC security groups allow all protocols (any port) from 0.0.0.0/0 on ingress."""
+
+ def execute(self) -> list[CheckReportHuaweiCloud]:
+ findings = []
+
+ for sg in vpc_client.security_groups.values():
+ report = CheckReportHuaweiCloud(metadata=self.metadata(), resource=sg)
+ report.region = sg.region
+ report.resource_id = sg.id
+ report.resource_arn = f"huaweicloud:vpc:{sg.region}:{vpc_client.audited_account}:security-group/{sg.id}"
+
+ all_protocol_rules = [
+ rule
+ for rule in sg.rules
+ if rule.direction == "ingress"
+ and rule_source_is_open(rule)
+ and rule_covers_all_ports(rule)
+ ]
+
+ if all_protocol_rules:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"Security group {sg.name} ({sg.id}) allows ingress from 0.0.0.0/0 on all ports/protocols "
+ f"({len(all_protocol_rules)} rule(s))."
+ )
+ else:
+ report.status = "PASS"
+ report.status_extended = f"Security group {sg.name} ({sg.id}) does not allow ingress from 0.0.0.0/0 on all ports/protocols."
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/huaweicloud/services/vpc/vpc_security_group_open_ingress/__init__.py b/prowler/providers/huaweicloud/services/vpc/vpc_security_group_open_ingress/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/vpc/vpc_security_group_open_ingress/vpc_security_group_open_ingress.metadata.json b/prowler/providers/huaweicloud/services/vpc/vpc_security_group_open_ingress/vpc_security_group_open_ingress.metadata.json
new file mode 100644
index 0000000000..a778b090a3
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/vpc/vpc_security_group_open_ingress/vpc_security_group_open_ingress.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "huaweicloud",
+ "CheckID": "vpc_security_group_open_ingress",
+ "CheckTitle": "VPC security groups do not allow open ingress on sensitive ports",
+ "CheckType": [],
+ "ServiceName": "vpc",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "network",
+ "Description": "**Security groups** should not allow `ingress` from `0.0.0.0/0` on sensitive ports such as `SSH` (`22`), `RDP` (`3389`), `MySQL` (`3306`), `Redis` (`6379`), and `MongoDB` (`27017`). Open `ingress` on these ports exposes critical services to the internet and significantly increases the **attack surface**.",
+ "Risk": "Allowing unrestricted `ingress` on sensitive ports exposes services like `SSH`, `RDP`, and databases to the entire internet. This makes them vulnerable to **brute force attacks**, **credential stuffing**, exploitation of known vulnerabilities, and **unauthorized access** to sensitive data.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://support.huaweicloud.com/intl/en-us/usermanual-vpc/vpc_SecurityGroup_0001.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "hcloud VPC DeleteSecurityGroupRule --security_group_rule_id=",
+ "NativeIaC": "",
+ "Other": "1. Log on to the **Huawei Cloud** console.\n2. Choose **VPC**.\n3. Click **Security Groups**.\n4. Select the **security group**.\n5. Edit the `ingress` rule with `0.0.0.0/0` on a sensitive port.\n6. Restrict the source **CIDR** to a trusted IP range.\n7. Click **OK**.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Restrict ingress rules on sensitive ports to trusted IP ranges instead of 0.0.0.0/0.",
+ "Url": "https://hub.prowler.com/check/vpc_security_group_open_ingress"
+ }
+ },
+ "Categories": [
+ "internet-exposed"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Sensitive ports checked: 22 (SSH), 3389 (RDP), 3306 (MySQL), 6379 (Redis), 27017 (MongoDB)."
+}
diff --git a/prowler/providers/huaweicloud/services/vpc/vpc_security_group_open_ingress/vpc_security_group_open_ingress.py b/prowler/providers/huaweicloud/services/vpc/vpc_security_group_open_ingress/vpc_security_group_open_ingress.py
new file mode 100644
index 0000000000..254cc29886
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/vpc/vpc_security_group_open_ingress/vpc_security_group_open_ingress.py
@@ -0,0 +1,48 @@
+from prowler.lib.check.models import Check, CheckReportHuaweiCloud
+from prowler.providers.huaweicloud.services.vpc.vpc_client import vpc_client
+from prowler.providers.huaweicloud.services.vpc.vpc_service import (
+ SENSITIVE_PORTS,
+ rule_covers_port,
+ rule_source_is_open,
+)
+
+
+class vpc_security_group_open_ingress(Check):
+ """Check if VPC security groups allow open ingress on sensitive ports."""
+
+ def execute(self) -> list[CheckReportHuaweiCloud]:
+ findings = []
+
+ for sg in vpc_client.security_groups.values():
+ report = CheckReportHuaweiCloud(metadata=self.metadata(), resource=sg)
+ report.region = sg.region
+ report.resource_id = sg.id
+ report.resource_arn = f"huaweicloud:vpc:{sg.region}:{vpc_client.audited_account}:security-group/{sg.id}"
+
+ open_sensitive_ports = set()
+ for rule in sg.rules:
+ if rule.direction != "ingress":
+ continue
+ if not rule_source_is_open(rule):
+ continue
+ for port in SENSITIVE_PORTS:
+ if rule_covers_port(rule, port):
+ open_sensitive_ports.add(port)
+
+ if open_sensitive_ports:
+ report.status = "FAIL"
+ ports_str = ", ".join(str(p) for p in sorted(open_sensitive_ports))
+ report.status_extended = (
+ f"Security group {sg.name} ({sg.id}) allows open ingress "
+ f"on sensitive port(s): {ports_str}."
+ )
+ else:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Security group {sg.name} ({sg.id}) does not allow "
+ "open ingress on sensitive ports."
+ )
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/huaweicloud/services/vpc/vpc_service.py b/prowler/providers/huaweicloud/services/vpc/vpc_service.py
new file mode 100644
index 0000000000..842fd0bcb3
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/vpc/vpc_service.py
@@ -0,0 +1,213 @@
+from typing import List, Optional
+
+from prowler.lib.logger import logger
+from prowler.lib.scan_filters.scan_filters import is_resource_filtered
+from prowler.providers.huaweicloud.lib.service.service import HuaweiCloudService
+from prowler.providers.huaweicloud.models import HuaweiCloudBaseModel
+
+
+class VPC(HuaweiCloudService):
+ """
+ VPC (Virtual Private Cloud) service class for Huawei Cloud.
+
+ This class provides methods to interact with Huawei Cloud VPC service
+ to retrieve VPCs, security groups, and their rules.
+ """
+
+ def __init__(self, provider):
+ super().__init__(__class__.__name__, provider, global_service=False)
+
+ self.vpcs = {}
+ self.security_groups = {}
+
+ self.__threading_call__(self._list_vpcs)
+ self.__threading_call__(self._list_security_groups)
+
+ def _list_vpcs(self, regional_client):
+ """List all VPCs in the region."""
+ region = getattr(regional_client, "region", "unknown")
+ logger.info(f"VPC - Listing VPCs in {region}...")
+
+ try:
+ from huaweicloudsdkvpc.v2 import ListVpcsRequest
+
+ request = ListVpcsRequest()
+ response = self._call_with_retries(regional_client.list_vpcs, request)
+
+ if response and response.vpcs:
+ for vpc_data in response.vpcs:
+ if not self.audit_resources or is_resource_filtered(
+ vpc_data.id, self.audit_resources
+ ):
+ vpc_id = vpc_data.id
+ # The SDK returns attributes explicitly set to None, so
+ # `getattr(..., default)` alone is not enough; coerce
+ # None to the field default with `or`.
+ self.vpcs[vpc_id] = VPCs(
+ id=vpc_id,
+ name=getattr(vpc_data, "name", None) or vpc_id,
+ region=region,
+ cidr=getattr(vpc_data, "cidr", None) or "",
+ status=getattr(vpc_data, "status", None) or "",
+ description=getattr(vpc_data, "description", None) or "",
+ created_at=getattr(vpc_data, "created_at", None),
+ )
+
+ except Exception as error:
+ logger.error(
+ f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+
+ def _list_security_groups(self, regional_client):
+ """List all security groups and their rules in the region."""
+ region = getattr(regional_client, "region", "unknown")
+ logger.info(f"VPC - Listing Security Groups in {region}...")
+
+ try:
+ from huaweicloudsdkvpc.v2 import ListSecurityGroupsRequest
+
+ request = ListSecurityGroupsRequest()
+ response = self._call_with_retries(
+ regional_client.list_security_groups, request
+ )
+
+ if response and response.security_groups:
+ for sg_data in response.security_groups:
+ if not self.audit_resources or is_resource_filtered(
+ sg_data.id, self.audit_resources
+ ):
+ sg_id = sg_data.id
+ rules = []
+ if (
+ hasattr(sg_data, "security_group_rules")
+ and sg_data.security_group_rules
+ ):
+ for rule_data in sg_data.security_group_rules:
+ # The SDK sets optional fields (protocol,
+ # remote_ip_prefix, description, ...) to None;
+ # coerce to the field default with `or`.
+ rules.append(
+ SecurityGroupRule(
+ id=getattr(rule_data, "id", None) or "",
+ direction=getattr(rule_data, "direction", None)
+ or "",
+ protocol=getattr(rule_data, "protocol", None)
+ or "",
+ ethertype=getattr(rule_data, "ethertype", None)
+ or "",
+ port_range_min=getattr(
+ rule_data, "port_range_min", None
+ ),
+ port_range_max=getattr(
+ rule_data, "port_range_max", None
+ ),
+ remote_ip_prefix=getattr(
+ rule_data, "remote_ip_prefix", None
+ )
+ or "",
+ remote_group_id=getattr(
+ rule_data, "remote_group_id", None
+ )
+ or "",
+ description=getattr(
+ rule_data, "description", None
+ )
+ or "",
+ )
+ )
+
+ self.security_groups[sg_id] = SecurityGroups(
+ id=sg_id,
+ name=getattr(sg_data, "name", None) or sg_id,
+ region=region,
+ vpc_id=getattr(sg_data, "vpc_id", None) or "",
+ description=getattr(sg_data, "description", None) or "",
+ rules=rules,
+ )
+
+ except Exception as error:
+ logger.error(
+ f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+
+
+class VPCs(HuaweiCloudBaseModel):
+ """VPC model."""
+
+ id: str
+ name: str
+ region: str
+ cidr: str
+ status: str = ""
+ description: str = ""
+ created_at: Optional[str] = None
+
+
+class SecurityGroupRule(HuaweiCloudBaseModel):
+ """Security Group Rule model."""
+
+ id: str
+ direction: str
+ protocol: str
+ ethertype: str
+ port_range_min: Optional[int] = None
+ port_range_max: Optional[int] = None
+ remote_ip_prefix: str = ""
+ remote_group_id: str = ""
+ description: str = ""
+
+
+class SecurityGroups(HuaweiCloudBaseModel):
+ """Security Group model."""
+
+ id: str
+ name: str
+ region: str
+ vpc_id: str = ""
+ description: str = ""
+ rules: List[SecurityGroupRule] = []
+
+
+# Names Huawei Cloud uses for the auto-created default security group. It is
+# "default" on China/International and "Sys-default" on Europe.
+DEFAULT_SECURITY_GROUP_NAMES = ("default", "Sys-default")
+
+# Ports flagged as sensitive when open from the internet.
+SENSITIVE_PORTS = frozenset({22, 3389, 3306, 6379, 27017})
+
+
+def rule_source_is_open(rule: SecurityGroupRule) -> bool:
+ """True when a rule allows traffic from any source.
+
+ Huawei Cloud represents "any source" in two ways: an explicit ``0.0.0.0/0``
+ (or ``::/0``) in ``remote_ip_prefix``, or leaving both ``remote_ip_prefix``
+ and ``remote_group_id`` empty. Rules that reference another security group
+ via ``remote_group_id`` are NOT open even when ``remote_ip_prefix`` is
+ empty.
+ """
+ if rule.remote_ip_prefix in ("0.0.0.0/0", "::/0"):
+ return True
+ return not rule.remote_ip_prefix and not rule.remote_group_id
+
+
+def rule_covers_all_ports(rule: SecurityGroupRule) -> bool:
+ """True when a rule effectively opens every TCP/UDP port.
+
+ Huawei encodes "all ports" as both port_range_min and port_range_max being
+ None. A range that spans the full 1-65535 window is equivalent.
+ """
+ if rule.port_range_min is None and rule.port_range_max is None:
+ return True
+ return rule.port_range_min == 1 and rule.port_range_max == 65535
+
+
+def rule_covers_port(rule: SecurityGroupRule, port: int) -> bool:
+ """True when the port is inside the rule's port range (all-ports included)."""
+ if rule_covers_all_ports(rule):
+ return True
+ if rule.port_range_min is None:
+ return False
+ upper = (
+ rule.port_range_max if rule.port_range_max is not None else rule.port_range_min
+ )
+ return rule.port_range_min <= port <= upper
diff --git a/prowler/providers/huaweicloud/services/waf/__init__.py b/prowler/providers/huaweicloud/services/waf/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/waf/waf_client.py b/prowler/providers/huaweicloud/services/waf/waf_client.py
new file mode 100644
index 0000000000..eebd6c52b2
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/waf/waf_client.py
@@ -0,0 +1,4 @@
+from prowler.providers.common.provider import Provider
+from prowler.providers.huaweicloud.services.waf.waf_service import WAF
+
+waf_client = WAF(Provider.get_global_provider())
diff --git a/prowler/providers/huaweicloud/services/waf/waf_enabled/__init__.py b/prowler/providers/huaweicloud/services/waf/waf_enabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/huaweicloud/services/waf/waf_enabled/waf_enabled.metadata.json b/prowler/providers/huaweicloud/services/waf/waf_enabled/waf_enabled.metadata.json
new file mode 100644
index 0000000000..14aff38cdf
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/waf/waf_enabled/waf_enabled.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "huaweicloud",
+ "CheckID": "waf_enabled",
+ "CheckTitle": "WAF (Web Application Firewall) is enabled",
+ "CheckType": [],
+ "ServiceName": "waf",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "network",
+ "Description": "Ensure that **Web Application Firewall (WAF)** is enabled to protect web applications from common exploits.",
+ "Risk": "Without **WAF**, web applications are exposed to common attacks such as SQL injection, cross-site scripting (XSS), and other OWASP Top 10 threats.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://support.huaweicloud.com/intl/en-us/usermanual-waf/waf_01_0001.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "hcloud WAF CreatePolicy --name= --enterprise_project_id=",
+ "NativeIaC": "",
+ "Other": "1. Log on to the **Huawei Cloud console**.\n2. Navigate to **Web Application Firewall**.\n3. Create a **WAF** instance (dedicated or cloud).\n4. Add protected websites/domains.\n5. Configure protection policies and rules.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Enable WAF and configure it to protect all web applications and APIs.",
+ "Url": "https://hub.prowler.com/check/waf_enabled"
+ }
+ },
+ "Categories": [
+ "threat-detection"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/huaweicloud/services/waf/waf_enabled/waf_enabled.py b/prowler/providers/huaweicloud/services/waf/waf_enabled/waf_enabled.py
new file mode 100644
index 0000000000..a21e6491f4
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/waf/waf_enabled/waf_enabled.py
@@ -0,0 +1,46 @@
+from prowler.lib.check.models import Check, CheckReportHuaweiCloud
+from prowler.providers.huaweicloud.services.waf.waf_client import waf_client
+
+
+class waf_enabled(Check):
+ """Check if WAF (Web Application Firewall) is enabled."""
+
+ def execute(self) -> list[CheckReportHuaweiCloud]:
+ findings = []
+
+ if waf_client.instances:
+ for instance in waf_client.instances:
+ report = CheckReportHuaweiCloud(
+ metadata=self.metadata(), resource=instance
+ )
+ report.region = instance.region
+ report.resource_id = instance.id
+ report.resource_arn = f"huaweicloud:waf:{instance.region}:{waf_client.audited_account}:instance/{instance.id}"
+
+ # status: 0 = creating, 1 = running, 2 = deleting, 3 = deleted, 4 = abnormal, 5 = freezing
+ if instance.status == 1:
+ report.status = "PASS"
+ report.status_extended = (
+ f"WAF instance {instance.name} ({instance.id}) "
+ f"is enabled and running."
+ )
+ else:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"WAF instance {instance.name} ({instance.id}) "
+ f"is not running (status: {instance.status})."
+ )
+
+ findings.append(report)
+ else:
+ report = CheckReportHuaweiCloud(metadata=self.metadata(), resource={})
+ report.region = waf_client.region
+ report.resource_id = "waf"
+ report.resource_arn = f"huaweicloud:waf:{waf_client.region}:{waf_client.audited_account}:waf/global"
+ report.status = "FAIL"
+ report.status_extended = (
+ "No WAF instances found. Web Application Firewall is not enabled."
+ )
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/huaweicloud/services/waf/waf_service.py b/prowler/providers/huaweicloud/services/waf/waf_service.py
new file mode 100644
index 0000000000..a6109bfd46
--- /dev/null
+++ b/prowler/providers/huaweicloud/services/waf/waf_service.py
@@ -0,0 +1,62 @@
+from typing import List
+
+from prowler.lib.logger import logger
+from prowler.providers.huaweicloud.lib.service.service import HuaweiCloudService
+from prowler.providers.huaweicloud.models import HuaweiCloudBaseModel
+
+
+class WAF(HuaweiCloudService):
+ """
+ WAF (Web Application Firewall) service class for Huawei Cloud.
+
+ This class provides methods to interact with Huawei Cloud WAF service
+ to retrieve WAF instances (dedicated and cloud) and their status.
+ """
+
+ def __init__(self, provider):
+ super().__init__(__class__.__name__, provider)
+
+ self.instances: List[WAFInstance] = []
+
+ self.__threading_call__(self._list_instances)
+
+ def _list_instances(self, regional_client):
+ """List all WAF dedicated instances across regions."""
+ region = getattr(regional_client, "region", "unknown")
+ logger.info(f"WAF - Listing Instances in {region}...")
+
+ try:
+ from huaweicloudsdkwaf.v1 import ListInstanceRequest
+
+ request = ListInstanceRequest()
+ response = self._call_with_retries(regional_client.list_instance, request)
+
+ if response and response.items:
+ for inst_data in response.items:
+ name = (
+ getattr(inst_data, "instancename", "")
+ or getattr(inst_data, "instance_name", "")
+ or ""
+ )
+ self.instances.append(
+ WAFInstance(
+ id=getattr(inst_data, "id", "") or "",
+ name=name,
+ status=getattr(inst_data, "status", 0) or 0,
+ region=region,
+ )
+ )
+
+ except Exception as error:
+ logger.error(
+ f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+
+
+class WAFInstance(HuaweiCloudBaseModel):
+ """WAF Instance model."""
+
+ id: str
+ name: str
+ status: int = 0
+ region: str = ""
diff --git a/pyproject.toml b/pyproject.toml
index 64affc8556..601ac0a02c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -117,7 +117,18 @@ dependencies = [
"alibabacloud_cs20151215==6.1.0",
"alibabacloud-rds20140815==12.0.0",
"alibabacloud-sls20201230==5.9.0",
- "scaleway==2.10.3"
+ "scaleway==2.10.3",
+ "huaweicloudsdkcore==3.1.204",
+ "huaweicloudsdkcts==3.1.204",
+ "huaweicloudsdkecs==3.1.204",
+ "huaweicloudsdkelb==3.1.204",
+ "huaweicloudsdkevs==3.1.204",
+ "huaweicloudsdkiam==3.1.204",
+ "huaweicloudsdkkms==3.1.204",
+ "huaweicloudsdkobs==3.1.204",
+ "huaweicloudsdkrds==3.1.204",
+ "huaweicloudsdkvpc==3.1.204",
+ "huaweicloudsdkwaf==3.1.204"
]
description = "Prowler is an Open Source security tool to perform AWS, GCP and Azure security best practices assessments, audits, incident response, continuous monitoring, hardening and forensics readiness. It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, FedRAMP, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, AWS Well-Architected Framework Security Pillar, AWS Foundational Technical Review (FTR), ENS (Spanish National Security Scheme) and your custom security frameworks."
license = "Apache-2.0"
@@ -125,7 +136,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
name = "prowler"
readme = "README.md"
requires-python = ">=3.10,<3.14"
-version = "5.36.0"
+version = "5.37.0"
[project.scripts]
prowler = "prowler.__main__:prowler"
@@ -244,6 +255,17 @@ constraint-dependencies = [
"httpcore==1.0.9",
"httplib2==0.31.2",
"httpx==0.28.1",
+ "huaweicloudsdkcore==3.1.204",
+ "huaweicloudsdkcts==3.1.204",
+ "huaweicloudsdkecs==3.1.204",
+ "huaweicloudsdkelb==3.1.204",
+ "huaweicloudsdkevs==3.1.204",
+ "huaweicloudsdkiam==3.1.204",
+ "huaweicloudsdkkms==3.1.204",
+ "huaweicloudsdkobs==3.1.204",
+ "huaweicloudsdkrds==3.1.204",
+ "huaweicloudsdkvpc==3.1.204",
+ "huaweicloudsdkwaf==3.1.204",
"hyperframe==6.1.0",
"iamdata==0.1.202605131",
"idna==3.15",
diff --git a/skills/prowler-compliance/SKILL.md b/skills/prowler-compliance/SKILL.md
index f119c7fa9b..747cb6ab64 100644
--- a/skills/prowler-compliance/SKILL.md
+++ b/skills/prowler-compliance/SKILL.md
@@ -2,23 +2,29 @@
name: prowler-compliance
description: >
Creates, syncs, audits and manages Prowler compliance frameworks end-to-end.
- Covers the four-layer architecture (SDK models → JSON catalogs → output
- formatters → API/UI), upstream sync workflows, cloud-auditor check-mapping
- reviews, output formatter creation, and framework-specific attribute models.
- Trigger: When working with compliance frameworks (CIS, NIST, PCI-DSS, SOC2,
- GDPR, ISO27001, ENS, MITRE ATT&CK, CCC, C5, CSA CCM, KISA ISMS-P,
- Prowler ThreatScore, FedRAMP, HIPAA), syncing with upstream catalogs,
- auditing check-to-requirement mappings, adding output formatters, or fixing
- compliance JSON bugs (duplicate IDs, empty Version, wrong Section, stale
- check refs).
+ Covers the two supported JSON schemas (universal multi-provider and legacy
+ per-provider), the SDK model tree (legacy attribute classes, universal
+ ComplianceFramework, ConfigRequirements guardrails), output formatters
+ (legacy per-framework + universal data-driven), API/UI consumption, upstream
+ sync workflows, and cloud-auditor check-mapping reviews.
+ Trigger: When working with compliance frameworks (CIS, CIS Controls, NIST,
+ PCI-DSS, SOC2, GDPR, ISO27001, ENS, MITRE ATT&CK, CCC, C5, CSA CCM, DORA,
+ KISA ISMS-P, ASD Essential Eight, DISA STIG, CISA SCuBA, SecNumCloud,
+ FedRAMP, HIPAA, NIS2, Prowler ThreatScore), creating a universal
+ multi-provider framework, adding ConfigRequirements guardrails, syncing with
+ upstream catalogs, auditing check-to-requirement mappings, adding output
+ formatters, or fixing compliance JSON bugs (duplicate IDs, empty Version,
+ wrong Section, stale check refs).
license: Apache-2.0
metadata:
author: prowler-cloud
- version: "1.2"
+ version: "2.0"
scope: [root, sdk]
auto_invoke:
- "Creating/updating compliance frameworks"
+ - "Creating a universal (multi-provider) compliance framework"
- "Mapping checks to compliance controls"
+ - "Adding ConfigRequirements guardrails to compliance requirements"
- "Syncing compliance framework with upstream catalog"
- "Auditing check-to-requirement mappings as a cloud auditor"
- "Adding a compliance output formatter (per-provider class + table dispatcher)"
@@ -29,527 +35,673 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task
## When to Use
Use this skill when:
-- Creating a new compliance framework for any provider
+
+- Creating a new compliance framework for any provider — **decide universal vs legacy first** (see below)
- **Syncing an existing framework with an upstream source of truth** (CIS, FINOS CCC, CSA CCM, NIST, ENS, etc.)
-- Adding requirements to existing frameworks
+- Adding requirements to existing frameworks, or extending a universal framework to a new provider
- Mapping checks to compliance controls
-- **Auditing existing check mappings as a cloud auditor** (user asks "are these mappings correct?", "which checks apply to this requirement?", "review the mappings")
-- **Adding a new output formatter** (new framework needs a table dispatcher + per-provider classes + CSV models)
+- **Adding `ConfigRequirements` guardrails** so configurable checks can't silently satisfy a requirement with a loosened config
+- **Auditing existing check mappings as a cloud auditor** ("are these mappings correct?", "which checks apply?", "review the mappings")
+- **Adding a new legacy output formatter** (table dispatcher + per-provider classes + CSV models)
- **Fixing JSON bugs**: duplicate IDs, empty Version, wrong Section, stale check refs, inconsistent FamilyName, padded tangential check mappings
-- **Registering a framework in the CLI table dispatcher or API export map**
- Investigating why a finding/check isn't showing under the expected compliance framework in the UI
- Understanding compliance framework structures and attributes
-## Four-Layer Architecture (Mental Model)
+The authoritative contributor doc is `docs/developer-guide/security-compliance-framework.mdx` —
+keep this skill and that doc consistent when either changes. For **reviewing**
+a compliance PR, use the sister skill
+[prowler-compliance-review](../prowler-compliance-review/SKILL.md) instead.
-Prowler compliance is a **four-layer system** hanging off one Pydantic model tree. Bugs usually happen where one layer doesn't match another, so know all four before touching anything.
+## Universal vs Legacy: The First Decision
+
+Prowler supports **two JSON schemas**. Choosing wrong means unnecessary Python
+code, so decide this before anything else. At load time both converge: legacy
+files are adapted into the universal `ComplianceFramework` model
+(`adapt_legacy_to_universal()`), so the difference is about **authoring cost
+and capabilities**, not about what the rest of Prowler sees.
+
+### Side-by-side comparison
+
+| | Universal (recommended for new frameworks) | Legacy provider-specific |
+|---|---|---|
+| File location | `prowler/compliance/.json` (top level) | `prowler/compliance//__.json` |
+| Providers | Any number, one file (`checks` dict keyed by provider) | Exactly one provider per file (one file per provider to multi-cover) |
+| Key style | lowercase (`framework`, `requirements`, `checks`) | Capitalized (`Framework`, `Requirements`, `Checks`) |
+| Attribute schema | Declared **in the JSON itself** via `attributes_metadata`, validated at load | Pydantic class per framework family in `compliance_models.py` (code change for new shapes) |
+| Attributes per requirement | One flat dict (`attributes: {...}`) | List of objects (`Attributes: [{...}]`) — only `Attributes[0]` is used downstream |
+| Table/CSV/OCSF output | Data-driven from `outputs.table_config` — **zero Python changes** | Formatter package + registrations in `compliance.py`, `__main__.py`, `export.py` |
+| Guardrails field | `config_requirements` (+ mandatory `Provider` per constraint) | `ConfigRequirements` (`Provider` omitted) |
+| Loader behavior on error | Lenient: logs + skips file (`load_compliance_framework_universal`) | Fail-fast: `sys.exit(1)` (`load_compliance_framework`) |
+| Loaded by | Only `get_bulk_compliance_frameworks_universal()` | Both loaders (`Compliance.get_bulk()` + universal, via adapter) |
+| Shipped examples | `cis_controls_8.1.json`, `csa_ccm_4.0.json`, `dora_2022_2554.json` | Everything else (~105 files across 11 providers) |
+
+### When to use which
+
+**Use universal when** (any of these):
+
+- The framework is **new to Prowler** — no existing attribute class, no
+ existing formatter. This is the default: zero Python changes needed.
+- The framework spans (or will span) **more than one provider** — DORA, CSA
+ CCM, CIS Controls. One file covers all providers; extending to a new
+ provider is a one-line `checks` edit.
+- The attribute shape is **unique to this framework** — declare it in
+ `attributes_metadata` instead of adding a Pydantic class to the Union.
+
+**Use legacy only when extending an existing legacy family**:
+
+- A new **version** of a shipped legacy framework (CIS 8.0 for AWS → new
+ `cis_8.0_aws.json`, same `CIS_Requirement_Attribute`, same `cis/` formatter).
+- An existing legacy framework for a **new provider** (ENS for m365 → new
+ `ens_rd2022_m365.json` + `ens_m365.py` transformer).
+- Consistency with the family matters more than the universal benefits — a
+ lone `cis_8.0_aws` in universal format while 20+ CIS files stay legacy
+ would fragment the family.
+
+**Never**: start a brand-new single-provider framework as legacy "because it's
+only AWS today". Universal handles single-provider fine (the `checks` dict
+just has one key) and you skip 3 output files + 3 registrations.
+
+### The same requirement in both schemas
+
+Universal (`prowler/compliance/my_framework_1.0.json`):
+
+```json
+{
+ "framework": "My-Framework",
+ "name": "My Framework 1.0",
+ "version": "1.0",
+ "description": "...",
+ "attributes_metadata": [
+ {"key": "Section", "type": "str", "required": true},
+ {"key": "Service", "type": "str"}
+ ],
+ "outputs": {"table_config": {"group_by": "Section"}},
+ "requirements": [
+ {
+ "id": "MF-1.1",
+ "name": "Root MFA",
+ "description": "Root account must have MFA enabled.",
+ "attributes": {"Section": "IAM", "Service": "iam"},
+ "checks": {
+ "aws": ["iam_root_mfa_enabled"],
+ "azure": []
+ }
+ }
+ ]
+}
+```
+
+Legacy (`prowler/compliance/aws/my_framework_1.0_aws.json` — plus a second
+file per extra provider, plus formatter + registrations):
+
+```json
+{
+ "Framework": "My-Framework",
+ "Name": "My Framework 1.0 for AWS",
+ "Version": "1.0",
+ "Provider": "AWS",
+ "Description": "...",
+ "Requirements": [
+ {
+ "Id": "MF-1.1",
+ "Name": "Root MFA",
+ "Description": "Root account must have MFA enabled.",
+ "Attributes": [
+ {"ItemId": "MF-1.1", "Section": "IAM", "Service": "iam"}
+ ],
+ "Checks": ["iam_root_mfa_enabled"]
+ }
+ ]
+}
+```
+
+Same control, but the universal file already covers Azure, validates its own
+attribute schema, and renders table/CSV/OCSF with no code. Field-by-field
+references for each schema follow below.
+
+## Architecture (Mental Model)
+
+Prowler compliance is a four-layer system. Bugs usually happen where one layer
+doesn't match another, so know all four before touching anything.
### Layer 1: SDK / Core Models — `prowler/lib/check/`
-- **`compliance_models.py`** — Pydantic **v1** model tree (`from pydantic.v1 import`). One `*_Requirement_Attribute` class per framework type + `Generic_Compliance_Requirement_Attribute` as fallback.
-- `Compliance_Requirement.Attributes: list[Union[...]]` — **`Generic_Compliance_Requirement_Attribute` MUST be LAST** in the Union or every framework-specific attribute falls through to Generic (Pydantic v1 tries union members in order).
-- **`compliance.py`** — runtime linker. `get_check_compliance()` builds the key as `f"{Framework}-{Version}"` **only if `Version` is non-empty**. An empty Version makes the key just `"{Framework}"` — this breaks downstream filters and tests that expect the versioned key.
-- `Compliance.get_bulk(provider)` walks `prowler/compliance/{provider}/` and parses every `.json` file. No central index — just directory scan.
+All in **Pydantic v1** (`from pydantic.v1 import ...`). Three model groups live
+in `compliance_models.py`:
-### Layer 2: JSON Frameworks — `prowler/compliance/{provider}/`
+**Legacy tree** — `Compliance` → `Compliance_Requirement` / `Mitre_Requirement`:
-See "Compliance Framework Location" and "Framework-Specific Attribute Structures" sections below.
+- One `*_Requirement_Attribute` class per framework family. Registered today (Union order matters):
+ `ASDEssentialEight`, `CIS`, `ENS`, `ISO27001_2013`, `AWS_Well_Architected`,
+ `KISA_ISMSP`, `Prowler_ThreatScore`, `CCC`, `C5Germany`, `CSA_CCM`, `STIG`
+ (Okta IDaaS), and `Generic_Compliance_Requirement_Attribute` as fallback.
+- **Generic MUST stay LAST** in `Compliance_Requirement.Attributes: list[Union[...]]` —
+ Pydantic v1 tries union members in order; Generic first would swallow every
+ framework-specific attribute. NIST 800-53/CSF, PCI DSS, GDPR, HIPAA, SOC2,
+ FedRAMP, SecNumCloud etc. intentionally use Generic.
+- A `root_validator` rejects empty `Framework`, `Provider` or `Name`.
+- MITRE uses the separate `Mitre_Requirement` model (`Tactics`, `SubTechniques`,
+ `Platforms`, `TechniqueURL` at requirement top level, per-provider
+ `Mitre_Requirement_Attribute_{AWS,Azure,GCP}`).
-### Layer 3: Output Formatters — `prowler/lib/outputs/compliance/{framework}/`
+**Universal tree** — `ComplianceFramework` → `UniversalComplianceRequirement`:
-**Every framework directory follows this exact convention** — do not deviate:
+- Flat `attributes: dict` per requirement, schema declared in
+ `attributes_metadata` (key, label, type, enum, required, `enum_display`,
+ `enum_order`, `output_formats`). A `root_validator` rejects missing required
+ keys, unknown keys (drift guard), enum violations, and int/float/bool type
+ mismatches. If `attributes_metadata` is omitted, **no validation runs**.
+- `checks: dict[provider, list[check_id]]` — the provider list of the framework
+ is **derived** from these keys (`get_providers()` / `supports_provider()`);
+ the top-level `provider` field is only a fallback.
+- `outputs.table_config` (group_by, split_by, scoring, labels) drives the CLI
+ table; `outputs.pdf_config` exists in the model but **is not consumed by the
+ API PDF pipeline yet** (see Layer 4).
+
+**Guardrails** — `Compliance_Requirement_ConfigConstraint`:
+
+- Fields `Check`, `ConfigKey`, `Operator` (`lte|gte|eq|in|subset|superset`),
+ `Value`, optional `Provider` (required in universal multi-provider files).
+- A `root_validator` rejects Value/Operator type mismatches at load time.
+- Evaluation is centralized in `prowler/lib/check/compliance_config_eval.py`
+ (`evaluate_config_constraints`, `apply_config_status`, `get_effective_status`,
+ `CONFIG_NOT_VALID_PREFIX = "Configuration not valid for this requirement."`),
+ shared by CSV/OCSF/table outputs **and** the API backend. A violated
+ constraint forces the requirement to FAIL and prepends the reason to
+ `status_extended`. Constraints whose `ConfigKey` is absent from
+ `audit_config` are skipped (defaults assumed compliant).
+
+**Loaders**:
+
+- `Compliance.get_bulk(provider)` — legacy: scans only
+ `prowler/compliance/{provider}/` (+ external JSONs via the
+ `prowler.compliance` entry-point group). Does NOT see top-level universal files.
+- `get_bulk_compliance_frameworks_universal(provider)` — scans **both** the
+ top-level `prowler/compliance/` and every provider subdirectory, adapting
+ legacy files via `adapt_legacy_to_universal()` (flattens `Attributes[0]` to a
+ dict, wraps `Checks` as `{provider: [...]}`, infers `attributes_metadata`).
+ Also loads external universal frameworks via the
+ `prowler.compliance.universal` entry-point group (built-ins win collisions).
+- `get_check_compliance(finding, provider_type, bulk_checks_metadata)` lives in
+ **`prowler/lib/outputs/compliance/compliance_check.py`** (not in
+ `lib/check/compliance.py`). It builds the per-finding dict keyed
+ `f"{Framework}-{Version}"` **only when Version is non-empty** — an empty
+ Version silently produces the key `"{Framework}"` and breaks downstream
+ filters and tests.
+- `prowler/lib/check/compliance.py` now contains only
+ `update_checks_metadata_with_compliance()`.
+
+### Layer 2: JSON Catalogs — `prowler/compliance/`
+
+See "Compliance Catalog Coverage" below.
+
+### Layer 3: Output Formatters — `prowler/lib/outputs/compliance/`
+
+**Universal path** (no Python needed per framework):
+
+- `universal/universal_table.py` — `get_universal_table()`, renders the CLI
+ table from `outputs.table_config` + `attributes_metadata`.
+- `universal/universal_output.py` — `UniversalComplianceOutput`, builds the CSV
+ Pydantic model **dynamically** from `attributes_metadata`.
+- `universal/ocsf_compliance.py` — `OCSFComplianceOutput`; OCSF output is
+ **always generated** for universal frameworks regardless of `--output-formats`.
+- Orchestrated by `process_universal_compliance_frameworks()` in
+ `compliance.py`, which runs **before** any legacy dispatch and removes the
+ processed frameworks from the set.
+
+**Legacy path** — per-framework directory, usually:
```text
{framework}/
├── __init__.py
-├── {framework}.py # ONLY get_{framework}_table() — NO function docstring
-├── {framework}_{provider}.py # One class per provider (e.g., CCC_AWS, CCC_Azure, CCC_GCP)
-└── models.py # One Pydantic v2 BaseModel per provider (CSV columns)
+├── {framework}.py # get_{framework}_table() summary-table function
+├── {framework}_{provider}.py # One ComplianceOutput subclass per provider
+└── models.py # One Pydantic CSV row model per provider
```
-- **`{framework}.py`** holds the **table dispatcher function** `get_{framework}_table()`. It prints the pass/fail/muted summary table. **Must NOT import `Finding` or `ComplianceOutput`** — doing so creates a circular import with `prowler/lib/outputs/compliance/compliance.py`. Only imports: `colorama`, `tabulate`, `prowler.config.config.orange_color`.
-- **`{framework}_{provider}.py`** holds a per-provider class like `CCC_AWS(ComplianceOutput)` with a `transform()` method that walks findings and emits rows. This file IS allowed to import `Finding` because it's not on the dispatcher import chain.
-- **`models.py`** holds one Pydantic v2 `BaseModel` per provider. Field names become CSV column headers (**public API** — renaming breaks downstream consumers).
-- **Never collapse per-provider files into a unified parameterized class**, even when DRY-tempting. Every framework in Prowler follows the per-provider file pattern and reviewers will reject the refactor. CSV columns differ per provider (`AccountId`/`Region` vs `SubscriptionId`/`Location` vs `ProjectId`/`Location`) — three classes is the convention.
-- **No function docstring on `get_{framework}_table()`** — no other framework has one; stay consistent.
-- Register in `prowler/lib/outputs/compliance/compliance.py` → `display_compliance_table()` with an `elif compliance_framework.startswith("{framework}_"):` branch. Import the table function at the top of the file.
+Directories today: `asd_essential_eight`, `aws_well_architected`, `c5`, `ccc`,
+`cis`, `cisa_scuba`, `ens`, `generic`, `iso27001`, `kisa_ismsp`,
+`mitre_attack`, `okta_idaas_stig`, `prowler_threatscore`, `universal`.
+Known deviations (don't "fix" them without a reason): `iso27001/` has no table
+file (falls to the generic table), `aws_well_architected/` has no per-provider
+files, `cisa_scuba/` only ships googleworkspace.
+
+- CSV writers emit `;`-delimited files with UPPERCASE headers
+ (`ComplianceOutput.batch_write_data_to_file`). Field names in `models.py`
+ are **public API** — renaming breaks downstream consumers.
+- **Circular import rule**: the table file (`{framework}.py`) must not import
+ `Finding` directly or transitively (`compliance.compliance` → table module →
+ `ComplianceOutput` → `Finding` → `get_check_compliance` → cycle). Keep table
+ files bare (`colorama`, `tabulate`, `prowler.config.config`); when a module
+ genuinely needs both, use `if TYPE_CHECKING:` or function-local imports (see
+ `universal_output.py` / `process_universal_compliance_frameworks`).
+- Legacy table functions have no docstrings; the universal ones do. Match the
+ style of the file family you're touching.
+- Dispatcher `display_compliance_table()` in `compliance.py` order:
+ universal (`table_config`) first → `cis_` → `ens_` → `mitre_attack` →
+ `kisa` → `prowler_threatscore_` → `c5_` → `ccc_` → `asd_essential_eight`
+ (substring) → `okta_idaas_stig` → else provider hook
+ (`provider.display_compliance_table()`, may raise `NotImplementedError`) →
+ `get_generic_compliance_table()`. iso27001, aws_well_architected and
+ cisa_scuba ride the fallback on purpose.
### Layer 4: API / UI
-- **API table dispatcher**: `api/src/backend/tasks/jobs/export.py` → `COMPLIANCE_CLASS_MAP` keyed by provider. Uses `startswith` predicates: `(lambda name: name.startswith("ccc_"), CCC_AWS)`. **Never use exact match** (`name == "ccc_aws"`) — it's inconsistent and breaks versioning.
-- **API lazy loader**: `api/src/backend/api/compliance.py` — `LazyComplianceTemplate` and `LazyChecksMapping` load compliance per provider on first access.
-- **UI mapper routing**: `ui/lib/compliance/compliance-mapper.ts` routes framework names → per-framework mapper.
-- **UI per-framework mapper**: `ui/lib/compliance/{framework}.tsx` flattens `Requirements` into a 3-level tree (Framework → Category → Control → Requirement) for the accordion view. Groups by `Attributes[0].FamilyName` and `Attributes[0].Section`.
-- **UI detail panel**: `ui/components/compliance/compliance-custom-details/{framework}-details.tsx`.
-- **UI types**: `ui/types/compliance.ts` — TypeScript mirrors of the attribute metadata.
+- **API lazy loaders**: `api/src/backend/api/compliance.py` —
+ `LazyComplianceTemplate` / `LazyChecksMapping` (per-provider lazy caches over
+ `get_bulk_compliance_frameworks_universal`, with Gunicorn background warm-up).
+- **API CSV export dispatch**: `COMPLIANCE_CLASS_MAP` in
+ `api/src/backend/tasks/jobs/export.py`, consumed from `tasks/tasks.py`. It is
+ a dict `provider → [(predicate, exporter_class)]` with `GenericCompliance` as
+ fallback. Predicates mix **`startswith` for multi-version families**
+ (`cis_`, `ens_`, `iso27001_`, `ccc_`, `cisa_scuba_`, ...) and **exact
+ `name == ...` for true singletons** (`mitre_attack_aws`,
+ `prowler_threatscore_*`, `asd_essential_eight_aws` — and inconsistently
+ `c5_azure`/`c5_gcp`, while aws uses `startswith("c5_")`). Rule of thumb: if
+ the framework can ever grow versions or variants, use `startswith`.
+- **API overview ingestion**: `create_compliance_requirements()` in
+ `api/src/backend/tasks/jobs/scan.py` builds per-region rows from the lazy
+ template and persists `ComplianceRequirementOverview` (COPY with bulk-create
+ fallback) plus `ComplianceOverviewSummary`.
+- **API PDF reports**: `api/src/backend/tasks/jobs/reports/` — hardcoded
+ `FRAMEWORK_REGISTRY` (own `FrameworkConfig` dataclass, NOT the SDK
+ `PDFConfig`) with one generator class per framework. Only
+ `prowler_threatscore`, `ens`, `nis2`, `csa_ccm` and `cis` have PDFs today;
+ adding one means a generator class + registry entry + wiring in `report.py`.
+- **UI mapper routing**: `ui/lib/compliance/compliance-mapper.ts` —
+ `getComplianceMappers()` keyed by the JSON's `framework` value
+ (e.g. `"CIS"`, `"CIS-Controls"`, `"DORA"`, `"Okta-IDaaS-STIG"`). Unregistered
+ frameworks **fall back to the generic mapper + `GenericCustomDetails`
+ automatically** — a dedicated mapper/detail panel is a first-class upgrade,
+ not a requirement to render.
+- **UI grouping varies per mapper**: generic/cis group by
+ `Section`/`SubSection`, iso by `Category`, ccc by `FamilyName`. All read
+ `attributes[0]` — inconsistent values within one JSON become separate tree
+ branches, so normalize before shipping.
+- **UI types**: `ui/types/compliance.ts` — one `*AttributesMetadata` interface
+ per framework, added to the `AttributesItemData` metadata union.
+- **UI icons**: `ui/components/icons/compliance/` + `IconCompliance.tsx`.
+ Registration is an ordered substring match (`COMPLIANCE_LOGOS`): put
+ framework-specific keywords **before** generic ones (`nist` before `nis2`,
+ `cisa` before `cis`; `aws` deliberately last).
### The CLI Pipeline (end-to-end)
```text
-prowler aws --compliance ccc_aws
+prowler aws --compliance cis_7.0_aws # framework key = JSON basename
↓
-Compliance.get_bulk("aws") → parses prowler/compliance/aws/*.json
+Compliance.get_bulk("aws") # legacy frameworks
+get_bulk_compliance_frameworks_universal("aws") # legacy (adapted) + universal
↓
-update_checks_metadata_with_compliance() → attaches compliance info to CheckMetadata
+update_checks_metadata_with_compliance() # attaches compliance to CheckMetadata
↓
-execute_checks() → runs checks, produces Finding objects
+execute_checks() → Finding objects
↓
-get_check_compliance(finding, "aws", bulk_checks_metadata)
- → dict "{Framework}-{Version}" → [requirement_ids]
+get_check_compliance(finding, "aws", bulk) # dict "{Framework}-{Version}" → [req_ids]
↓
-CCC_AWS(findings, compliance).transform() → per-provider class builds CSV rows
+process_universal_compliance_frameworks() # universal: CSV + OCSF, then removed from set
+per-provider elif branches in __main__.py # legacy: AWSCIS(...).batch_write_data_to_file()
↓
-batch_write_data_to_file() → writes {output_filename}_ccc_aws.csv
- ↓
-display_compliance_table() → get_ccc_table() → prints stdout summary
+display_compliance_table() # universal table first, then legacy elifs,
+ # then generic fallback
```
---
-## Compliance Framework Location
+## Compliance Catalog Coverage
-Frameworks are JSON files located in: `prowler/compliance/{provider}/{framework_name}_{provider}.json`
+Counts as of 2026-07 (109 JSON files). Regenerate before trusting them:
-**Supported Providers:**
-- `aws` - Amazon Web Services
-- `azure` - Microsoft Azure
-- `gcp` - Google Cloud Platform
-- `kubernetes` - Kubernetes
-- `github` - GitHub
-- `m365` - Microsoft 365
-- `alibabacloud` - Alibaba Cloud
-- `cloudflare` - Cloudflare
-- `oraclecloud` - Oracle Cloud
-- `oci` - Oracle Cloud Infrastructure
-- `nhn` - NHN Cloud
-- `mongodbatlas` - MongoDB Atlas
-- `iac` - Infrastructure as Code
-- `llm` - Large Language Models
+```bash
+for d in prowler/compliance/*/; do printf "%s: %s\n" "$(basename $d)" "$(ls $d*.json 2>/dev/null | wc -l)"; done
+ls prowler/compliance/*.json # universal, top-level
+```
-## Base Framework Structure
+**Universal (top-level, multi-provider)**: `cis_controls_8.1.json` (18
+providers), `csa_ccm_4.0.json` (aws/azure/gcp/alibabacloud/oraclecloud),
+`dora_2022_2554.json` (aws/azure/gcp/alibabacloud/cloudflare).
-All compliance frameworks share this base structure:
+**Legacy per-provider** (families, not exhaustive versions):
+
+| Provider | # | Framework families |
+|---|---|---|
+| aws | 45 | CIS 1.4–7.0, NIST 800-53 r4/r5, NIST 800-171 r2, NIST CSF 1.1/2.0, PCI 3.2.1/4.0, ISO 27001 2013/2022, HIPAA, GDPR, SOC2, FedRAMP low/moderate r4 + 20x KSI low, ENS RD2022, MITRE ATT&CK, C5, CCC, CISA, FFIEC, RBI, Well-Architected (security/reliability), FTR, FSBP, AWS AI Security Framework, AWS Account Security Onboarding, Audit Manager Control Tower, GxP 21 CFR 11 / EU Annex 11, KISA ISMS-P 2023 (en+ko), NIS2, ASD Essential Eight, SecNumCloud 3.2, Prowler ThreatScore |
+| azure | 19 | CIS 2.0–6.0, ISO 27001 2022, ENS RD2022, MITRE ATT&CK, PCI 4.0, HIPAA, SOC2, NIS2, RBI, C5, CCC, FedRAMP 20x KSI low, SecNumCloud 3.2, Prowler ThreatScore |
+| gcp | 17 | CIS 2.0–5.0, ISO 27001 2022, ENS RD2022, MITRE ATT&CK, PCI 4.0, HIPAA, SOC2, NIS2, RBI, C5, CCC, FedRAMP 20x KSI low, SecNumCloud 3.2, Prowler ThreatScore |
+| kubernetes | 8 | CIS 1.8–2.0.1, ISO 27001 2022, PCI 4.0, Prowler ThreatScore |
+| m365 | 5 | CIS 4.0/6.0/7.0, ISO 27001 2022, Prowler ThreatScore |
+| alibabacloud | 3 | CIS 2.0, SecNumCloud 3.2, Prowler ThreatScore |
+| oraclecloud | 3 | CIS 3.0/3.1, SecNumCloud 3.2 |
+| github | 2 | CIS 1.0/1.2.0 |
+| googleworkspace | 2 | CIS 1.3, CISA SCuBA 0.6 |
+| okta | 1 | Okta IDaaS STIG V1R2 |
+| nhn | 1 | ISO 27001 2022 |
+
+Providers with a compliance directory but no frameworks yet: cloudflare, iac,
+linode, llm, mongodbatlas, openstack, stackit. Provider keys inside universal
+`checks` dicts must match directory names under `prowler/providers/` (lowercase).
+
+---
+
+## Universal Schema Reference
+
+Full spec in `docs/developer-guide/security-compliance-framework.mdx`. Skeleton:
+
+```json
+{
+ "framework": "DORA",
+ "name": "Digital Operational Resilience Act (DORA) 2022/2554",
+ "version": "2022/2554",
+ "description": "Shown in --list-compliance and PDF reports.",
+ "icon": "dora",
+ "attributes_metadata": [
+ {"key": "Pillar", "label": "Pillar", "type": "str", "required": true,
+ "enum": ["ICT Risk Management", "..."],
+ "output_formats": {"csv": true, "ocsf": true}},
+ {"key": "Article", "type": "str", "required": true}
+ ],
+ "outputs": {
+ "table_config": {"group_by": "Pillar"},
+ "pdf_config": {"group_by_field": "Pillar", "charts": ["..."]}
+ },
+ "requirements": [
+ {
+ "id": "DORA-Art5",
+ "name": "Governance and organisation",
+ "description": "Requirement text verbatim from the source.",
+ "attributes": {"Pillar": "ICT Risk Management", "Article": "Article 5"},
+ "checks": {
+ "aws": ["iam_no_root_access_key"],
+ "azure": [],
+ "gcp": []
+ },
+ "config_requirements": [
+ {"Check": "iam_user_accesskey_unused", "Provider": "aws",
+ "ConfigKey": "max_unused_access_keys_days", "Operator": "lte", "Value": 45}
+ ]
+ }
+ ]
+}
+```
+
+### Universal fields, top level (`ComplianceFramework`)
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| `framework` | string | Yes | Short identifier (`DORA`, `CSA-CCM`, `CIS-Controls`). This is the key the UI mapper routes on. |
+| `name` | string | Yes | Human-readable full name. |
+| `version` | string | No (never leave empty) | Framework version/edition (`8.1`, `2022/2554`). |
+| `description` | string | Yes | Shown in `--list-compliance` and PDF reports. |
+| `provider` | string | No | Fallback only — the effective provider list is derived from `checks` keys across requirements (`get_providers()`). |
+| `icon` | string | No | Short icon slug. |
+| `attributes_metadata` | array | No (strongly recommended) | Declares the schema of every `attributes` key. **If omitted, no attribute validation runs at all.** |
+| `outputs` | object | No | `table_config` (CLI table) + `pdf_config` (modeled, not yet consumed by the API). |
+| `requirements` | array | Yes | List of requirement objects (below). |
+
+### Universal fields, per requirement (`UniversalComplianceRequirement`)
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| `id` | string | Yes | Unique within the framework. |
+| `description` | string | Yes | Requirement text verbatim from the source. |
+| `name` | string | No | Short title. |
+| `attributes` | dict | No (default `{}`) | Flat dict; every key must be declared in `attributes_metadata` (unknown keys are rejected at load when metadata exists). |
+| `checks` | dict | No (default `{}`) | `{provider: [check_ids]}`, lowercase keys matching `prowler/providers/` dirs. Empty list = manual requirement for that provider. |
+| `config_requirements` | array | No | Guardrails; each constraint **must** carry `Provider`. |
+| `tactics`, `sub_techniques`, `platforms`, `technique_url` | — | No | MITRE-style extras (auto-populated when adapting legacy MITRE files). |
+
+### `attributes_metadata` entry fields (`AttributeMetadata`)
+
+| Field | Type | Notes |
+|---|---|---|
+| `key` | string (required) | Attribute name as used in `requirement.attributes`. |
+| `label` | string | Human-readable label for CSV headers / PDF. |
+| `type` | string | `str` (default), `int`, `float`, `bool`, `list_str`, `list_dict`. Only int/float/bool are enforced at load; the rest are documentation. |
+| `enum` | list | Allowed values — enforced at load. Use it whenever the value set is closed. |
+| `required` | bool | Enforced at load: every requirement must carry the key non-null. |
+| `enum_display` / `enum_order` | dict / list | Per-enum-value visual metadata (label, abbreviation, color, icon) and ordering for PDF rendering. |
+| `chart_label` | string | Axis label when the attribute is used in charts. |
+| `output_formats` | object | `{"csv": bool, "ocsf": bool}`, both default `true` — toggles inclusion per output. |
+
+Key rules:
+
+- `--compliance` key = JSON basename without `.json` (`dora_2022_2554`).
+- Auto-discovered: no `__init__.py`, no formatter, no dispatcher registration.
+- `table_config.group_by`, `pdf_config.group_by_field` and every
+ `charts[].group_by` must reference a key declared in `attributes_metadata`.
+- Runtime type validation only covers `int`/`float`/`bool`; `str`/`list_str`/
+ `list_dict` are documentation-only.
+- Extending to a new provider = adding a key to `requirement.checks`. Nothing else.
+- **No automatic check-existence validation at load time** — a typo'd check id
+ silently produces a requirement with no findings. Always run the
+ check-existence cross-check (see Validation).
+- In universal files, always set `Provider` on every config constraint so a
+ guardrail authored for an AWS check never affects Azure/GCP scans of the
+ same requirement.
+
+## Legacy Schema Reference
+
+Base legacy file structure:
```json
{
"Framework": "FRAMEWORK_NAME",
"Name": "Full Framework Name with Version",
"Version": "X.X",
- "Provider": "PROVIDER",
+ "Provider": "AWS",
"Description": "Framework description...",
"Requirements": [
{
"Id": "requirement_id",
- "Description": "Requirement description",
"Name": "Optional requirement name",
- "Attributes": [...],
- "Checks": ["check_name_1", "check_name_2"]
+ "Description": "Requirement description",
+ "Attributes": [ ... ],
+ "Checks": ["check_name_1"],
+ "ConfigRequirements": [ ... ]
}
]
}
```
-## Framework-Specific Attribute Structures
+### Legacy fields, top level (`Compliance`)
-Each framework type has its own attribute model. Below are the exact structures used by Prowler:
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| `Framework` | string | Yes (non-empty, validated) | Canonical identifier (`CIS`, `ENS`, `NIST-800-53-Revision-5`). |
+| `Name` | string | Yes (non-empty, validated) | Human-readable name with version. |
+| `Version` | string | Optional in the model — **never leave it empty in practice** | Empty Version silently degrades the `get_check_compliance()` key to `"{Framework}"` (gotcha #4). Must match the version substring in the filename. |
+| `Provider` | string | Yes (non-empty, validated) | Upper-cased single provider (`AWS`, `AZURE`, `GCP`, `M365`, ...). One file = one provider. |
+| `Description` | string | Yes | Framework scope and purpose. |
+| `Requirements` | array | Yes | Requirement objects (below), or `Mitre_Requirement` objects for MITRE files. |
-### CIS (Center for Internet Security)
+### Legacy fields, per requirement (`Compliance_Requirement`)
-**Framework ID format:** `cis_{version}_{provider}` (e.g., `cis_5.0_aws`)
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| `Id` | string | Yes | Unique within the framework; follow the source numbering exactly (`1.1`, `A.5.1`, `CCC.Core.CN01.AR01`). |
+| `Description` | string | Yes | Verbatim from the source catalog. |
+| `Name` | string | No | Optional short title (NIST-style catalogs use it). |
+| `Attributes` | array of objects | Yes | Parsed against the Union of attribute classes below; only `Attributes[0]` survives the universal adaptation and drives UI grouping. |
+| `Checks` | array of strings | Yes | Check ids automating the requirement; `[]` = manual. |
+| `ConfigRequirements` | array | No | Guardrails; `Provider` is omitted (the file is single-provider). |
+
+MITRE files use `Mitre_Requirement` instead, which adds `Tactics`,
+`SubTechniques`, `Platforms`, `TechniqueURL` at the requirement top level.
+
+### Attribute shapes per framework family
+
+Unlike universal (schema in-file), a legacy requirement's `Attributes` must
+match one of the Pydantic classes registered in
+`Compliance_Requirement.Attributes` — a shape matching no class **silently
+falls through to Generic**, dropping its specific fields. The most common
+shapes (full field sets in `compliance_models.py`):
+
+### CIS — `cis_{version}_{provider}`
```json
{
- "Id": "1.1",
- "Description": "Maintain current contact details",
- "Checks": ["account_maintain_current_contact_details"],
- "Attributes": [
- {
- "Section": "1 Identity and Access Management",
- "SubSection": "Optional subsection",
- "Profile": "Level 1",
- "AssessmentStatus": "Automated",
- "Description": "Detailed attribute description",
- "RationaleStatement": "Why this control matters",
- "ImpactStatement": "Impact of implementing this control",
- "RemediationProcedure": "Steps to fix the issue",
- "AuditProcedure": "Steps to verify compliance",
- "AdditionalInformation": "Extra notes",
- "DefaultValue": "Default configuration value",
- "References": "https://docs.example.com/reference"
- }
- ]
+ "Section": "1 Identity and Access Management",
+ "SubSection": "Optional subsection",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "...", "RationaleStatement": "...", "ImpactStatement": "...",
+ "RemediationProcedure": "...", "AuditProcedure": "...",
+ "AdditionalInformation": "...", "DefaultValue": "...", "References": "https://..."
}
```
-**Profile values:** `Level 1`, `Level 2`, `E3 Level 1`, `E3 Level 2`, `E5 Level 1`, `E5 Level 2`
-**AssessmentStatus values:** `Automated`, `Manual`
+`Profile`: `Level 1|Level 2|E3 Level 1|E3 Level 2|E5 Level 1|E5 Level 2`.
+`AssessmentStatus`: `Automated|Manual`.
----
-
-### ISO 27001
-
-**Framework ID format:** `iso27001_{year}_{provider}` (e.g., `iso27001_2022_aws`)
+### ENS — `ens_rd2022_{provider}`
```json
{
- "Id": "A.5.1",
- "Description": "Policies for information security should be defined...",
- "Name": "Policies for information security",
- "Checks": ["securityhub_enabled"],
- "Attributes": [
- {
- "Category": "A.5 Organizational controls",
- "Objetive_ID": "A.5.1",
- "Objetive_Name": "Policies for information security",
- "Check_Summary": "Summary of what is being checked"
- }
- ]
+ "IdGrupoControl": "op.acc.1", "Marco": "operacional",
+ "Categoria": "control de acceso", "DescripcionControl": "...",
+ "Nivel": "alto", "Tipo": "requisito",
+ "Dimensiones": ["trazabilidad", "autenticidad"],
+ "ModoEjecucion": "automatico", "Dependencias": []
}
```
-**Note:** `Objetive_ID` and `Objetive_Name` use this exact spelling (not "Objective").
+`Nivel`: `opcional|bajo|medio|alto`. `Tipo`: `refuerzo|requisito|recomendacion|medida`.
+`Dimensiones`: `confidencialidad|integridad|trazabilidad|autenticidad|disponibilidad`.
----
-
-### ENS (Esquema Nacional de Seguridad - Spain)
-
-**Framework ID format:** `ens_rd2022_{provider}` (e.g., `ens_rd2022_aws`)
+### ISO 27001 — `iso27001_{year}_{provider}`
```json
{
- "Id": "op.acc.1.aws.iam.2",
- "Description": "Proveedor de identidad centralizado",
- "Checks": ["iam_check_saml_providers_sts"],
- "Attributes": [
- {
- "IdGrupoControl": "op.acc.1",
- "Marco": "operacional",
- "Categoria": "control de acceso",
- "DescripcionControl": "Detailed control description in Spanish",
- "Nivel": "alto",
- "Tipo": "requisito",
- "Dimensiones": ["trazabilidad", "autenticidad"],
- "ModoEjecucion": "automatico",
- "Dependencias": []
- }
- ]
+ "Category": "A.5 Organizational controls",
+ "Objetive_ID": "A.5.1", "Objetive_Name": "Policies for information security",
+ "Check_Summary": "Summary of what is being checked"
}
```
-**Nivel values:** `opcional`, `bajo`, `medio`, `alto`
-**Tipo values:** `refuerzo`, `requisito`, `recomendacion`, `medida`
-**Dimensiones values:** `confidencialidad`, `integridad`, `trazabilidad`, `autenticidad`, `disponibilidad`
+Note: `Objetive_ID` / `Objetive_Name` use this exact (mis)spelling.
----
-
-### MITRE ATT&CK
-
-**Framework ID format:** `mitre_attack_{provider}` (e.g., `mitre_attack_aws`)
-
-MITRE uses a different requirement structure:
+### MITRE ATT&CK — `mitre_attack_{provider}` (separate requirement model)
```json
{
- "Name": "Exploit Public-Facing Application",
- "Id": "T1190",
- "Tactics": ["Initial Access"],
- "SubTechniques": [],
- "Platforms": ["Containers", "IaaS", "Linux", "Network", "Windows", "macOS"],
- "Description": "Adversaries may attempt to exploit a weakness...",
+ "Name": "Exploit Public-Facing Application", "Id": "T1190",
+ "Tactics": ["Initial Access"], "SubTechniques": [],
+ "Platforms": ["IaaS"], "Description": "...",
"TechniqueURL": "https://attack.mitre.org/techniques/T1190/",
- "Checks": ["guardduty_is_enabled", "inspector2_is_enabled"],
+ "Checks": ["guardduty_is_enabled"],
"Attributes": [
- {
- "AWSService": "Amazon GuardDuty",
- "Category": "Detect",
- "Value": "Minimal",
- "Comment": "Explanation of how this service helps..."
- }
+ {"AWSService": "Amazon GuardDuty", "Category": "Detect",
+ "Value": "Minimal", "Comment": "..."}
]
}
```
-**For Azure:** Use `AzureService` instead of `AWSService`
-**For GCP:** Use `GCPService` instead of `AWSService`
-**Category values:** `Detect`, `Protect`, `Respond`
-**Value values:** `Minimal`, `Partial`, `Significant`
+`AzureService`/`GCPService` for the other providers. `Category`:
+`Detect|Protect|Respond`. `Value`: `Minimal|Partial|Significant`.
----
-
-### NIST 800-53
-
-**Framework ID format:** `nist_800_53_revision_{version}_{provider}` (e.g., `nist_800_53_revision_5_aws`)
+### CCC — `ccc_{provider}`
```json
{
- "Id": "ac_2_1",
- "Name": "AC-2(1) Automated System Account Management",
- "Description": "Support the management of system accounts...",
- "Checks": ["iam_password_policy_minimum_length_14"],
- "Attributes": [
- {
- "ItemId": "ac_2_1",
- "Section": "Access Control (AC)",
- "SubSection": "Account Management (AC-2)",
- "SubGroup": "AC-2(3) Disable Accounts",
- "Service": "iam"
- }
- ]
+ "FamilyName": "Data", "FamilyDescription": "...",
+ "Section": "CCC.Core.CN01 Encrypt Data for Transmission", "SubSection": "",
+ "SubSectionObjective": "...",
+ "Applicability": ["tlp-green", "tlp-amber", "tlp-red"],
+ "Recommendation": "...",
+ "SectionThreatMappings": [{"ReferenceId": "CCC", "Identifiers": ["CCC.Core.TH02"]}],
+ "SectionGuidelineMappings": [{"ReferenceId": "NIST-CSF", "Identifiers": ["PR.DS-02"]}]
}
```
----
+`Applicability` holds TLP tags (`tlp-clear|tlp-green|tlp-amber|tlp-red`).
-### Generic Compliance (Fallback)
-
-For frameworks without specific attribute models:
+### ASD Essential Eight — `asd_essential_eight_aws`
```json
{
- "Id": "requirement_id",
- "Description": "Requirement description",
- "Name": "Optional name",
- "Checks": ["check_name"],
- "Attributes": [
- {
- "ItemId": "item_id",
- "Section": "Section name",
- "SubSection": "Subsection name",
- "SubGroup": "Subgroup name",
- "Service": "service_name",
- "Type": "type"
- }
- ]
+ "Section": "Patch applications", "MaturityLevel": "ML1",
+ "AssessmentStatus": "Automated", "CloudApplicability": "partial",
+ "MitigatedThreats": ["..."], "Description": "...",
+ "RationaleStatement": "...", "ImpactStatement": "...",
+ "RemediationProcedure": "...", "AuditProcedure": "...",
+ "AdditionalInformation": "...", "References": "..."
}
```
----
+`MaturityLevel`: `ML1|ML2|ML3`. `CloudApplicability`: `full|partial|limited|non-applicable`.
-### AWS Well-Architected Framework
-
-**Framework ID format:** `aws_well_architected_framework_{pillar}_pillar_aws`
+### DISA STIG — `okta_idaas_stig_v1r2_okta`
```json
{
- "Id": "SEC01-BP01",
- "Description": "Establish common guardrails...",
- "Name": "Establish common guardrails",
- "Checks": ["account_part_of_organizations"],
- "Attributes": [
- {
- "Name": "Establish common guardrails",
- "WellArchitectedQuestionId": "securely-operate",
- "WellArchitectedPracticeId": "sec_securely_operate_multi_accounts",
- "Section": "Security",
- "SubSection": "Security foundations",
- "LevelOfRisk": "High",
- "AssessmentMethod": "Automated",
- "Description": "Detailed description",
- "ImplementationGuidanceUrl": "https://docs.aws.amazon.com/..."
- }
- ]
+ "Section": "...", "Severity": "high", "RuleID": "...", "StigID": "...",
+ "CCI": ["CCI-000015"], "CheckText": "...", "FixText": "..."
}
```
----
+`Severity`: `high|medium|low` (maps to CAT I/II/III).
-### KISA ISMS-P (Korea)
+### Other registered shapes
-**Framework ID format:** `kisa_isms_p_{year}_{provider}` (e.g., `kisa_isms_p_2023_aws`)
+- **AWS Well-Architected** (`aws_well_architected_framework_{pillar}_pillar_aws`):
+ `Name`, `WellArchitectedQuestionId`, `WellArchitectedPracticeId`, `Section`,
+ `SubSection`, `LevelOfRisk`, `AssessmentMethod`, `Description`,
+ `ImplementationGuidanceUrl`.
+- **KISA ISMS-P** (`kisa_isms_p_2023_{provider}`): `Domain`, `Subdomain`,
+ `Section`, `AuditChecklist`, `RelatedRegulations`, `AuditEvidence`,
+ `NonComplianceCases`.
+- **C5** (`c5_{provider}`): `Section`, `SubSection`, `Type`, `AboutCriteria`,
+ `ComplementaryCriteria`.
+- **CSA CCM** (legacy shape; the shipped CSA CCM 4.0 is universal): `Section`,
+ `CCMLite`, `IaaS`, `PaaS`, `SaaS`, `ScopeApplicability`.
+- **Prowler ThreatScore** (`prowler_threatscore_{provider}`): `Title`,
+ `Section`, `SubSection`, `AttributeDescription`, `AdditionalInformation`,
+ `LevelOfRisk` (1–5), `Weight` (1/8/10/100/1000). Pillars: 1 IAM, 2 Attack
+ Surface, 3 Logging and Monitoring, 4 Encryption. Available for aws,
+ azure, gcp, kubernetes, m365, alibabacloud.
+- **Generic (fallback)**: `ItemId`, `Section`, `SubSection`, `SubGroup`,
+ `Service`, `Type`, `Comment` — all optional. Used by NIST, PCI, GDPR,
+ HIPAA, SOC2, FedRAMP, CISA, FFIEC, RBI, NIS2, GxP, SecNumCloud, etc.
+
+## Config Guardrails (`ConfigRequirements`)
+
+Requirements backed by [configurable checks](https://docs.prowler.com/developer-guide/configurable-checks)
+can be silently "satisfied" by a loosened `audit_config` (e.g. CIS demands
+45-day unused credentials but the scan ran with `max_unused_access_keys_days: 120`).
+Guardrails force such requirements to FAIL:
```json
-{
- "Id": "1.1.1",
- "Description": "Requirement description",
- "Name": "Requirement name",
- "Checks": ["check_name"],
- "Attributes": [
- {
- "Domain": "1. Management System",
- "Subdomain": "1.1 Management System Establishment",
- "Section": "1.1.1 Section Name",
- "AuditChecklist": ["Checklist item 1", "Checklist item 2"],
- "RelatedRegulations": ["Regulation 1"],
- "AuditEvidence": ["Evidence type 1"],
- "NonComplianceCases": ["Non-compliance example"]
- }
- ]
-}
+"ConfigRequirements": [
+ {"Check": "iam_user_accesskey_unused",
+ "ConfigKey": "max_unused_access_keys_days", "Operator": "lte", "Value": 45}
+]
```
----
-
-### C5 (Germany Cloud Computing Compliance Criteria Catalogue)
-
-**Framework ID format:** `c5_{provider}` (e.g., `c5_aws`)
-
-```json
-{
- "Id": "BCM-01",
- "Description": "Requirement description",
- "Name": "Requirement name",
- "Checks": ["check_name"],
- "Attributes": [
- {
- "Section": "BCM Business Continuity Management",
- "SubSection": "BCM-01",
- "Type": "Basic Criteria",
- "AboutCriteria": "Description of criteria",
- "ComplementaryCriteria": "Additional criteria"
- }
- ]
-}
-```
+- Operators: `lte`/`gte` (numeric thresholds), `eq` (toggles/exact — use JSON
+ booleans, not 0/1), `in` (scalar in allowed set), `subset` (allowlists —
+ widening breaks it), `superset` (denylists — removing an entry breaks it).
+- `Value` must be the **strictest** setting the control text tolerates.
+- `ConfigKey` must be spelled exactly as the check reads it; unknown keys are
+ silently skipped (defaults assumed OK).
+- Guardrails only tighten (PASS→FAIL), never relax.
+- Universal files: lowercase `config_requirements` + mandatory `Provider` per
+ constraint.
+- Tests: `tests/lib/check/compliance_config_eval_test.py`,
+ `compliance_config_constraint_model_test.py`,
+ `compliance_config_requirements_data_test.py`, plus per-output tests under
+ `tests/lib/outputs/compliance/`.
---
-### CCC (Cloud Computing Compliance)
-
-**Framework ID format:** `ccc_{provider}` (e.g., `ccc_aws`)
-
-```json
-{
- "Id": "CCC.C01",
- "Description": "Requirement description",
- "Name": "Requirement name",
- "Checks": ["check_name"],
- "Attributes": [
- {
- "FamilyName": "Cryptography & Key Management",
- "FamilyDescription": "Family description",
- "Section": "CCC.C01",
- "SubSection": "Key Management",
- "SubSectionObjective": "Objective description",
- "Applicability": ["IaaS", "PaaS", "SaaS"],
- "Recommendation": "Recommended action",
- "SectionThreatMappings": [{"threat": "T1190"}],
- "SectionGuidelineMappings": [{"guideline": "NIST"}]
- }
- ]
-}
-```
-
----
-
-### Prowler ThreatScore
-
-**Framework ID format:** `prowler_threatscore_{provider}` (e.g., `prowler_threatscore_aws`)
-
-Prowler ThreatScore is a custom security scoring framework developed by Prowler that evaluates AWS account security based on **four main pillars**:
-
-| Pillar | Description |
-|--------|-------------|
-| **1. IAM** | Identity and Access Management controls (authentication, authorization, credentials) |
-| **2. Attack Surface** | Network exposure, public resources, security group rules |
-| **3. Logging and Monitoring** | Audit logging, threat detection, forensic readiness |
-| **4. Encryption** | Data at rest and in transit encryption |
-
-**Scoring System:**
-- **LevelOfRisk** (1-5): Severity of the security issue
- - `5` = Critical (e.g., root MFA, public S3 buckets)
- - `4` = High (e.g., user MFA, public EC2)
- - `3` = Medium (e.g., password policies, encryption)
- - `2` = Low
- - `1` = Informational
-- **Weight**: Impact multiplier for score calculation
- - `1000` = Critical controls (root security, public exposure)
- - `100` = High-impact controls (user authentication, monitoring)
- - `10` = Standard controls (password policies, encryption)
- - `1` = Low-impact controls (best practices)
-
-```json
-{
- "Id": "1.1.1",
- "Description": "Ensure MFA is enabled for the 'root' user account",
- "Checks": ["iam_root_mfa_enabled"],
- "Attributes": [
- {
- "Title": "MFA enabled for 'root'",
- "Section": "1. IAM",
- "SubSection": "1.1 Authentication",
- "AttributeDescription": "The root user account holds the highest level of privileges within an AWS account. Enabling MFA enhances security by adding an additional layer of protection.",
- "AdditionalInformation": "Enabling MFA enhances console security by requiring the authenticating user to both possess a time-sensitive key-generating device and have knowledge of their credentials.",
- "LevelOfRisk": 5,
- "Weight": 1000
- }
- ]
-}
-```
-
-**Available for providers:** AWS, Kubernetes, M365
-
----
-
-## Available Compliance Frameworks
-
-### AWS (41 frameworks)
-
-| Framework | File Name |
-|-----------|-----------|
-| CIS 1.4, 1.5, 2.0, 3.0, 4.0, 5.0 | `cis_{version}_aws.json` |
-| ISO 27001:2013, 2022 | `iso27001_{year}_aws.json` |
-| NIST 800-53 Rev 4, 5 | `nist_800_53_revision_{version}_aws.json` |
-| NIST 800-171 Rev 2 | `nist_800_171_revision_2_aws.json` |
-| NIST CSF 1.1, 2.0 | `nist_csf_{version}_aws.json` |
-| PCI DSS 3.2.1, 4.0 | `pci_{version}_aws.json` |
-| HIPAA | `hipaa_aws.json` |
-| GDPR | `gdpr_aws.json` |
-| SOC 2 | `soc2_aws.json` |
-| FedRAMP Low/Moderate | `fedramp_{level}_revision_4_aws.json` |
-| ENS RD2022 | `ens_rd2022_aws.json` |
-| MITRE ATT&CK | `mitre_attack_aws.json` |
-| C5 Germany | `c5_aws.json` |
-| CISA | `cisa_aws.json` |
-| FFIEC | `ffiec_aws.json` |
-| RBI Cyber Security | `rbi_cyber_security_framework_aws.json` |
-| AWS Well-Architected | `aws_well_architected_framework_{pillar}_pillar_aws.json` |
-| AWS FTR | `aws_foundational_technical_review_aws.json` |
-| GxP 21 CFR Part 11, EU Annex 11 | `gxp_{standard}_aws.json` |
-| KISA ISMS-P 2023 | `kisa_isms_p_2023_aws.json` |
-| NIS2 | `nis2_aws.json` |
-
-### Azure (15+ frameworks)
-
-| Framework | File Name |
-|-----------|-----------|
-| CIS 2.0, 2.1, 3.0, 4.0 | `cis_{version}_azure.json` |
-| ISO 27001:2022 | `iso27001_2022_azure.json` |
-| ENS RD2022 | `ens_rd2022_azure.json` |
-| MITRE ATT&CK | `mitre_attack_azure.json` |
-| PCI DSS 4.0 | `pci_4.0_azure.json` |
-| NIST CSF 2.0 | `nist_csf_2.0_azure.json` |
-
-### GCP (15+ frameworks)
-
-| Framework | File Name |
-|-----------|-----------|
-| CIS 2.0, 3.0, 4.0 | `cis_{version}_gcp.json` |
-| ISO 27001:2022 | `iso27001_2022_gcp.json` |
-| HIPAA | `hipaa_gcp.json` |
-| MITRE ATT&CK | `mitre_attack_gcp.json` |
-| PCI DSS 4.0 | `pci_4.0_gcp.json` |
-| NIST CSF 2.0 | `nist_csf_2.0_gcp.json` |
-
-### Kubernetes (6 frameworks)
-
-| Framework | File Name |
-|-----------|-----------|
-| CIS 1.8, 1.10, 1.11 | `cis_{version}_kubernetes.json` |
-| ISO 27001:2022 | `iso27001_2022_kubernetes.json` |
-| PCI DSS 4.0 | `pci_4.0_kubernetes.json` |
-
-### Other Providers
-- **GitHub:** `cis_1.0_github.json`
-- **M365:** `cis_4.0_m365.json`, `iso27001_2022_m365.json`
-- **NHN:** `iso27001_2022_nhn.json`
-
## Workflow A: Sync a Framework With an Upstream Catalog
-Use when the framework is maintained upstream (CIS Benchmarks, FINOS CCC, CSA CCM, NIST, ENS, etc.) and Prowler needs to catch up.
+Use when the framework is maintained upstream (CIS Benchmarks, FINOS CCC, CSA
+CCM, NIST, ENS, etc.) and Prowler needs to catch up.
### Step 1 — Cache the upstream source
-Download every upstream file to a local cache so subsequent iterations don't hit the network. For FINOS CCC:
+Download every upstream file to a local cache so iterations don't hit the
+network. For FINOS CCC:
```bash
mkdir -p /tmp/ccc_upstream
@@ -563,492 +715,480 @@ done
### Step 2 — Run the generic sync runner against a framework config
-The sync tooling is split into three layers so adding a new framework only takes a YAML config (and optionally a new parser module for an unfamiliar upstream format):
+The sync tooling is three layers, so adding a framework only takes a YAML
+config (plus a parser module for an unfamiliar upstream format):
```text
skills/prowler-compliance/assets/
├── sync_framework.py # generic runner — works for any framework
-├── configs/
-│ └── ccc.yaml # per-framework config (canonical example)
-└── parsers/
- ├── __init__.py
- └── finos_ccc.py # parser module for FINOS CCC YAML
+├── configs/ccc.yaml # per-framework config (canonical example)
+└── parsers/finos_ccc.py # parser module for FINOS CCC YAML
```
-**For frameworks that already have a config + parser** (today: FINOS CCC), run:
-
```bash
python skills/prowler-compliance/assets/sync_framework.py \
skills/prowler-compliance/assets/configs/ccc.yaml
```
-The runner loads the config, validates it, dynamically imports the parser declared in `parser.module`, calls `parser.parse_upstream(config) -> list[dict]`, then applies generic post-processing (id uniqueness safety net, `FamilyName` normalization, legacy check-mapping preservation) and writes the provider JSONs.
+The runner loads the config, dynamically imports `parser.module`, calls
+`parse_upstream(config) -> list[dict]`, then applies generic post-processing
+(id-uniqueness safety net, `FamilyName` normalization, legacy check-mapping
+preservation with config-driven fallback keys) and writes the provider JSONs
+with Pydantic post-validation.
**To add a new framework sync**:
-1. **Write a config file** at `skills/prowler-compliance/assets/configs/{framework}.yaml`. See `configs/ccc.yaml` as the canonical example. Required top-level sections:
- - `framework` — `name`, `display_name`, `version` (**never empty** — empty Version silently breaks `get_check_compliance()` key construction, so the runner refuses to start), `description_template` (accepts `{provider_display}`, `{provider_key}`, `{framework_name}`, `{framework_display}`, `{version}` placeholders).
- - `providers` — list of `{key, display}` pairs, one per Prowler provider the framework targets.
- - `output.path_template` — supports `{provider}`, `{framework}`, `{version}` placeholders. Examples: `"prowler/compliance/{provider}/ccc_{provider}.json"` for unversioned file names, `"prowler/compliance/{provider}/cis_{version}_{provider}.json"` for versioned ones.
- - `upstream.dir` — local cache directory (populate via Step 1).
- - `parser.module` — name of the module under `parsers/` to load (without `.py`). Everything else under `parser.` is opaque to the runner and passed to the parser as config.
- - `post_processing.check_preservation.primary_key` — top-level field name for the primary legacy-mapping lookup (almost always `Id`).
- - `post_processing.check_preservation.fallback_keys` — **config-driven fallback keys** for preserving check mappings when ids change. Each entry is a list of `Attributes[0]` field names composed into a tuple. Examples:
- - CCC: `- [Section, Applicability]` (because `Applicability` is a CCC-only attribute, verified in `compliance_models.py:213`).
- - CIS would use `- [Section, Profile]`.
- - NIST would use `- [ItemId]`.
- - List-valued fields (like `Applicability`) are automatically frozen to `frozenset` so the tuple is hashable.
- - `post_processing.family_name_normalization` (optional) — map of raw → canonical `FamilyName` values. The UI groups by `Attributes[0].FamilyName` exactly, so inconsistent upstream variants otherwise become separate tree branches.
+1. Write `assets/configs/{framework}.yaml` (see `ccc.yaml`). Required sections:
+ - `framework` — `name`, `display_name`, `version` (**never empty** — the
+ runner refuses to start, because empty Version breaks the
+ `get_check_compliance()` key), `description_template`.
+ - `providers` — list of `{key, display}` pairs.
+ - `output.path_template` — e.g.
+ `"prowler/compliance/{provider}/cis_{version}_{provider}.json"`.
+ - `upstream.dir` — local cache (Step 1).
+ - `parser.module` — module under `parsers/`; the rest of `parser.` is
+ passed through opaque.
+ - `post_processing.check_preservation.primary_key` (almost always `Id`) and
+ `fallback_keys` — lists of `Attributes[0]` field names composed into
+ tuples for recovering mappings when ids change. CCC:
+ `- [Section, Applicability]`; CIS: `- [Section, Profile]`; NIST:
+ `- [ItemId]`. List-valued fields are frozen to `frozenset` automatically.
+ - `post_processing.family_name_normalization` (optional) — raw → canonical
+ map; the UI groups by the exact attribute value, so upstream variants
+ otherwise become separate tree branches.
+2. Reuse an existing parser or write `parsers/{name}.py` implementing
+ `parse_upstream(config) -> list[dict]` returning Prowler-format
+ requirements with **guaranteed-unique ids**. The runner raises on
+ duplicates — it never silently renumbers, because mutating a canonical
+ upstream id (CIS `1.1.1`, NIST `AC-2(1)`) would be catastrophic. The parser
+ owns all upstream quirks: foreign-prefix rewriting, genuine collision
+ renumbering, multi-shape handling.
-2. **Reuse an existing parser** if the upstream format matches one (currently only `finos_ccc` exists). Otherwise, **write a new parser** at `parsers/{name}.py` implementing:
+**Gotchas the runner already handles** (from the FINOS CCC v2025.10 sync):
- ```python
- def parse_upstream(config: dict) -> list[dict]:
- """Return Prowler-format requirements {Id, Description, Attributes: [...], Checks: []}.
-
- Ids MUST be unique in the returned list. The runner raises ValueError
- on duplicates — it does NOT silently renumber, because mutating a
- canonical upstream id (e.g. CIS '1.1.1' or NIST 'AC-2(1)') would be
- catastrophic. The parser owns all upstream-format quirks: foreign-prefix
- rewriting, genuine collision renumbering, shape handling.
- """
- ```
-
- The parser reads its own settings from `config['upstream']` and `config['parser']`. It does NOT load existing Prowler JSONs (the runner does that for check preservation) and does NOT write output (the runner does that too).
-
-**Gotchas the runner already handles for you** (learned from the FINOS CCC v2025.10 sync — they're documented here so you don't re-discover them):
-
-- **Multiple upstream YAML shapes**. Most FINOS CCC catalogs use `control-families: [...]`, but `storage/object` uses a top-level `controls: [...]` with a `family: "CCC.X.Y"` reference id and no human-readable family name. A parser that only handles shape 1 silently drops the shape-2 catalog — this exact bug dropped ObjStor from Prowler for a full iteration. `parsers/finos_ccc.py` handles both shapes; if you write a new parser for a similar format, test with at least one file of each shape.
-- **Whitespace collapse**. Upstream YAML multi-line block scalars (`|`) preserve newlines. Prowler stores descriptions single-line. Collapse with `" ".join(value.split())` before emitting (see `parsers/finos_ccc.py::clean()`).
-- **Foreign-prefix AR id rewriting**. Upstream sometimes aliases requirements across catalogs by keeping the original prefix (e.g., `CCC.AuditLog.CN08.AR01` appears nested under `CCC.Logging.CN03`). Rewrite the foreign id to fit its parent control: `CCC.Logging.CN03.AR01`. This logic is parser-specific because the id structure varies per framework (CCC uses 3-dot depth; CIS uses numeric dots; NIST uses `AC-2(1)`).
-- **Genuine upstream collision renumbering**. Sometimes upstream has a real typo where two different requirements share the same id (e.g., `CCC.Core.CN14.AR02` defined twice for 30-day and 14-day backup variants). Renumber the second copy to the next free AR number (`.AR03`). The parser handles this; the runner asserts the final list has unique ids as a safety net.
-- **Existing check mapping preservation**. The runner uses the `primary_key` + `fallback_keys` declared in config to look up the old `Checks` list for each requirement. For CCC this means primary index by `Id` plus fallback index by `(Section, frozenset(Applicability))` — the fallback recovers mappings for requirements whose ids were rewritten or renumbered by the parser.
-- **FamilyName normalization**. Configured via `post_processing.family_name_normalization` — no code changes needed to collapse upstream variants like `"Logging & Monitoring"` → `"Logging and Monitoring"`.
-- **Populate `Version`**. The runner refuses to start on empty `framework.version` — fail-fast replaces the silent bug where `get_check_compliance()` would build the key as just `"{Framework}"`.
+- **Multiple upstream YAML shapes.** Most FINOS CCC catalogs use
+ `control-families: [...]` but `storage/object` uses top-level
+ `controls: [...]`. A single-shape parser silently drops entire catalogs —
+ this exact bug dropped ObjStor for a full iteration. Test with one file of
+ each shape.
+- **Whitespace collapse.** Upstream `|` block scalars keep newlines; Prowler
+ stores single-line. Collapse with `" ".join(value.split())`.
+- **Foreign-prefix id rewriting.** Upstream aliases requirements across
+ catalogs keeping the original prefix (`CCC.AuditLog.CN08.AR01` nested under
+ `CCC.Logging.CN03`) — rewrite to fit the parent (`CCC.Logging.CN03.AR01`).
+- **Genuine upstream collisions.** Two different requirements sharing one id
+ (upstream typo): renumber the second to the next free number; check-mapping
+ preservation recovers by the fallback keys.
+- **Populate `Version`** — fail-fast beats the silent broken-key bug.
### Step 3 — Validate before committing
-```python
-from prowler.lib.check.compliance_models import Compliance
-for prov in ['aws', 'azure', 'gcp']:
- c = Compliance.parse_file(f"prowler/compliance/{prov}/ccc_{prov}.json")
- print(f"{prov}: {len(c.Requirements)} reqs, version={c.Version}")
-```
+Run the full Validation section below (universal loader + check existence +
+CLI smoke + pytest).
-Any `ValidationError` means the Attribute fields don't match the `*_Requirement_Attribute` model. Either fix the JSON or extend the model in `compliance_models.py` (remember: Generic stays last).
+### Step 4 — Add an attribute model if needed
-### Step 4 — Verify every check id exists
-
-```python
-import json
-from pathlib import Path
-for prov in ['aws', 'azure', 'gcp']:
- existing = {p.stem.replace('.metadata','')
- for p in Path(f'prowler/providers/{prov}/services').rglob('*.metadata.json')}
- with open(f'prowler/compliance/{prov}/ccc_{prov}.json') as f:
- data = json.load(f)
- refs = {c for r in data['Requirements'] for c in r['Checks']}
- missing = refs - existing
- assert not missing, f"{prov} missing: {missing}"
-```
-
-A stale check id silently becomes dead weight — no finding will ever map to it. This pre-validation **must run on every write**; bake it into the generator script.
-
-### Step 5 — Add an attribute model if needed
-
-Only if the framework has fields beyond `Generic_Compliance_Requirement_Attribute`. Add the class to `prowler/lib/check/compliance_models.py` and register it in `Compliance_Requirement.Attributes: list[Union[...]]`. **Generic stays last.**
+Only if the framework has fields beyond
+`Generic_Compliance_Requirement_Attribute` and must stay legacy. Add the class
+to `compliance_models.py` and register it in the
+`Compliance_Requirement.Attributes` Union **before Generic** (Generic stays
+last). For new frameworks, prefer universal `attributes_metadata` instead.
---
## Workflow B: Audit Check Mappings as a Cloud Auditor
-Use when the user asks to review existing mappings ("are these correct?", "verify that the checks apply", "audit the CCC mappings"). This is the highest-value compliance task — it surfaces padded mappings with zero actual coverage and missing mappings for legitimate coverage.
+Use when the user asks to review existing mappings. This is the
+highest-value compliance task — it surfaces padded mappings with zero actual
+coverage and missing mappings for legitimate coverage.
### The golden rule
-> A Prowler check's title/risk MUST **literally describe what the requirement text says**. "Related" is not enough. If no check actually addresses the requirement, leave `Checks: []` (MANUAL) — **honest MANUAL is worth more than padded coverage**.
+> A Prowler check's title/risk MUST **literally describe what the requirement
+> text says**. "Related" is not enough. If no check actually addresses the
+> requirement, leave the checks list empty (MANUAL) — **honest MANUAL is worth
+> more than padded coverage**.
### Audit process
-**Step 1 — Build a per-provider check inventory** (cache in `/tmp/`):
+1. **Build a per-provider check inventory** — `assets/build_inventory.py`
+ (writes `/tmp/checks_{provider}.json` for every provider discovered under
+ `prowler/providers/`).
+2. **Query it** — `assets/query_checks.py` (run from the repository root):
-```python
-import json
-from pathlib import Path
-for provider in ['aws', 'azure', 'gcp']:
- inv = {}
- for meta in Path(f'prowler/providers/{provider}/services').rglob('*.metadata.json'):
- with open(meta) as f:
- d = json.load(f)
- cid = d.get('CheckID') or meta.stem.replace('.metadata','')
- inv[cid] = {
- 'service': d.get('ServiceName', ''),
- 'title': d.get('CheckTitle', ''),
- 'risk': d.get('Risk', ''),
- 'description': d.get('Description', ''),
- }
- with open(f'/tmp/checks_{provider}.json', 'w') as f:
- json.dump(inv, f, indent=2)
-```
+ ```bash
+ python skills/prowler-compliance/assets/query_checks.py aws encryption transit # keyword AND-search
+ python skills/prowler-compliance/assets/query_checks.py aws --service iam # all iam checks
+ python skills/prowler-compliance/assets/query_checks.py aws --id kms_cmk_rotation_enabled
+ ```
-**Step 2 — Keyword/service query helper** — see [assets/query_checks.py](assets/query_checks.py):
+3. **Dump a framework section with current mappings** — `assets/dump_section.py`:
-```bash
-python assets/query_checks.py aws encryption transit # keyword AND-search
-python assets/query_checks.py aws --service iam # all iam checks
-python assets/query_checks.py aws --id kms_cmk_rotation_enabled # full metadata
-```
+ ```bash
+ python skills/prowler-compliance/assets/dump_section.py ccc "CCC.Core."
+ python skills/prowler-compliance/assets/dump_section.py cis_5.0_aws "1."
+ ```
-**Step 3 — Dump a framework section with current mappings** — see [assets/dump_section.py](assets/dump_section.py):
+4. **Encode explicit REPLACE decisions** — `assets/audit_framework_template.py`:
-```bash
-python assets/dump_section.py ccc "CCC.Core." # all Core ARs across 3 providers
-python assets/dump_section.py ccc "CCC.AuditLog." # all AuditLog ARs
-```
+ ```python
+ DECISIONS = {}
+ DECISIONS["CCC.Core.CN01.AR01"] = {
+ "aws": ["cloudfront_distributions_https_enabled", ...],
+ "azure": ["storage_secure_transfer_required_is_enabled", ...],
+ "gcp": ["cloudsql_instance_ssl_connections"],
+ # Missing provider key = leave the legacy mapping untouched
+ }
+ # Empty list = EXPLICITLY MANUAL (overwrites legacy)
+ DECISIONS["CCC.Core.CN01.AR07"] = {"aws": [], "azure": [], "gcp": []}
+ ```
-**Step 4 — Encode explicit REPLACE decisions** — see [assets/audit_framework_template.py](assets/audit_framework_template.py). Structure:
+ **REPLACE, not PATCH.** Full lists make the audit reproducible and surface
+ hidden assumptions in the legacy data.
+5. **Pre-validate** every check id against the inventory; the script MUST
+ abort with stderr listing typos (real audits caught
+ `storage_secure_transfer_required_enabled` →
+ `storage_secure_transfer_required_is_enabled`,
+ `sqlserver_minimum_tls_version_12` →
+ `sqlserver_recommended_minimal_tls_version`, and several checks that
+ simply don't exist).
+6. **Apply + validate + test**:
-```python
-DECISIONS = {}
+ ```bash
+ python /path/to/audit_script.py
+ uv run pytest -n auto tests/lib/outputs/compliance/ tests/lib/check/ -q
+ ```
-DECISIONS["CCC.Core.CN01.AR01"] = {
- "aws": [
- "cloudfront_distributions_https_enabled",
- "cloudfront_distributions_origin_traffic_encrypted",
- # ...
- ],
- "azure": [
- "storage_secure_transfer_required_is_enabled",
- "app_minimum_tls_version_12",
- # ...
- ],
- "gcp": [
- "cloudsql_instance_ssl_connections",
- ],
- # Missing provider key = leave the legacy mapping untouched
-}
-
-# Empty list = EXPLICITLY MANUAL (overwrites legacy)
-DECISIONS["CCC.Core.CN01.AR07"] = {
- "aws": [], # Prowler has no IANA port/protocol check
- "azure": [],
- "gcp": [],
-}
-```
-
-**REPLACE, not PATCH.** Encoding every mapping as a full list (not add/remove delta) makes the audit reproducible and surfaces hidden assumptions from the legacy data.
-
-**Step 5 — Pre-validation**. The audit script MUST validate every check id against the inventory and **abort with stderr listing typos**. Common typos caught during a real audit:
-
-- `fsx_file_system_encryption_at_rest_using_kms` (doesn't exist)
-- `cosmosdb_account_encryption_at_rest_with_cmk` (doesn't exist)
-- `sqlserver_geo_replication` (doesn't exist)
-- `redshift_cluster_audit_logging` (should be `redshift_cluster_encrypted_at_rest`)
-- `postgresql_flexible_server_require_secure_transport` (should be `postgresql_flexible_server_enforce_ssl_enabled`)
-- `storage_secure_transfer_required_enabled` (should be `storage_secure_transfer_required_is_enabled`)
-- `sqlserver_minimum_tls_version_12` (should be `sqlserver_recommended_minimal_tls_version`)
-
-**Step 6 — Apply + validate + test**:
-
-```bash
-python /path/to/audit_script.py # applies decisions, pre-validates
-python -m pytest tests/lib/outputs/compliance/ tests/lib/check/ -q
-```
-
-### Audit Reference Table: Requirement Text → Prowler Checks
-
-Use this table to map CCC-style / NIST-style / ISO-style requirements to the checks that actually verify them. Built from a real audit of 172 CCC ARs × 3 providers.
-
-| Requirement text | AWS checks | Azure checks | GCP checks |
-|---|---|---|---|
-| **TLS in transit enforced** | `cloudfront_distributions_https_enabled`, `s3_bucket_secure_transport_policy`, `elbv2_ssl_listeners`, `elbv2_insecure_ssl_ciphers`, `elb_ssl_listeners`, `elb_insecure_ssl_ciphers`, `opensearch_service_domains_https_communications_enforced`, `rds_instance_transport_encrypted`, `redshift_cluster_in_transit_encryption_enabled`, `elasticache_redis_cluster_in_transit_encryption_enabled`, `dynamodb_accelerator_cluster_in_transit_encryption_enabled`, `dms_endpoint_ssl_enabled`, `kafka_cluster_in_transit_encryption_enabled`, `transfer_server_in_transit_encryption_enabled`, `glue_database_connections_ssl_enabled`, `sns_subscription_not_using_http_endpoints` | `storage_secure_transfer_required_is_enabled`, `storage_ensure_minimum_tls_version_12`, `postgresql_flexible_server_enforce_ssl_enabled`, `mysql_flexible_server_ssl_connection_enabled`, `mysql_flexible_server_minimum_tls_version_12`, `sqlserver_recommended_minimal_tls_version`, `app_minimum_tls_version_12`, `app_ensure_http_is_redirected_to_https`, `app_ftp_deployment_disabled` | `cloudsql_instance_ssl_connections` (almost only option) |
-| **TLS 1.3 specifically** | Partial: `cloudfront_distributions_using_deprecated_ssl_protocols`, `elb*_insecure_ssl_ciphers`, `*_minimum_tls_version_12` | Partial: `*_minimum_tls_version_12` checks | None — accept as MANUAL |
-| **SSH / port 22 hardening** | `ec2_instance_port_ssh_exposed_to_internet`, `ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22`, `ec2_networkacl_allow_ingress_tcp_port_22` | `network_ssh_internet_access_restricted`, `vm_linux_enforce_ssh_authentication` | `compute_firewall_ssh_access_from_the_internet_allowed`, `compute_instance_block_project_wide_ssh_keys_disabled`, `compute_project_os_login_enabled`, `compute_project_os_login_2fa_enabled` |
-| **mTLS (mutual TLS)** | `kafka_cluster_mutual_tls_authentication_enabled`, `apigateway_restapi_client_certificate_enabled` | `app_client_certificates_on` | None — MANUAL |
-| **Data at rest encrypted** | `s3_bucket_default_encryption`, `s3_bucket_kms_encryption`, `ec2_ebs_default_encryption`, `ec2_ebs_volume_encryption`, `rds_instance_storage_encrypted`, `rds_cluster_storage_encrypted`, `rds_snapshots_encrypted`, `dynamodb_tables_kms_cmk_encryption_enabled`, `redshift_cluster_encrypted_at_rest`, `neptune_cluster_storage_encrypted`, `documentdb_cluster_storage_encrypted`, `opensearch_service_domains_encryption_at_rest_enabled`, `kinesis_stream_encrypted_at_rest`, `firehose_stream_encrypted_at_rest`, `sns_topics_kms_encryption_at_rest_enabled`, `sqs_queues_server_side_encryption_enabled`, `efs_encryption_at_rest_enabled`, `athena_workgroup_encryption`, `glue_data_catalogs_metadata_encryption_enabled`, `backup_vaults_encrypted`, `backup_recovery_point_encrypted`, `cloudtrail_kms_encryption_enabled`, `cloudwatch_log_group_kms_encryption_enabled`, `eks_cluster_kms_cmk_encryption_in_secrets_enabled`, `sagemaker_notebook_instance_encryption_enabled`, `apigateway_restapi_cache_encrypted`, `kafka_cluster_encryption_at_rest_uses_cmk`, `dynamodb_accelerator_cluster_encryption_enabled`, `storagegateway_fileshare_encryption_enabled` | `storage_infrastructure_encryption_is_enabled`, `storage_ensure_encryption_with_customer_managed_keys`, `vm_ensure_attached_disks_encrypted_with_cmk`, `vm_ensure_unattached_disks_encrypted_with_cmk`, `sqlserver_tde_encryption_enabled`, `sqlserver_tde_encrypted_with_cmk`, `databricks_workspace_cmk_encryption_enabled`, `monitor_storage_account_with_activity_logs_cmk_encrypted` | `compute_instance_encryption_with_csek_enabled`, `dataproc_encrypted_with_cmks_disabled`, `bigquery_dataset_cmk_encryption`, `bigquery_table_cmk_encryption` |
-| **CMEK required (customer-managed keys)** | `kms_cmk_are_used` | `storage_ensure_encryption_with_customer_managed_keys`, `vm_ensure_attached_disks_encrypted_with_cmk`, `vm_ensure_unattached_disks_encrypted_with_cmk`, `sqlserver_tde_encrypted_with_cmk`, `databricks_workspace_cmk_encryption_enabled` | `bigquery_dataset_cmk_encryption`, `bigquery_table_cmk_encryption`, `dataproc_encrypted_with_cmks_disabled`, `compute_instance_encryption_with_csek_enabled` |
-| **Key rotation enabled** | `kms_cmk_rotation_enabled` | `keyvault_key_rotation_enabled`, `storage_key_rotation_90_days` | `kms_key_rotation_enabled` |
-| **MFA for UI access** | `iam_root_mfa_enabled`, `iam_root_hardware_mfa_enabled`, `iam_user_mfa_enabled_console_access`, `iam_user_hardware_mfa_enabled`, `iam_administrator_access_with_mfa`, `cognito_user_pool_mfa_enabled` | `entra_privileged_user_has_mfa`, `entra_non_privileged_user_has_mfa`, `entra_user_with_vm_access_has_mfa`, `entra_security_defaults_enabled` | `compute_project_os_login_2fa_enabled` |
-| **API access / credentials** | `iam_no_root_access_key`, `iam_user_no_setup_initial_access_key`, `apigateway_restapi_authorizers_enabled`, `apigateway_restapi_public_with_authorizer`, `apigatewayv2_api_authorizers_enabled` | `entra_conditional_access_policy_require_mfa_for_management_api`, `app_function_access_keys_configured`, `app_function_identity_is_configured` | `apikeys_api_restrictions_configured`, `apikeys_key_exists`, `apikeys_key_rotated_in_90_days` |
-| **Log all admin/config changes** | `cloudtrail_multi_region_enabled`, `cloudtrail_multi_region_enabled_logging_management_events`, `cloudtrail_cloudwatch_logging_enabled`, `cloudtrail_log_file_validation_enabled`, `cloudwatch_log_metric_filter_*`, `cloudwatch_changes_to_*_alarm_configured`, `config_recorder_all_regions_enabled` | `monitor_diagnostic_settings_exists`, `monitor_diagnostic_setting_with_appropriate_categories`, `monitor_alert_*` | `iam_audit_logs_enabled`, `logging_log_metric_filter_and_alert_for_*`, `logging_sink_created` |
-| **Log integrity (digital signatures)** | `cloudtrail_log_file_validation_enabled` (exact) | None | None |
-| **Public access denied** | `s3_bucket_public_access`, `s3_bucket_public_list_acl`, `s3_bucket_public_write_acl`, `s3_account_level_public_access_blocks`, `apigateway_restapi_public`, `awslambda_function_url_public`, `awslambda_function_not_publicly_accessible`, `rds_instance_no_public_access`, `rds_snapshots_public_access`, `ec2_securitygroup_allow_ingress_from_internet_to_all_ports`, `sns_topics_not_publicly_accessible`, `sqs_queues_not_publicly_accessible` | `storage_blob_public_access_level_is_disabled`, `storage_ensure_private_endpoints_in_storage_accounts`, `containerregistry_not_publicly_accessible`, `keyvault_private_endpoints`, `app_function_not_publicly_accessible`, `aks_clusters_public_access_disabled`, `network_http_internet_access_restricted` | `cloudstorage_bucket_public_access`, `compute_instance_public_ip`, `cloudsql_instance_public_ip`, `compute_firewall_*_access_from_the_internet_allowed` |
-| **IAM least privilege** | `iam_*_no_administrative_privileges`, `iam_policy_allows_privilege_escalation`, `iam_inline_policy_allows_privilege_escalation`, `iam_role_administratoraccess_policy`, `iam_group_administrator_access_policy`, `iam_user_administrator_access_policy`, `iam_policy_attached_only_to_group_or_roles`, `iam_role_cross_service_confused_deputy_prevention` | `iam_role_user_access_admin_restricted`, `iam_subscription_roles_owner_custom_not_created`, `iam_custom_role_has_permissions_to_administer_resource_locks` | `iam_sa_no_administrative_privileges`, `iam_no_service_roles_at_project_level`, `iam_role_kms_enforce_separation_of_duties`, `iam_role_sa_enforce_separation_of_duties` |
-| **Password policy** | `iam_password_policy_minimum_length_14`, `iam_password_policy_uppercase`, `iam_password_policy_lowercase`, `iam_password_policy_symbol`, `iam_password_policy_number`, `iam_password_policy_expires_passwords_within_90_days_or_less`, `iam_password_policy_reuse_24` | None | None |
-| **Credential rotation / unused** | `iam_rotate_access_key_90_days`, `iam_user_accesskey_unused`, `iam_user_console_access_unused` | None | `iam_sa_user_managed_key_rotate_90_days`, `iam_sa_user_managed_key_unused`, `iam_service_account_unused` |
-| **VPC / flow logs** | `vpc_flow_logs_enabled` | `network_flow_log_captured_sent`, `network_watcher_enabled`, `network_flow_log_more_than_90_days` | `compute_subnet_flow_logs_enabled` |
-| **Backup / DR / Multi-AZ** | `backup_vaults_exist`, `backup_plans_exist`, `backup_reportplans_exist`, `rds_instance_backup_enabled`, `rds_*_protected_by_backup_plan`, `rds_cluster_multi_az`, `neptune_cluster_backup_enabled`, `documentdb_cluster_backup_enabled`, `efs_have_backup_enabled`, `s3_bucket_cross_region_replication`, `dynamodb_table_protected_by_backup_plan` | `vm_backup_enabled`, `vm_sufficient_daily_backup_retention_period`, `storage_geo_redundant_enabled` | `cloudsql_instance_automated_backups`, `cloudstorage_bucket_log_retention_policy_lock`, `cloudstorage_bucket_sufficient_retention_period` |
-| **Access analysis / discovery** | `accessanalyzer_enabled`, `accessanalyzer_enabled_without_findings` | None specific | `iam_account_access_approval_enabled`, `iam_cloud_asset_inventory_enabled` |
-| **Object lock / retention** | `s3_bucket_object_lock`, `s3_bucket_object_versioning`, `s3_bucket_lifecycle_enabled`, `cloudtrail_bucket_requires_mfa_delete`, `s3_bucket_no_mfa_delete` | `storage_ensure_soft_delete_is_enabled`, `storage_blob_versioning_is_enabled`, `storage_ensure_file_shares_soft_delete_is_enabled` | `cloudstorage_bucket_log_retention_policy_lock`, `cloudstorage_bucket_soft_delete_enabled`, `cloudstorage_bucket_versioning_enabled`, `cloudstorage_bucket_sufficient_retention_period` |
-| **Uniform bucket-level access** | `s3_bucket_acl_prohibited` | `storage_account_key_access_disabled`, `storage_default_to_entra_authorization_enabled` | `cloudstorage_bucket_uniform_bucket_level_access` |
-| **Container vulnerability scanning** | `ecr_registry_scan_images_on_push_enabled`, `ecr_repositories_scan_vulnerabilities_in_latest_image` | `defender_container_images_scan_enabled`, `defender_container_images_resolved_vulnerabilities` | `artifacts_container_analysis_enabled`, `gcr_container_scanning_enabled` |
-| **WAF / rate limiting** | `wafv2_webacl_with_rules`, `waf_*_webacl_with_rules`, `wafv2_webacl_logging_enabled`, `waf_global_webacl_logging_enabled` | None | None |
-| **Deployment region restriction** | `organizations_scp_check_deny_regions` | None | None |
-| **Secrets automatic rotation** | `secretsmanager_automatic_rotation_enabled`, `secretsmanager_secret_rotated_periodically` | `keyvault_rbac_secret_expiration_set`, `keyvault_non_rbac_secret_expiration_set` | None |
-| **Certificate management** | `acm_certificates_expiration_check`, `acm_certificates_with_secure_key_algorithms`, `acm_certificates_transparency_logs_enabled` | `keyvault_key_expiration_set_in_non_rbac`, `keyvault_rbac_key_expiration_set`, `keyvault_non_rbac_secret_expiration_set` | None |
-| **GenAI guardrails / input/output filtering** | `bedrock_guardrail_prompt_attack_filter_enabled`, `bedrock_guardrail_sensitive_information_filter_enabled`, `bedrock_agent_guardrail_enabled`, `bedrock_model_invocation_logging_enabled`, `bedrock_api_key_no_administrative_privileges`, `bedrock_api_key_no_long_term_credentials` | None | None |
-| **ML dev environment security** | `sagemaker_notebook_instance_root_access_disabled`, `sagemaker_notebook_instance_without_direct_internet_access_configured`, `sagemaker_notebook_instance_vpc_settings_configured`, `sagemaker_models_vpc_settings_configured`, `sagemaker_training_jobs_vpc_settings_configured`, `sagemaker_training_jobs_network_isolation_enabled`, `sagemaker_training_jobs_volume_and_output_encryption_enabled` | None | None |
-| **Threat detection / anomalous behavior** | `cloudtrail_threat_detection_enumeration`, `cloudtrail_threat_detection_privilege_escalation`, `cloudtrail_threat_detection_llm_jacking`, `guardduty_is_enabled`, `guardduty_no_high_severity_findings` | None | None |
-| **Serverless private access** | `awslambda_function_inside_vpc`, `awslambda_function_not_publicly_accessible`, `awslambda_function_url_public` | `app_function_not_publicly_accessible` | None |
-
-### What Prowler Does NOT Cover (accept MANUAL honestly)
-
-Don't pad mappings for these — mark `Checks: []` and move on:
-
-- **TLS 1.3 version specifically** — Prowler verifies TLS is enforced, not always the exact version
-- **IANA port-protocol consistency** — no check for "protocol running on its assigned port"
-- **mTLS on most Azure/GCP services** — limited to App Service client certs on Azure, nothing on GCP
-- **Rate limiting** on monitoring endpoints, load balancers, serverless invocations, vector ingestion
-- **Session cookie expiry** (LB stickiness)
-- **HTTP header scrubbing** (Server, X-Powered-By)
-- **Certificate transparency verification for imports**
-- **Model version pinning, red teaming, AI quality review**
-- **Vector embedding validation, dimensional constraints, ANN vs exact search**
-- **Secret region replication** (cross-region residency)
-- **Lifecycle cleanup policies on container registries**
-- **Row-level / column-level security in data warehouses**
-- **Deployment region restriction on Azure/GCP** (AWS has `organizations_scp_check_deny_regions`, others don't)
-- **Cross-tenant alert silencing permissions**
-- **Field-level masking in logs**
-- **Managed view enforcement for database access**
-- **Automatic MFA delete on all S3 buckets** (only CloudTrail bucket variant exists for some frameworks — AWS has the generic `s3_bucket_no_mfa_delete` though)
+For the curated mapping table (requirement text → AWS/Azure/GCP checks) and
+the list of controls Prowler genuinely cannot verify, see
+[references/check-mapping-reference.md](references/check-mapping-reference.md).
---
-## Workflow C: Add a New Output Formatter
+## Workflow C: Add a New Universal Framework
-Use when a new framework needs its own CSV columns or terminal table. Follow the c5/csa/ens layout exactly:
+1. Author `prowler/compliance/{framework}_{version}.json` following the
+ Universal Schema Reference above (use `dora_2022_2554.json` or
+ `csa_ccm_4.0.json` as template).
+2. Declare every attribute in `attributes_metadata` (with `required`/`enum`
+ where possible — that's your load-time validation) and a
+ `outputs.table_config.group_by`.
+3. Map checks per provider; add `config_requirements` (with `Provider`) for
+ configurable checks; leave empty lists for manual requirements — **include
+ every requirement of the source catalog** (coverage percentages depend on
+ the full denominator).
+4. Validate (section below). No Python registration of any kind is needed for
+ CLI table/CSV/OCSF.
+5. Optional first-class UI: mapper in `ui/lib/compliance/{framework}.tsx`,
+ registration in `getComplianceMappers()` under the JSON's `framework` value,
+ detail panel, `*AttributesMetadata` type, and icon (ordered keyword!). Until
+ then the generic mapper renders it.
+6. Optional API extras: CSV exporter entry in `COMPLIANCE_CLASS_MAP`; PDF
+ generator + `FRAMEWORK_REGISTRY` entry if a PDF is required.
+7. Tests: extend `tests/lib/check/universal_compliance_models_test.py` with a
+ case loading the new JSON. The parametrized `test_loads_as_universal`
+ already picks the file up automatically.
+8. Changelog fragment `prowler/changelog.d/.added.md` + user-guide
+ tutorial under `docs/user-guide/compliance/tutorials/` for high-profile
+ frameworks.
-```bash
-mkdir -p prowler/lib/outputs/compliance/{framework}
-touch prowler/lib/outputs/compliance/{framework}/__init__.py
-```
+## Workflow D: Add a New Legacy Output Formatter
-### Step 1 — Create `{framework}.py` (table dispatcher ONLY)
+Only for new members of an existing legacy family. Follow the `c5/` or `ccc/`
+layout exactly:
-Copy from `prowler/lib/outputs/compliance/c5/c5.py` and change the function name + framework string. The `diff` between your file and `c5.py` should be just those two lines. **No function docstring** — other frameworks don't have one, stay consistent.
+1. `mkdir prowler/lib/outputs/compliance/{framework}` with `__init__.py`.
+2. `{framework}.py` — copy `c5/c5.py`, change function name + framework
+ string; the diff should be just those lines. No docstring (legacy style).
+3. `models.py` — one Pydantic CSV row model per provider. Column sets differ
+ per provider (`AccountId`/`Region` vs `SubscriptionId`/`Location` vs
+ `ProjectId`/`Location`); per-provider files are the convention — don't
+ collapse them into a parameterized class, reviewers will reject it.
+4. `{framework}_{provider}.py` — `{Framework}_{Provider}(ComplianceOutput)`
+ with `transform()`; this file may import `Finding`.
+5. Register:
+ - `compliance.py` → `display_compliance_table()` `elif` branch (+ top import).
+ - `prowler/__main__.py` → per-provider `elif compliance_name.startswith(...)`
+ branches instantiating the writer classes.
+ - `api/src/backend/tasks/jobs/export.py` → `COMPLIANCE_CLASS_MAP` entries
+ (`startswith` for families, exact match only for true singletons).
+6. Tests under `tests/lib/outputs/compliance/{framework}/` + fixtures in
+ `tests/lib/outputs/compliance/fixtures.py` (1 evaluated + 1 manual
+ requirement to exercise both `transform()` paths).
-### Step 2 — Create `models.py`
+**Circular import warning**: the table file must not import `Finding` directly
+or transitively (cycle: `compliance.compliance` → table → `ComplianceOutput` →
+`Finding` → `get_check_compliance` → `compliance.compliance`). Keep it bare;
+use `TYPE_CHECKING`/function-local imports where both are genuinely needed.
-One Pydantic v2 `BaseModel` per provider. Field names become CSV column headers (public API — don't rename later without a migration).
+---
-```python
-from typing import Optional
-from pydantic import BaseModel
+## Validation (run before every commit)
-class {Framework}_AWSModel(BaseModel):
- Provider: str
- Description: str
- AccountId: str
- Region: str
- AssessmentDate: str
- Requirements_Id: str
- Requirements_Description: str
- # ... provider-specific columns
- Status: str
- StatusExtended: str
- ResourceId: str
- ResourceName: str
- CheckId: str
- Muted: bool
-```
+1. **Schema load (both formats)**:
-### Step 3 — Create `{framework}_{provider}.py` for each provider
+ ```python
+ from prowler.lib.check.compliance_models import (
+ load_compliance_framework_universal,
+ get_bulk_compliance_frameworks_universal,
+ )
+ fw = load_compliance_framework_universal("prowler/compliance/.json")
+ assert fw is not None, "check logs for the ValidationError"
+ print(fw.framework, len(fw.requirements), fw.get_providers())
+ assert "" in get_bulk_compliance_frameworks_universal("aws")
+ ```
-Copy from `prowler/lib/outputs/compliance/c5/c5_aws.py` etc. Contains the `{Framework}_AWS(ComplianceOutput)` class with `transform()` that walks findings and emits model rows. This file IS allowed to import `Finding`.
+ Remember: the universal loader is lenient (skips broken files with a log
+ line) — an `assert fw is not None` is mandatory, a green scan is not proof.
-### Step 4 — Register everywhere
+2. **Check existence** — no loader validates this; a stale id is silent dead
+ weight:
-**`prowler/lib/outputs/compliance/compliance.py`** (CLI table dispatcher):
-```python
-from prowler.lib.outputs.compliance.{framework}.{framework} import get_{framework}_table
+ ```python
+ import json
+ from pathlib import Path
+ for prov in ["aws", "azure", "gcp"]:
+ real = {p.stem.replace(".metadata", "")
+ for p in Path(f"prowler/providers/{prov}/services").rglob("*.metadata.json")}
+ data = json.load(open(f"prowler/compliance/{prov}/.json"))
+ refs = {c for r in data["Requirements"] for c in r["Checks"]}
+ missing = refs - real
+ assert not missing, f"{prov} missing: {missing}"
+ ```
-def display_compliance_table(...):
- ...
- elif compliance_framework.startswith("{framework}_"):
- get_{framework}_table(findings, bulk_checks_metadata,
- compliance_framework, output_filename,
- output_directory, compliance_overview)
-```
+ (For universal files use `r.get("checks", {}).get(prov, [])` instead —
+ requirements may legitimately omit a provider key.)
-**`prowler/__main__.py`** (CLI output writer per provider):
-Add imports at the top:
-```python
-from prowler.lib.outputs.compliance.{framework}.{framework}_aws import {Framework}_AWS
-from prowler.lib.outputs.compliance.{framework}.{framework}_azure import {Framework}_Azure
-from prowler.lib.outputs.compliance.{framework}.{framework}_gcp import {Framework}_GCP
-```
-Add provider-specific `elif compliance_name.startswith("{framework}_"):` branches that instantiate the class and call `batch_write_data_to_file()`.
+3. **CLI smoke test**:
-**`api/src/backend/tasks/jobs/export.py`** (API export dispatcher):
-```python
-from prowler.lib.outputs.compliance.{framework}.{framework}_aws import {Framework}_AWS
-# ... azure, gcp
+ ```bash
+ uv run python prowler-cli.py --list-compliance # appears?
+ uv run python prowler-cli.py --compliance --log-level ERROR
+ ```
-COMPLIANCE_CLASS_MAP = {
- "aws": [
- # ...
- (lambda name: name.startswith("{framework}_"), {Framework}_AWS),
- ],
- # ... azure, gcp
-}
-```
+ Verify the CSV under `output/compliance/`, the summary table sections, and
+ the findings roll-up.
-**Always use `startswith`**, never `name == "framework_aws"`. Exact match is a regression.
+4. **Tests**:
-### Step 5 — Add tests
+ ```bash
+ uv run pytest -n auto tests/lib/check/universal_compliance_models_test.py \
+ tests/lib/outputs/compliance/
+ ```
-Create `tests/lib/outputs/compliance/{framework}/` with `{framework}_aws_test.py`, `{framework}_azure_test.py`, `{framework}_gcp_test.py`. See the test template in [references/test_template.md](references/test_template.md).
+ `test_loads_as_universal` is parametrized over **every** JSON in
+ `prowler/compliance/` (top-level + subdirectories) — a malformed file fails
+ CI here even if you never wrote a dedicated test.
-Add fixtures to `tests/lib/outputs/compliance/fixtures.py`: one `Compliance` object per provider with 1 evaluated + 1 manual requirement to exercise both code paths in `transform()`.
+5. **What CI/pre-commit do and don't cover**: pre-commit only guarantees
+ well-formed/pretty JSON (`check-json`, `pretty-format-json`) — no semantic
+ validation. The workflow `.github/workflows/pr-check-compliance-mapping.yml`
+ flags PRs adding new checks without mapping them to any framework (label
+ `needs-compliance-review`; skip with label `no-compliance-check`). Semantic
+ validation happens in the pytest suite above and manually via
+ `skills/prowler-compliance-review/assets/validate_compliance.py` (note:
+ that validator assumes the **legacy** schema).
-### Circular import warning
-
-**The table dispatcher file (`{framework}.py`) MUST NOT import `Finding`** (directly or transitively). The cycle is:
-
-```text
-compliance.compliance imports get_{framework}_table
- → {framework}.py imports ComplianceOutput
- → compliance_output imports Finding
- → finding imports get_check_compliance from compliance.compliance
- → CIRCULAR
-```
-
-Keep `{framework}.py` bare — only `colorama`, `tabulate`, `prowler.config.config`. Put anything that imports `Finding` in the per-provider `{framework}_{provider}.py` files.
+6. **Prowler Local Server**: `docker compose up` and confirm the compliance
+ page renders requirements, sections and widgets.
---
## Conventions and Hard-Won Gotchas
-These are lessons from the FINOS CCC v2025.10 sync + 172-AR audit pass (April 2026). Learn them once; save days of debugging.
-
-1. **Per-provider files are non-negotiable.** Never collapse `{framework}_aws.py`, `{framework}_azure.py`, `{framework}_gcp.py` into a single parameterized class, no matter how DRY-tempting. Every other framework in the codebase follows the per-provider pattern and reviewers will reject the refactor. The CSV column names differ per provider — three classes is the convention.
-2. **`{framework}.py` has NO function docstring.** Other frameworks don't have them. Don't add one to be "helpful".
-3. **Circular import protection**: the table dispatcher file MUST NOT import `Finding` (directly or transitively). Split the code so `{framework}.py` only has `get_{framework}_table()` with bare imports, and `{framework}_{provider}.py` holds the class that needs `Finding`.
-4. **`Generic_Compliance_Requirement_Attribute` is the fallback** — in the `Compliance_Requirement.Attributes` Union in `compliance_models.py`, Generic MUST be LAST because Pydantic v1 tries union members in order. Putting Generic first means every framework-specific attribute falls through to Generic and the specific model is never used.
-5. **Pydantic v1 imports.** `from pydantic.v1 import BaseModel` in `compliance_models.py` — not v2. Mixing causes validation errors. Pydantic v2 is used in the CSV models (`models.py`) — that's fine because they're separate trees.
-6. **`get_check_compliance()` key format** is `f"{Framework}-{Version}"` ONLY if Version is set. Empty Version → key is `"{Framework}"` (no version suffix). Tests that mock compliance dicts must match this exact format — when a framework ships with `Version: ""`, downstream code and tests break silently.
-7. **CSV column names from `models.py` are public API.** Don't rename a field without migrating downstream consumers — CSV headers change.
-8. **Upstream YAML multi-line scalars** (`|` block scalars) preserve newlines. Collapse to single-line with `" ".join(value.split())` before writing to JSON.
-9. **Upstream catalogs can use multiple shapes.** FINOS CCC uses `control-families: [...]` in most catalogs but `controls: [...]` at the top level in `storage/object`. Any sync script must handle both or silently drop entire catalogs.
-10. **Foreign-prefix AR ids.** Upstream sometimes "imports" requirements from one catalog into another by keeping the original id prefix (e.g., `CCC.AuditLog.CN08.AR01` appearing under `CCC.Logging.CN03`). Prowler's compliance model requires unique ids within a catalog — rewrite the foreign id to fit the parent control: `CCC.AuditLog.CN08.AR01` (inside `CCC.Logging.CN03`) → `CCC.Logging.CN03.AR01`.
-11. **Genuine upstream id collisions.** Sometimes upstream has a real typo where two different requirements share the same id (e.g., `CCC.Core.CN14.AR02` defined twice for 30-day and 14-day backup variants). Renumber the second copy to the next free AR number. Preserve check mappings by matching on `(Section, frozenset(Applicability))` since the renumbered id won't match by id.
-12. **`COMPLIANCE_CLASS_MAP` in `export.py` uses `startswith` predicates** for all modern frameworks. Exact match (`name == "ccc_aws"`) is an anti-pattern — it was present for CCC until April 2026 and was the reason CCC couldn't have versioned variants.
-13. **Pre-validate every check id** against the per-provider inventory before writing the JSON. A typo silently creates an unreferenced check that will fail when findings try to map to it. The audit script MUST abort with stderr listing typos, not swallow them.
-14. **REPLACE is better than PATCH** for audit decisions. Encoding every mapping explicitly makes the audit reproducible and surfaces hidden assumptions from the legacy data. A PATCH system that adds/removes is too easy to forget.
-15. **When no check applies, MANUAL is correct.** Do not pad mappings with tangential checks "just in case". Prowler's compliance reports are meant to be actionable — padding them with noise breaks that. Honest manual reqs can be mapped later when new checks land.
-16. **UI groups by `Attributes[0].FamilyName` and `Attributes[0].Section`.** If FamilyName has inconsistent variants within the same JSON (e.g., "Logging & Monitoring" vs "Logging and Monitoring"), the UI renders them as separate categories. Section empty → the requirement falls into an orphan control with label "". Normalize before shipping.
-17. **Provider coverage is asymmetric.** AWS has dense coverage (~586 checks across 80+ services): in-transit encryption, IAM, database encryption, backup. Azure (~167 checks) and GCP (~102 checks) are thinner especially for in-transit encryption, mTLS, and ML/AI. Accept the asymmetry in mappings — don't force GCP parity where Prowler genuinely can't verify.
+1. **Universal first.** A new framework that starts as legacy needs 3 output
+ files + 3 registrations; the same framework as universal needs zero. Only
+ extend legacy families.
+2. **`Generic_Compliance_Requirement_Attribute` stays LAST** in the legacy
+ Attributes Union — Pydantic v1 tries members in order; Generic first
+ silently swallows every specific shape.
+3. **Pydantic v1 everywhere in `compliance_models.py`**
+ (`from pydantic.v1 import ...`). Don't mix in v2.
+4. **`get_check_compliance()` lives in
+ `prowler/lib/outputs/compliance/compliance_check.py`** and keys the dict
+ `f"{Framework}-{Version}"` only when Version is non-empty. Never ship
+ `Version: ""` — the key silently degrades to `"{Framework}"` and breaks
+ filters, tests and `--compliance`. For legacy files the filename version
+ substring must match `Version` (the CLI reads
+ `compliance_framework.split("_")[1]`).
+5. **`Compliance.get_bulk()` does not see top-level universal files** — only
+ `get_bulk_compliance_frameworks_universal()` does. Wire new code paths
+ against the universal loader.
+6. **Loader leniency differs**: legacy loader exits the process on a broken
+ JSON; universal loader logs and skips. A missing framework after your edit
+ usually means the universal loader dropped it — check the logs.
+7. **Circular import protection**: legacy table dispatcher files must not
+ import `Finding` (directly or transitively). Use `TYPE_CHECKING` or
+ function-local imports when a module needs both sides (that's how the
+ universal formatter does it).
+8. **Per-provider formatter files are the legacy convention** — but know the
+ exceptions before flagging them (iso27001 has no table file,
+ aws_well_architected has no per-provider files, cisa_scuba is
+ googleworkspace-only). CSV model field names are public API.
+9. **CSV output**: `;` delimiter, UPPERCASE headers. OCSF compliance output is
+ always generated for universal frameworks regardless of `--output-formats`.
+10. **`COMPLIANCE_CLASS_MAP` mixes predicate styles**: `startswith` for
+ multi-version families, exact `==` for singletons. When in doubt use
+ `startswith` — exact match blocked versioned CCC variants until 2026.
+11. **UI grouping is per-mapper, always on `attributes[0]`**: generic/cis →
+ `Section`/`SubSection`, iso → `Category`, ccc → `FamilyName`. Inconsistent
+ values (or empty Section) create orphan/duplicate tree branches — normalize
+ before shipping.
+12. **UI has a generic fallback** — an unregistered framework still renders.
+ A dedicated mapper/panel/icon is an upgrade, not a prerequisite.
+13. **Icon registration is ordered substring matching** in
+ `IconCompliance.tsx` — specific keywords before generic (`nist` before
+ `nis2`, `cisa` before `cis`, `aws` last).
+14. **API PDF pipeline is not `PDFConfig`-driven yet** — it has its own
+ `FRAMEWORK_REGISTRY` (5 frameworks). Don't assume adding `pdf_config` to a
+ JSON produces a PDF in Prowler App.
+15. **Pre-validate every check id** against the per-provider inventory before
+ writing JSON. No loader will catch a typo; the requirement just never
+ matches a finding.
+16. **REPLACE beats PATCH** for audit decisions — full explicit lists are
+ reproducible and surface legacy assumptions.
+17. **When no check applies, MANUAL is correct.** Don't pad mappings with
+ tangential checks; compliance reports must stay actionable.
+18. **Include every requirement of the source catalog**, automated or not —
+ compliance percentages use the full requirement count as denominator.
+19. **Provider coverage is asymmetric** (AWS dense; Azure/GCP thinner; new
+ providers minimal). Accept it — don't force parity Prowler can't verify.
+20. **Guardrail authoring**: strictest tolerated `Value`, exact `ConfigKey`
+ spelling, `Provider` mandatory in universal files, booleans as JSON
+ booleans. Malformed constraints are treated as satisfied — validate with
+ the config tests, don't trust silence.
---
## Useful One-Liners
```bash
-# Count requirements per service prefix (CCC, CIS sections, etc.)
-jq -r '.Requirements[].Id | split(".")[1]' prowler/compliance/aws/ccc_aws.json | sort | uniq -c
-
-# Find duplicate requirement IDs
+# Find duplicate requirement IDs (legacy | universal)
jq -r '.Requirements[].Id' file.json | sort | uniq -d
+jq -r '.requirements[].id' file.json | sort | uniq -d
-# Count manual requirements (no checks)
+# Count manual requirements (legacy | universal, per provider)
jq '[.Requirements[] | select((.Checks | length) == 0)] | length' file.json
+jq '[.requirements[] | select((.checks.aws // [] | length) == 0)] | length' file.json
-# List all unique check references in a framework
+# List unique check references (legacy | universal)
jq -r '.Requirements[].Checks[]' file.json | sort -u
+jq -r '.requirements[].checks[]? | .[]' file.json | sort -u
-# List all unique Sections (to spot inconsistency)
+# Providers covered by a universal framework
+jq '[.requirements[].checks | keys[]] | unique' file.json
+
+# Spot inconsistent grouping values (UI tree branches)
jq '[.Requirements[].Attributes[0].Section] | unique' file.json
-
-# List all unique FamilyNames (to spot inconsistency)
jq '[.Requirements[].Attributes[0].FamilyName] | unique' file.json
-# Diff requirement ids between two versions of the same framework
+# Requirements with config guardrails (empty arrays are truthy in jq — check length)
+jq '[.Requirements[] | select((.ConfigRequirements // []) | length > 0)] | length' file.json
+
+# Diff requirement ids between two versions
diff <(jq -r '.Requirements[].Id' a.json | sort) <(jq -r '.Requirements[].Id' b.json | sort)
-# Find where a check id is used across all frameworks
+# Where is a check mapped across all frameworks?
grep -rl "my_check_name" prowler/compliance/
-# Check if a Prowler check exists
+# Does a check exist?
find prowler/providers/aws/services -name "{check_id}.metadata.json"
-# Validate a JSON with Pydantic
-python -c "from prowler.lib.check.compliance_models import Compliance; print(Compliance.parse_file('prowler/compliance/aws/ccc_aws.json').Framework)"
+# Validate one file with the universal loader
+python -c "from prowler.lib.check.compliance_models import load_compliance_framework_universal as l; fw=l('prowler/compliance/aws/cis_7.0_aws.json'); print(fw.framework, len(fw.requirements))"
```
----
-
-## Best Practices
-
-1. **Requirement IDs**: Follow the original framework numbering exactly (e.g., "1.1", "A.5.1", "T1190", "ac_2_1")
-2. **Check Mapping**: Map to existing checks when possible. Use `Checks: []` for manual-only requirements — honest MANUAL beats padded coverage
-3. **Completeness**: Include all framework requirements, even those without automated checks
-4. **Version Control**: Include framework version in `Name` and `Version` fields. **Never leave `Version: ""`** — it breaks `get_check_compliance()` key format
-5. **File Naming**: Use format `{framework}_{version}_{provider}.json`
-6. **Validation**: Prowler validates JSON against Pydantic models at startup — invalid JSON will cause errors
-7. **Pre-validate check ids** against the provider's `*.metadata.json` inventory before every commit
-8. **Normalize FamilyName and Section** to avoid inconsistent UI tree branches
-9. **Register everywhere**: SDK model (if needed) → `compliance.py` dispatcher → `__main__.py` CLI writer → `export.py` API map → UI mapper. Skipping any layer results in silent failures
-10. **Audit, don't pad**: when reviewing mappings, apply the golden rule — the check's title/risk MUST literally describe what the requirement text says. Tangential relation doesn't count
-
## Commands
```bash
-# List available frameworks for a provider
prowler {provider} --list-compliance
-
-# Run scan with specific compliance framework
-prowler aws --compliance cis_5.0_aws
-
-# Run scan with multiple frameworks
-prowler aws --compliance cis_5.0_aws pci_4.0_aws
-
-# Output compliance report in multiple formats
-prowler aws --compliance cis_5.0_aws -M csv json html
+prowler {provider} --compliance cis_7.0_aws
+prowler aws --compliance cis_7.0_aws pci_4.0_aws
+prowler aws --compliance dora_2022_2554 # universal key = file basename
+prowler aws --list-compliance-requirements cis_7.0_aws
+prowler aws --compliance cis_7.0_aws -M csv json html
```
## Code References
### Layer 1 — SDK / Core
-- **Compliance Models:** `prowler/lib/check/compliance_models.py` (Pydantic v1 model tree)
-- **Compliance Processing / Linker:** `prowler/lib/check/compliance.py` (`get_check_compliance`, `update_checks_metadata_with_compliance`)
-- **Check Utils:** `prowler/lib/check/utils.py` (`list_compliance_modules`)
+
+- `prowler/lib/check/compliance_models.py` — legacy + universal model trees,
+ `Compliance_Requirement_ConfigConstraint`, all loaders and the
+ legacy→universal adapter
+- `prowler/lib/check/compliance.py` — `update_checks_metadata_with_compliance`
+- `prowler/lib/check/compliance_config_eval.py` — guardrail evaluation
+ (shared with the API)
+- `prowler/lib/outputs/compliance/compliance_check.py` — `get_check_compliance`
+- `prowler/lib/check/utils.py` — `list_compliance_modules`
### Layer 2 — JSON Catalogs
-- **Framework JSONs:** `prowler/compliance/{provider}/` (auto-discovered via directory walk)
+
+- `prowler/compliance/*.json` — universal, multi-provider (auto-discovered)
+- `prowler/compliance/{provider}/` — legacy, per-provider (auto-discovered)
### Layer 3 — Output Formatters
-- **Per-framework folders:** `prowler/lib/outputs/compliance/{framework}/`
-- **Shared base class:** `prowler/lib/outputs/compliance/compliance_output.py` (`ComplianceOutput` + `batch_write_data_to_file`)
-- **CLI table dispatcher:** `prowler/lib/outputs/compliance/compliance.py` (`display_compliance_table`)
-- **Finding model:** `prowler/lib/outputs/finding.py` (**do not import transitively from table dispatcher files — circular import**)
-- **CLI writer:** `prowler/__main__.py` (per-provider `elif compliance_name.startswith(...)` branches that instantiate per-provider classes)
+
+- `prowler/lib/outputs/compliance/universal/` — `universal_table.py`,
+ `universal_output.py`, `ocsf_compliance.py`
+- `prowler/lib/outputs/compliance/{framework}/` — legacy per-framework packages
+- `prowler/lib/outputs/compliance/compliance.py` —
+ `process_universal_compliance_frameworks`, `display_compliance_table`
+- `prowler/lib/outputs/compliance/compliance_output.py` — `ComplianceOutput`
+ base + CSV writer
+- `prowler/__main__.py` — universal processing + per-provider legacy writer
+ branches
### Layer 4 — API / UI
-- **API lazy loader:** `api/src/backend/api/compliance.py` (`LazyComplianceTemplate`, `LazyChecksMapping`)
-- **API export dispatcher:** `api/src/backend/tasks/jobs/export.py` (`COMPLIANCE_CLASS_MAP` with `startswith` predicates)
-- **UI framework router:** `ui/lib/compliance/compliance-mapper.ts`
-- **UI per-framework mapper:** `ui/lib/compliance/{framework}.tsx`
-- **UI detail panel:** `ui/components/compliance/compliance-custom-details/{framework}-details.tsx`
-- **UI types:** `ui/types/compliance.ts`
-- **UI icon:** `ui/components/icons/compliance/{framework}.svg` + registration in `IconCompliance.tsx`
+
+- `api/src/backend/api/compliance.py` — `LazyComplianceTemplate`,
+ `LazyChecksMapping`, cache warm-up
+- `api/src/backend/tasks/jobs/export.py` — `COMPLIANCE_CLASS_MAP`
+- `api/src/backend/tasks/jobs/scan.py` — `create_compliance_requirements`
+ (overview ingestion)
+- `api/src/backend/tasks/jobs/reports/` — PDF generators + `FRAMEWORK_REGISTRY`
+- `ui/lib/compliance/compliance-mapper.ts` — mapper routing + generic fallback
+- `ui/lib/compliance/{framework}.tsx` — per-framework mappers
+- `ui/components/compliance/compliance-custom-details/` — detail panels
+- `ui/types/compliance.ts` — attribute metadata types
+- `ui/components/icons/compliance/` + `IconCompliance.tsx` — icons (ordered)
### Tests
-- **Output formatter tests:** `tests/lib/outputs/compliance/{framework}/{framework}_{provider}_test.py`
-- **Shared fixtures:** `tests/lib/outputs/compliance/fixtures.py`
+
+- `tests/lib/check/universal_compliance_models_test.py` — includes the
+ parametrized `test_loads_as_universal` over every shipped JSON
+- `tests/lib/check/compliance_check_test.py`,
+ `compliance_config_eval_test.py`, `compliance_config_constraint_model_test.py`,
+ `compliance_config_requirements_data_test.py`, `mitre_config_requirements_test.py`
+- `tests/lib/outputs/compliance/` — per-framework + universal + dispatcher +
+ config-status coverage tests; shared `fixtures.py`
## Resources
-- **JSON Templates:** See [assets/](assets/) for framework JSON templates (cis, ens, iso27001, mitre_attack, prowler_threatscore, generic)
-- **Config-driven compliance sync** (any upstream-backed framework):
- - [assets/sync_framework.py](assets/sync_framework.py) — generic runner. Loads a YAML config, dynamically imports the declared parser, applies generic post-processing (id uniqueness safety net, `FamilyName` normalization, legacy check-mapping preservation with config-driven fallback keys), and writes the provider JSONs with Pydantic post-validation. Framework-agnostic — works for any compliance framework.
- - [assets/configs/ccc.yaml](assets/configs/ccc.yaml) — canonical config example (FINOS CCC v2025.10). Copy and adapt for new frameworks.
- - [assets/parsers/finos_ccc.py](assets/parsers/finos_ccc.py) — FINOS CCC YAML parser. Handles both upstream shapes (`control-families` and top-level `controls`), foreign-prefix AR rewriting, and genuine collision renumbering. Exposes `parse_upstream(config) -> list[dict]`.
- - [assets/parsers/](assets/parsers/) — add new parser modules here for unfamiliar upstream formats (NIST OSCAL JSON, MITRE STIX, CIS Benchmarks, etc.). Each parser is a `{name}.py` file implementing `parse_upstream(config) -> list[dict]` with guaranteed-unique ids.
-- **Reusable audit tooling** (added April 2026 after the FINOS CCC v2025.10 sync):
- - [assets/audit_framework_template.py](assets/audit_framework_template.py) — explicit REPLACE decision ledger with pre-validation against the per-provider inventory. Drop-in template for auditing any framework.
- - [assets/query_checks.py](assets/query_checks.py) — keyword/service/id query helper over `/tmp/checks_{provider}.json`.
- - [assets/dump_section.py](assets/dump_section.py) — dumps every AR for a given id prefix across all 3 providers with current check mappings.
- - [assets/build_inventory.py](assets/build_inventory.py) — generates `/tmp/checks_{provider}.json` from `*.metadata.json` files.
-- **Documentation:** See [references/compliance-docs.md](references/compliance-docs.md) for additional resources
-- **Related skill:** [prowler-compliance-review](../prowler-compliance-review/SKILL.md) — PR review checklist and validator script for compliance framework PRs
+- **Docs (source of truth for contributors)**:
+ `docs/developer-guide/security-compliance-framework.mdx` (both schemas,
+ guardrails, validation, PR process),
+ `docs/user-guide/compliance/tutorials/compliance.mdx`,
+ `docs/user-guide/compliance/tutorials/cross-provider-compliance.mdx`
+- **Repo tooling** (`util/compliance/`): CSV→JSON generators
+ (`generate_json_from_csv/`), `ccc/from_yaml_to_json.py`,
+ `compliance_mapper/`, `threatscore/`
+- **Skill assets** ([assets/](assets/)):
+ - `sync_framework.py` + `configs/ccc.yaml` + `parsers/finos_ccc.py` —
+ config-driven upstream sync (Workflow A)
+ - `build_inventory.py`, `query_checks.py`, `dump_section.py`,
+ `audit_framework_template.py` — audit tooling (Workflow B)
+ - Legacy JSON templates: `cis_framework.json`, `ens_framework.json`,
+ `iso27001_framework.json`, `mitre_attack_framework.json`,
+ `prowler_threatscore_framework.json`, `generic_framework.json`
+- **References**:
+ [references/compliance-docs.md](references/compliance-docs.md) — model/loader
+ quick reference;
+ [references/check-mapping-reference.md](references/check-mapping-reference.md)
+ — curated requirement-text → checks mapping table + honest-MANUAL list
+- **Sister skill**:
+ [prowler-compliance-review](../prowler-compliance-review/SKILL.md) — PR
+ review checklist + `validate_compliance.py` (legacy-schema validator)
+- After editing this skill's frontmatter, run
+ `./skills/skill-sync/assets/sync.sh` to regenerate the AGENTS.md auto-invoke
+ tables.
diff --git a/skills/prowler-compliance/references/check-mapping-reference.md b/skills/prowler-compliance/references/check-mapping-reference.md
new file mode 100644
index 0000000000..cae9701ae7
--- /dev/null
+++ b/skills/prowler-compliance/references/check-mapping-reference.md
@@ -0,0 +1,78 @@
+# Audit Reference: Requirement Text → Prowler Checks
+
+Built from a real audit of 172 CCC ARs × 3 providers (April 2026). Use it to map
+CCC-style / NIST-style / ISO-style requirement text to the checks that actually
+verify them. Always re-validate every check id against the current inventory
+(`assets/build_inventory.py` + `assets/query_checks.py`) before using a row —
+checks get renamed and added over time.
+
+**Entries containing `*` are glob patterns, NOT literal check ids** (e.g.
+`iam_*_no_administrative_privileges`, `cloudwatch_log_metric_filter_*`,
+`*_minimum_tls_version_12`). Copied verbatim into a compliance JSON they map
+nothing — expand each pattern to the concrete check ids via
+`python skills/prowler-compliance/assets/query_checks.py `
+before writing any mapping.
+
+| Requirement text | AWS checks | Azure checks | GCP checks |
+|---|---|---|---|
+| **TLS in transit enforced** | `cloudfront_distributions_https_enabled`, `s3_bucket_secure_transport_policy`, `elbv2_ssl_listeners`, `elbv2_insecure_ssl_ciphers`, `elb_ssl_listeners`, `elb_insecure_ssl_ciphers`, `opensearch_service_domains_https_communications_enforced`, `rds_instance_transport_encrypted`, `redshift_cluster_in_transit_encryption_enabled`, `elasticache_redis_cluster_in_transit_encryption_enabled`, `dynamodb_accelerator_cluster_in_transit_encryption_enabled`, `dms_endpoint_ssl_enabled`, `kafka_cluster_in_transit_encryption_enabled`, `transfer_server_in_transit_encryption_enabled`, `glue_database_connections_ssl_enabled`, `sns_subscription_not_using_http_endpoints` | `storage_secure_transfer_required_is_enabled`, `storage_ensure_minimum_tls_version_12`, `postgresql_flexible_server_enforce_ssl_enabled`, `mysql_flexible_server_ssl_connection_enabled`, `mysql_flexible_server_minimum_tls_version_12`, `sqlserver_recommended_minimal_tls_version`, `app_minimum_tls_version_12`, `app_ensure_http_is_redirected_to_https`, `app_ftp_deployment_disabled` | `cloudsql_instance_ssl_connections` (almost only option) |
+| **TLS 1.3 specifically** | Partial: `cloudfront_distributions_using_deprecated_ssl_protocols`, `elb*_insecure_ssl_ciphers`, `*_minimum_tls_version_12` | Partial: `*_minimum_tls_version_12` checks | None — accept as MANUAL |
+| **SSH / port 22 hardening** | `ec2_instance_port_ssh_exposed_to_internet`, `ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22`, `ec2_networkacl_allow_ingress_tcp_port_22` | `network_ssh_internet_access_restricted`, `vm_linux_enforce_ssh_authentication` | `compute_firewall_ssh_access_from_the_internet_allowed`, `compute_instance_block_project_wide_ssh_keys_disabled`, `compute_project_os_login_enabled`, `compute_project_os_login_2fa_enabled` |
+| **mTLS (mutual TLS)** | `kafka_cluster_mutual_tls_authentication_enabled`, `apigateway_restapi_client_certificate_enabled` | `app_client_certificates_on` | None — MANUAL |
+| **Data at rest encrypted** | `s3_bucket_default_encryption`, `s3_bucket_kms_encryption`, `ec2_ebs_default_encryption`, `ec2_ebs_volume_encryption`, `rds_instance_storage_encrypted`, `rds_cluster_storage_encrypted`, `rds_snapshots_encrypted`, `dynamodb_tables_kms_cmk_encryption_enabled`, `redshift_cluster_encrypted_at_rest`, `neptune_cluster_storage_encrypted`, `documentdb_cluster_storage_encrypted`, `opensearch_service_domains_encryption_at_rest_enabled`, `kinesis_stream_encrypted_at_rest`, `firehose_stream_encrypted_at_rest`, `sns_topics_kms_encryption_at_rest_enabled`, `sqs_queues_server_side_encryption_enabled`, `efs_encryption_at_rest_enabled`, `athena_workgroup_encryption`, `glue_data_catalogs_metadata_encryption_enabled`, `backup_vaults_encrypted`, `backup_recovery_point_encrypted`, `cloudtrail_kms_encryption_enabled`, `cloudwatch_log_group_kms_encryption_enabled`, `eks_cluster_kms_cmk_encryption_in_secrets_enabled`, `sagemaker_notebook_instance_encryption_enabled`, `apigateway_restapi_cache_encrypted`, `kafka_cluster_encryption_at_rest_uses_cmk`, `dynamodb_accelerator_cluster_encryption_enabled`, `storagegateway_fileshare_encryption_enabled` | `storage_infrastructure_encryption_is_enabled`, `storage_ensure_encryption_with_customer_managed_keys`, `vm_ensure_attached_disks_encrypted_with_cmk`, `vm_ensure_unattached_disks_encrypted_with_cmk`, `sqlserver_tde_encryption_enabled`, `sqlserver_tde_encrypted_with_cmk`, `databricks_workspace_cmk_encryption_enabled`, `monitor_storage_account_with_activity_logs_cmk_encrypted` | `compute_instance_encryption_with_csek_enabled`, `dataproc_encrypted_with_cmks_disabled`, `bigquery_dataset_cmk_encryption`, `bigquery_table_cmk_encryption` |
+| **CMEK required (customer-managed keys)** | `kms_cmk_are_used` | `storage_ensure_encryption_with_customer_managed_keys`, `vm_ensure_attached_disks_encrypted_with_cmk`, `vm_ensure_unattached_disks_encrypted_with_cmk`, `sqlserver_tde_encrypted_with_cmk`, `databricks_workspace_cmk_encryption_enabled` | `bigquery_dataset_cmk_encryption`, `bigquery_table_cmk_encryption`, `dataproc_encrypted_with_cmks_disabled`, `compute_instance_encryption_with_csek_enabled` |
+| **Key rotation enabled** | `kms_cmk_rotation_enabled` | `keyvault_key_rotation_enabled`, `storage_key_rotation_90_days` | `kms_key_rotation_enabled` |
+| **MFA for UI access** | `iam_root_mfa_enabled`, `iam_root_hardware_mfa_enabled`, `iam_user_mfa_enabled_console_access`, `iam_user_hardware_mfa_enabled`, `iam_administrator_access_with_mfa`, `cognito_user_pool_mfa_enabled` | `entra_privileged_user_has_mfa`, `entra_non_privileged_user_has_mfa`, `entra_user_with_vm_access_has_mfa`, `entra_security_defaults_enabled` | `compute_project_os_login_2fa_enabled` |
+| **API access / credentials** | `iam_no_root_access_key`, `iam_user_no_setup_initial_access_key`, `apigateway_restapi_authorizers_enabled`, `apigateway_restapi_public_with_authorizer`, `apigatewayv2_api_authorizers_enabled` | `entra_conditional_access_policy_require_mfa_for_management_api`, `app_function_access_keys_configured`, `app_function_identity_is_configured` | `apikeys_api_restrictions_configured`, `apikeys_key_exists`, `apikeys_key_rotated_in_90_days` |
+| **Log all admin/config changes** | `cloudtrail_multi_region_enabled`, `cloudtrail_multi_region_enabled_logging_management_events`, `cloudtrail_cloudwatch_logging_enabled`, `cloudtrail_log_file_validation_enabled`, `cloudwatch_log_metric_filter_*`, `cloudwatch_changes_to_*_alarm_configured`, `config_recorder_all_regions_enabled` | `monitor_diagnostic_settings_exists`, `monitor_diagnostic_setting_with_appropriate_categories`, `monitor_alert_*` | `iam_audit_logs_enabled`, `logging_log_metric_filter_and_alert_for_*`, `logging_sink_created` |
+| **Log integrity (digital signatures)** | `cloudtrail_log_file_validation_enabled` (exact) | None | None |
+| **Public access denied** | `s3_bucket_public_access`, `s3_bucket_public_list_acl`, `s3_bucket_public_write_acl`, `s3_account_level_public_access_blocks`, `apigateway_restapi_public`, `awslambda_function_url_public`, `awslambda_function_not_publicly_accessible`, `rds_instance_no_public_access`, `rds_snapshots_public_access`, `ec2_securitygroup_allow_ingress_from_internet_to_all_ports`, `sns_topics_not_publicly_accessible`, `sqs_queues_not_publicly_accessible` | `storage_blob_public_access_level_is_disabled`, `storage_ensure_private_endpoints_in_storage_accounts`, `containerregistry_not_publicly_accessible`, `keyvault_private_endpoints`, `app_function_not_publicly_accessible`, `aks_clusters_public_access_disabled`, `network_http_internet_access_restricted` | `cloudstorage_bucket_public_access`, `compute_instance_public_ip`, `cloudsql_instance_public_ip`, `compute_firewall_*_access_from_the_internet_allowed` |
+| **IAM least privilege** | `iam_*_no_administrative_privileges`, `iam_policy_allows_privilege_escalation`, `iam_inline_policy_allows_privilege_escalation`, `iam_role_administratoraccess_policy`, `iam_group_administrator_access_policy`, `iam_user_administrator_access_policy`, `iam_policy_attached_only_to_group_or_roles`, `iam_role_cross_service_confused_deputy_prevention` | `iam_role_user_access_admin_restricted`, `iam_subscription_roles_owner_custom_not_created`, `iam_custom_role_has_permissions_to_administer_resource_locks` | `iam_sa_no_administrative_privileges`, `iam_no_service_roles_at_project_level`, `iam_role_kms_enforce_separation_of_duties`, `iam_role_sa_enforce_separation_of_duties` |
+| **Password policy** | `iam_password_policy_minimum_length_14`, `iam_password_policy_uppercase`, `iam_password_policy_lowercase`, `iam_password_policy_symbol`, `iam_password_policy_number`, `iam_password_policy_expires_passwords_within_90_days_or_less`, `iam_password_policy_reuse_24` | None | None |
+| **Credential rotation / unused** | `iam_rotate_access_key_90_days`, `iam_user_accesskey_unused`, `iam_user_console_access_unused` | None | `iam_sa_user_managed_key_rotate_90_days`, `iam_sa_user_managed_key_unused`, `iam_service_account_unused` |
+| **VPC / flow logs** | `vpc_flow_logs_enabled` | `network_flow_log_captured_sent`, `network_watcher_enabled`, `network_flow_log_more_than_90_days` | `compute_subnet_flow_logs_enabled` |
+| **Backup / DR / Multi-AZ** | `backup_vaults_exist`, `backup_plans_exist`, `backup_reportplans_exist`, `rds_instance_backup_enabled`, `rds_*_protected_by_backup_plan`, `rds_cluster_multi_az`, `neptune_cluster_backup_enabled`, `documentdb_cluster_backup_enabled`, `efs_have_backup_enabled`, `s3_bucket_cross_region_replication`, `dynamodb_table_protected_by_backup_plan` | `vm_backup_enabled`, `vm_sufficient_daily_backup_retention_period`, `storage_geo_redundant_enabled` | `cloudsql_instance_automated_backups`, `cloudstorage_bucket_log_retention_policy_lock`, `cloudstorage_bucket_sufficient_retention_period` |
+| **Access analysis / discovery** | `accessanalyzer_enabled`, `accessanalyzer_enabled_without_findings` | None specific | `iam_account_access_approval_enabled`, `iam_cloud_asset_inventory_enabled` |
+| **Object lock / retention** | `s3_bucket_object_lock`, `s3_bucket_object_versioning`, `s3_bucket_lifecycle_enabled`, `cloudtrail_bucket_requires_mfa_delete`, `s3_bucket_no_mfa_delete` | `storage_ensure_soft_delete_is_enabled`, `storage_blob_versioning_is_enabled`, `storage_ensure_file_shares_soft_delete_is_enabled` | `cloudstorage_bucket_log_retention_policy_lock`, `cloudstorage_bucket_soft_delete_enabled`, `cloudstorage_bucket_versioning_enabled`, `cloudstorage_bucket_sufficient_retention_period` |
+| **Uniform bucket-level access** | `s3_bucket_acl_prohibited` | `storage_account_key_access_disabled`, `storage_default_to_entra_authorization_enabled` | `cloudstorage_bucket_uniform_bucket_level_access` |
+| **Container vulnerability scanning** | `ecr_registry_scan_images_on_push_enabled`, `ecr_repositories_scan_vulnerabilities_in_latest_image` | `defender_container_images_scan_enabled`, `defender_container_images_resolved_vulnerabilities` | `artifacts_container_analysis_enabled`, `gcr_container_scanning_enabled` |
+| **WAF / rate limiting** | `wafv2_webacl_with_rules`, `waf_*_webacl_with_rules`, `wafv2_webacl_logging_enabled`, `waf_global_webacl_logging_enabled` | None | None |
+| **Deployment region restriction** | `organizations_scp_check_deny_regions` | None | None |
+| **Secrets automatic rotation** | `secretsmanager_automatic_rotation_enabled`, `secretsmanager_secret_rotated_periodically` | `keyvault_rbac_secret_expiration_set`, `keyvault_non_rbac_secret_expiration_set` | None |
+| **Certificate management** | `acm_certificates_expiration_check`, `acm_certificates_with_secure_key_algorithms`, `acm_certificates_transparency_logs_enabled` | `keyvault_key_expiration_set_in_non_rbac`, `keyvault_rbac_key_expiration_set`, `keyvault_non_rbac_secret_expiration_set` | None |
+| **GenAI guardrails / input/output filtering** | `bedrock_guardrail_prompt_attack_filter_enabled`, `bedrock_guardrail_sensitive_information_filter_enabled`, `bedrock_agent_guardrail_enabled`, `bedrock_model_invocation_logging_enabled`, `bedrock_api_key_no_administrative_privileges`, `bedrock_api_key_no_long_term_credentials` | None | None |
+| **ML dev environment security** | `sagemaker_notebook_instance_root_access_disabled`, `sagemaker_notebook_instance_without_direct_internet_access_configured`, `sagemaker_notebook_instance_vpc_settings_configured`, `sagemaker_models_vpc_settings_configured`, `sagemaker_training_jobs_vpc_settings_configured`, `sagemaker_training_jobs_network_isolation_enabled`, `sagemaker_training_jobs_volume_and_output_encryption_enabled` | None | None |
+| **Threat detection / anomalous behavior** | `cloudtrail_threat_detection_enumeration`, `cloudtrail_threat_detection_privilege_escalation`, `cloudtrail_threat_detection_llm_jacking`, `guardduty_is_enabled`, `guardduty_no_high_severity_findings` | None | None |
+| **Serverless private access** | `awslambda_function_inside_vpc`, `awslambda_function_not_publicly_accessible`, `awslambda_function_url_public` | `app_function_not_publicly_accessible` | None |
+
+## What Prowler Does NOT Cover (accept MANUAL honestly)
+
+Don't pad mappings for these — mark the requirement's checks empty and move on:
+
+- **TLS 1.3 version specifically** — Prowler verifies TLS is enforced, not always the exact version
+- **IANA port-protocol consistency** — no check for "protocol running on its assigned port"
+- **mTLS on most Azure/GCP services** — limited to App Service client certs on Azure, nothing on GCP
+- **Rate limiting** on monitoring endpoints, load balancers, serverless invocations, vector ingestion
+- **Session cookie expiry** (LB stickiness)
+- **HTTP header scrubbing** (Server, X-Powered-By)
+- **Certificate transparency verification for imports**
+- **Model version pinning, red teaming, AI quality review**
+- **Vector embedding validation, dimensional constraints, ANN vs exact search**
+- **Secret region replication** (cross-region residency)
+- **Lifecycle cleanup policies on container registries**
+- **Row-level / column-level security in data warehouses**
+- **Deployment region restriction on Azure/GCP** (AWS has `organizations_scp_check_deny_regions`, others don't)
+- **Cross-tenant alert silencing permissions**
+- **Field-level masking in logs**
+- **Managed view enforcement for database access**
+- **Automatic MFA delete on all S3 buckets** (only CloudTrail bucket variant exists for some frameworks — AWS has the generic `s3_bucket_no_mfa_delete` though)
+
+## Provider coverage asymmetry
+
+AWS has dense coverage (in-transit encryption, IAM, database encryption, backup,
+GenAI). Azure and GCP are thinner, especially for in-transit encryption, mTLS,
+and ML/AI. Accept the asymmetry in mappings — don't force GCP parity where
+Prowler genuinely can't verify. Newer providers (alibabacloud, oraclecloud,
+googleworkspace, okta, cloudflare, linode...) have far smaller inventories:
+always rebuild the inventory with `assets/build_inventory.py` before assuming
+a mapping exists.
diff --git a/skills/prowler-compliance/references/compliance-docs.md b/skills/prowler-compliance/references/compliance-docs.md
index a8d11484a9..272aa10ef1 100644
--- a/skills/prowler-compliance/references/compliance-docs.md
+++ b/skills/prowler-compliance/references/compliance-docs.md
@@ -1,137 +1,154 @@
-# Compliance Framework Documentation
+# Compliance Framework Quick Reference
## Code References
-Key files for understanding and modifying compliance frameworks:
-
| File | Purpose |
|------|---------|
-| `prowler/lib/check/compliance_models.py` | Pydantic models defining attribute structures for each framework type |
-| `prowler/lib/check/compliance.py` | Core compliance processing logic |
-| `prowler/lib/check/utils.py` | Utility functions including `list_compliance_modules()` |
-| `prowler/lib/outputs/compliance/` | Framework-specific output generators |
-| `prowler/compliance/{provider}/` | JSON compliance framework definitions |
+| `prowler/lib/check/compliance_models.py` | Legacy + universal Pydantic (v1) model trees, config-constraint model, loaders, legacy→universal adapter |
+| `prowler/lib/check/compliance.py` | `update_checks_metadata_with_compliance()` (only) |
+| `prowler/lib/check/compliance_config_eval.py` | Shared `ConfigRequirements` guardrail evaluation (SDK outputs + API) |
+| `prowler/lib/outputs/compliance/compliance_check.py` | `get_check_compliance()` — per-finding `{Framework}-{Version}` → requirement ids |
+| `prowler/lib/check/utils.py` | `list_compliance_modules()` |
+| `prowler/lib/outputs/compliance/` | Output formatters (legacy per-framework + `universal/`) |
+| `prowler/compliance/*.json` | Universal multi-provider framework definitions |
+| `prowler/compliance/{provider}/` | Legacy per-provider framework definitions |
-## Attribute Model Classes
+## Attribute Model Classes (legacy schema)
-Each framework type has a specific Pydantic model in `compliance_models.py`:
+Registered in the `Compliance_Requirement.Attributes` Union, in this order
+(order is load-bearing; Generic must stay last):
-| Framework | Model Class |
+| Framework family | Model Class |
|-----------|-------------|
+| ASD Essential Eight | `ASDEssentialEight_Requirement_Attribute` |
| CIS | `CIS_Requirement_Attribute` |
-| ISO 27001 | `ISO27001_2013_Requirement_Attribute` |
| ENS | `ENS_Requirement_Attribute` |
-| MITRE ATT&CK | `Mitre_Requirement` (uses different structure) |
+| ISO 27001 | `ISO27001_2013_Requirement_Attribute` |
| AWS Well-Architected | `AWS_Well_Architected_Requirement_Attribute` |
| KISA ISMS-P | `KISA_ISMSP_Requirement_Attribute` |
| Prowler ThreatScore | `Prowler_ThreatScore_Requirement_Attribute` |
| CCC | `CCC_Requirement_Attribute` |
| C5 Germany | `C5Germany_Requirement_Attribute` |
-| Generic/Fallback | `Generic_Compliance_Requirement_Attribute` |
+| CSA CCM (legacy shape) | `CSA_CCM_Requirement_Attribute` |
+| DISA STIG (Okta IDaaS) | `STIG_Requirement_Attribute` |
+| Generic/Fallback (NIST, PCI, GDPR, HIPAA, SOC2, FedRAMP, ...) | `Generic_Compliance_Requirement_Attribute` |
-## How Compliance Frameworks are Loaded
+MITRE ATT&CK uses the separate `Mitre_Requirement` model with per-provider
+`Mitre_Requirement_Attribute_{AWS,Azure,GCP}` attribute classes.
-1. `Compliance.get_bulk(provider)` is called at startup
-2. Scans `prowler/compliance/{provider}/` for `.json` files
-3. Each file is parsed using `load_compliance_framework()`
-4. Pydantic validates against `Compliance` model
-5. Framework is stored in dictionary with filename (without `.json`) as key
+`Compliance_Requirement_ConfigConstraint` models each `ConfigRequirements` /
+`config_requirements` entry (`Check`, `ConfigKey`, `Operator`, `Value`,
+optional `Provider`) with load-time operator/value type validation.
+
+## Universal Schema Models
+
+| Model | Purpose |
+|-------|---------|
+| `ComplianceFramework` | Top-level container (`framework`, `name`, `version`, `requirements`, `attributes_metadata`, `outputs`); validates attributes against metadata at load |
+| `UniversalComplianceRequirement` | Flat `attributes: dict`, `checks: dict[provider, list]`, `config_requirements`, MITRE extras |
+| `AttributeMetadata` | Per-attribute schema descriptor (key/label/type/enum/required/`enum_display`/`enum_order`/`output_formats`) |
+| `OutputsConfig` → `TableConfig` | CLI table rendering (`group_by`, `split_by`, `scoring`, `labels`) — consumed by `universal_table.py` |
+| `OutputsConfig` → `PDFConfig` (+ `ChartConfig`, `ScoringFormula`, `I18nLabels`, ...) | Declarative PDF config — modeled but **not yet consumed** by the API PDF pipeline (it uses its own `FRAMEWORK_REGISTRY`) |
+
+## How Frameworks Are Loaded
+
+Two entry points — they see different files:
+
+1. **Legacy**: `Compliance.get_bulk(provider)` scans only
+ `prowler/compliance/{provider}/` (exact provider-segment match) plus
+ external JSONs from the `prowler.compliance` entry-point group. Invalid
+ built-in file → `logger.critical` + `sys.exit(1)`
+ (`load_compliance_framework`, `fatal=True`).
+2. **Universal**: `get_bulk_compliance_frameworks_universal(provider)` scans
+ the top-level `prowler/compliance/` **and** every provider subdirectory,
+ plus the `prowler.compliance.universal` entry-point group (built-ins win
+ collisions). Legacy files are adapted via `adapt_legacy_to_universal()`
+ (flattens `Attributes[0]` into a dict, wraps `Checks` as
+ `{provider: [...]}`, infers `attributes_metadata` from the matched Pydantic
+ class). Invalid file → logged and **skipped**
+ (`load_compliance_framework_universal` returns `None`).
+
+The framework key in both bulk dicts is the JSON basename without `.json` —
+that's also the `--compliance` CLI key.
## How Checks Map to Compliance
-1. After loading, `update_checks_metadata_with_compliance()` is called
-2. For each check, it finds all compliance requirements that reference it
-3. Compliance info is attached to `CheckMetadata.Compliance` list
-4. During output, `get_check_compliance()` retrieves mappings per finding
+1. `update_checks_metadata_with_compliance()` attaches, per check, every
+ framework requirement that references it (`CheckMetadata.Compliance`).
+2. During output, `get_check_compliance()`
+ (`prowler/lib/outputs/compliance/compliance_check.py`) returns the
+ per-finding dict `{"{Framework}-{Version}": [requirement_ids]}` — the
+ `-{Version}` suffix only exists when `Version` is non-empty.
+3. `ConfigRequirements` guardrails are evaluated by
+ `evaluate_config_constraints()` (`compliance_config_eval.py`); a violated
+ constraint forces FAIL and prepends
+ `Configuration not valid for this requirement.` to `status_extended` in
+ every output format.
-## File Naming Convention
+## File Naming Conventions
```text
-{framework}_{version}_{provider}.json
+prowler/compliance/{framework}_{version}.json # universal
+prowler/compliance/{provider}/{framework}_{version}_{provider}.json # legacy
```
-Examples:
-- `cis_5.0_aws.json`
-- `iso27001_2022_azure.json`
-- `mitre_attack_gcp.json`
-- `ens_rd2022_aws.json`
-- `nist_800_53_revision_5_aws.json`
+Examples: `dora_2022_2554.json`, `cis_controls_8.1.json`, `cis_7.0_aws.json`,
+`iso27001_2022_azure.json`, `okta_idaas_stig_v1r2_okta.json`,
+`cisa_scuba_0.6_googleworkspace.json`, `ccc_aws.json` (unversioned only when
+the framework has no versioning). For legacy files the version substring in
+the filename must equal `Version`.
-## Validation
+## Validation Summary
-Prowler validates compliance JSON at startup. Invalid files cause:
-- `ValidationError` logged with details
-- Application exit with error code
+- **Load time (universal)**: `attributes_metadata` root validator — required
+ keys, unknown-key drift guard, enums, int/float/bool types. Omit the
+ metadata and nothing is validated.
+- **Load time (legacy)**: Pydantic attribute-class matching; a shape matching
+ no specific class silently falls through to Generic.
+- **Never validated at load**: check-id existence. Cross-check manually
+ (see SKILL.md → Validation).
+- **Test suite**: `tests/lib/check/universal_compliance_models_test.py::test_loads_as_universal`
+ is parametrized over every shipped JSON (top-level + per-provider).
+- **CI**: `.github/workflows/pr-check-compliance-mapping.yml` flags new checks
+ not mapped in any framework (`needs-compliance-review` label; opt out with
+ `no-compliance-check`).
+- **Pre-commit**: `check-json` + `pretty-format-json` only (syntax/format, no
+ semantics).
+- **Manual**: `skills/prowler-compliance-review/assets/validate_compliance.py`
+ (legacy schema only).
-Common validation errors:
-- Missing required fields (`Id`, `Description`, `Checks`, `Attributes`)
-- Invalid enum values (e.g., `Profile` must be "Level 1" or "Level 2" for CIS)
-- Type mismatches (e.g., `Checks` must be array of strings)
+## Repo Tooling (`util/compliance/`)
-## Adding a New Framework
-
-1. Create JSON file in `prowler/compliance/{provider}/`
-2. Use appropriate attribute model (see table above)
-3. Map existing checks to requirements via `Checks` array
-4. Use empty `Checks: []` for manual-only requirements
-5. Test with `prowler {provider} --list-compliance` to verify loading
-6. Run `prowler {provider} --compliance {framework_name}` to test execution
-
-## Templates
-
-See `assets/` directory for example templates:
-- `cis_framework.json` - CIS Benchmark template
-- `iso27001_framework.json` - ISO 27001 template
-- `ens_framework.json` - ENS (Spain) template
-- `mitre_attack_framework.json` - MITRE ATT&CK template
-- `prowler_threatscore_framework.json` - Prowler ThreatScore template
-- `generic_framework.json` - Generic/custom framework template
+| Tool | Purpose |
+|------|---------|
+| `util/compliance/generate_json_from_csv/*.py` | CSV→JSON generators (CIS 1.5, CIS 2.0 GCP, CIS 1.0 GitHub, CIS 4.0 M365, ENS, ThreatScore) |
+| `util/compliance/ccc/from_yaml_to_json.py` | FINOS CCC YAML→JSON converter |
+| `util/compliance/compliance_mapper/` | Compliance mapper (see its README) |
+| `util/compliance/threatscore/get_prowler_threatscore_from_generic_output.py` | Derive ThreatScore from generic output |
## Prowler ThreatScore Details
-Prowler ThreatScore is a custom security scoring framework that calculates an overall security posture score based on:
+Custom Prowler scoring framework. Pillars / ID prefixes: `1.x.x` IAM, `2.x.x`
+Attack Surface, `3.x.x` Logging and Monitoring, `4.x.x` Encryption.
-### Four Pillars
-1. **IAM (Identity and Access Management)**
- - SubSections: Authentication, Authorization, Credentials Management
-
-2. **Attack Surface**
- - SubSections: Network Exposure, Storage Exposure, Service Exposure
-
-3. **Logging and Monitoring**
- - SubSections: Audit Logging, Threat Detection, Alerting
-
-4. **Encryption**
- - SubSections: Data at Rest, Data in Transit
-
-### Scoring Algorithm
-The ThreatScore uses `LevelOfRisk` and `Weight` to calculate severity:
-
-| LevelOfRisk | Weight | Example Controls |
-|-------------|--------|------------------|
-| 5 (Critical) | 1000 | Root MFA, No root access keys, Public S3 buckets |
-| 4 (High) | 100 | User MFA, Public EC2, GuardDuty enabled |
-| 3 (Medium) | 10 | Password policies, EBS encryption, CloudTrail |
-| 2 (Low) | 1-10 | Best practice recommendations |
-| 1 (Info) | 1 | Informational controls |
-
-### ID Numbering Convention
-- `1.x.x` - IAM controls
-- `2.x.x` - Attack Surface controls
-- `3.x.x` - Logging and Monitoring controls
-- `4.x.x` - Encryption controls
+Scoring: `LevelOfRisk` 1–5 (5=critical) × `Weight` (values in the shipped
+catalogs: 1000 critical / 100 high / 8–10 standard / 1 low). Available for
+aws, azure, gcp, kubernetes, m365, alibabacloud.
## External Resources
-### Official Framework Documentation
- [CIS Benchmarks](https://www.cisecurity.org/cis-benchmarks)
-- [ISO 27001:2022](https://www.iso.org/standard/27001)
+- [CIS Critical Security Controls](https://www.cisecurity.org/controls)
+- [ISO 27001](https://www.iso.org/standard/27001)
- [NIST 800-53](https://csrc.nist.gov/publications/detail/sp/800-53/rev-5/final)
- [NIST CSF](https://www.nist.gov/cyberframework)
- [PCI DSS](https://www.pcisecuritystandards.org/)
- [MITRE ATT&CK](https://attack.mitre.org/)
- [ENS (Spain)](https://www.ccn-cert.cni.es/es/ens.html)
-
-### Prowler Documentation
-- [Prowler Docs - Compliance](https://docs.prowler.com/projects/prowler-open-source/en/latest/)
-- [Prowler GitHub](https://github.com/prowler-cloud/prowler)
+- [FINOS CCC](https://github.com/finos/common-cloud-controls)
+- [CSA CCM](https://cloudsecurityalliance.org/research/cloud-controls-matrix)
+- [DORA (EU 2022/2554)](https://eur-lex.europa.eu/eli/reg/2022/2554/oj)
+- [ASD Essential Eight](https://www.cyber.gov.au/resources-business-and-government/essential-cybersecurity/essential-eight)
+- [CISA SCuBA](https://www.cisa.gov/resources-tools/services/secure-cloud-business-applications-scuba-project)
+- [DISA STIGs](https://public.cyber.mil/stigs/)
+- [Prowler Docs — Compliance developer guide](https://docs.prowler.com/developer-guide/security-compliance-framework)
diff --git a/tests/config/config_test.py b/tests/config/config_test.py
index d10c384b0d..fbff8ade29 100644
--- a/tests/config/config_test.py
+++ b/tests/config/config_test.py
@@ -35,6 +35,7 @@ old_config_aws = {
"shodan_api_key": None,
"max_security_group_rules": 50,
"max_ec2_instance_age_in_days": 180,
+ "max_ec2_instance_stopped_days": 30,
"ec2_allowed_interface_types": ["api_gateway_managed", "vpc_endpoint"],
"ec2_allowed_instance_owners": ["amazon-elb"],
"trusted_account_ids": [],
@@ -86,6 +87,7 @@ config_aws = {
"shodan_api_key": None,
"max_security_group_rules": 50,
"max_ec2_instance_age_in_days": 180,
+ "max_ec2_instance_stopped_days": 30,
"ec2_allowed_interface_types": ["api_gateway_managed", "vpc_endpoint"],
"ec2_allowed_instance_owners": ["amazon-elb"],
"ec2_high_risk_ports": [
diff --git a/tests/config/fixtures/config.yaml b/tests/config/fixtures/config.yaml
index df663e923a..a64e497544 100644
--- a/tests/config/fixtures/config.yaml
+++ b/tests/config/fixtures/config.yaml
@@ -31,6 +31,8 @@ aws:
max_security_group_rules: 50
# aws.ec2_instance_older_than_specific_days --> by default is 6 months (180 days)
max_ec2_instance_age_in_days: 180
+ # aws.ec2_instance_stopped_older_than_specific_days --> by default is 30 days
+ max_ec2_instance_stopped_days: 30
# aws.ec2_securitygroup_allow_ingress_from_internet_to_any_port
# allowed network interface types for security groups open to the Internet
ec2_allowed_interface_types:
diff --git a/tests/config/fixtures/config_old.yaml b/tests/config/fixtures/config_old.yaml
index 33220e1246..88797cdbc2 100644
--- a/tests/config/fixtures/config_old.yaml
+++ b/tests/config/fixtures/config_old.yaml
@@ -5,6 +5,8 @@ shodan_api_key: null
max_security_group_rules: 50
# aws.ec2_instance_older_than_specific_days --> by default is 6 months (180 days)
max_ec2_instance_age_in_days: 180
+# aws.ec2_instance_stopped_older_than_specific_days --> by default is 30 days
+max_ec2_instance_stopped_days: 30
# aws.ec2_securitygroup_allow_ingress_from_internet_to_any_port
# allowed network interface types for security groups open to the Internet
ec2_allowed_interface_types:
diff --git a/tests/config/schema/bounds_test.py b/tests/config/schema/bounds_test.py
index 4d8d49bab2..6f61913b73 100644
--- a/tests/config/schema/bounds_test.py
+++ b/tests/config/schema/bounds_test.py
@@ -27,6 +27,7 @@ INT_BOUND_CASES = [
("aws", "max_unused_sagemaker_access_days", 7, 180),
("aws", "max_security_group_rules", 1, 1000),
("aws", "max_ec2_instance_age_in_days", 1, 1095),
+ ("aws", "max_ec2_instance_stopped_days", 1, 1095),
("aws", "recommended_cdk_bootstrap_version", 1, 100),
("aws", "max_idle_disconnect_timeout_in_seconds", 60, 1800),
("aws", "max_disconnect_timeout_in_seconds", 60, 3600),
diff --git a/tests/lib/cli/parser_test.py b/tests/lib/cli/parser_test.py
index 549ca17727..da16f437b5 100644
--- a/tests/lib/cli/parser_test.py
+++ b/tests/lib/cli/parser_test.py
@@ -17,7 +17,7 @@ prowler_command = "prowler"
# capsys
# https://docs.pytest.org/en/7.1.x/how-to/capture-stdout-stderr.html
-prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,linode,e2enetworks,dashboard,iac,image,llm} ..."
+prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,linode,huaweicloud,e2enetworks,dashboard,iac,image,llm} ..."
def mock_get_available_providers():
diff --git a/tests/lib/outputs/ocsf/ocsf_test.py b/tests/lib/outputs/ocsf/ocsf_test.py
index f449fd49f7..cd1ba289ea 100644
--- a/tests/lib/outputs/ocsf/ocsf_test.py
+++ b/tests/lib/outputs/ocsf/ocsf_test.py
@@ -16,9 +16,11 @@ from py_ocsf_models.events.findings.detection_finding import (
)
from py_ocsf_models.events.findings.finding import ActivityID, FindingInformation
from py_ocsf_models.objects.account import Account, TypeID
+from py_ocsf_models.objects.analytic import Analytic
from py_ocsf_models.objects.cloud import Cloud
from py_ocsf_models.objects.group import Group
from py_ocsf_models.objects.metadata import Metadata
+from py_ocsf_models.objects.mitre_attack import MITREAttack
from py_ocsf_models.objects.organization import Organization
from py_ocsf_models.objects.product import Product
from py_ocsf_models.objects.remediation import Remediation
@@ -26,6 +28,11 @@ from py_ocsf_models.objects.resource_details import ResourceDetails
from pydantic.v1 import BaseModel as V1BaseModel
from prowler.config.config import prowler_version
+from prowler.lib.check.compliance_models import (
+ Compliance,
+ Mitre_Requirement,
+ Mitre_Requirement_Attribute_AWS,
+)
from prowler.lib.outputs.ocsf.ocsf import OCSF
from tests.lib.outputs.fixtures.fixtures import generate_finding_output
from tests.providers.aws.utils import AWS_REGION_EU_WEST_1
@@ -106,6 +113,18 @@ class TestOCSF:
output_data.type_name
== f"Detection Finding: {DetectionFindingTypeID.Create.name}"
)
+ # analytic field describes the Prowler check (rule) that generated the finding
+ assert isinstance(output_data.finding_info.analytic, Analytic)
+ assert output_data.finding_info.analytic.name == findings[0].metadata.CheckTitle
+ assert output_data.finding_info.analytic.uid == findings[0].metadata.CheckID
+ assert output_data.finding_info.analytic.type_id == 1
+ assert output_data.finding_info.analytic.type == "Rule"
+ assert (
+ output_data.finding_info.analytic.category
+ == findings[0].metadata.ServiceName
+ )
+ # no MITRE data in default fixture compliance
+ assert output_data.finding_info.attacks is None
unmapped = output_data.unmapped
scan_id = unmapped.pop("scan_id")
assert UUID(scan_id) # Valid UUID
@@ -129,6 +148,89 @@ class TestOCSF:
1619600000, tz=timezone.utc
)
+ def test_transform_mitre_attacks_populated(self):
+ finding = generate_finding_output(
+ provider="aws",
+ compliance={"MITRE-ATTACK": ["T4242"]},
+ check_id="iam_user_mfa_enabled_console_access",
+ check_title="IAM users with console access have MFA enabled",
+ service_name="iam",
+ )
+ finding.metadata.Compliance = [
+ Compliance(
+ Framework="MITRE-ATTACK",
+ Name="MITRE ATT&CK compliance framework",
+ Provider="AWS",
+ Version="",
+ Description="MITRE ATT&CK test framework",
+ Requirements=[
+ Mitre_Requirement(
+ Name="Synthetic Valid Accounts",
+ Id="T4242",
+ Tactics=["Persistence", "Privilege Escalation"],
+ SubTechniques=[],
+ Description="Synthetic MITRE technique for OCSF tests.",
+ Platforms=["IaaS"],
+ TechniqueURL="https://attack.mitre.org/techniques/T4242/",
+ Attributes=[
+ Mitre_Requirement_Attribute_AWS(
+ AWSService="AWS IAM",
+ Category="Protect",
+ Value="Significant",
+ Comment="Test mapping",
+ )
+ ],
+ Checks=["iam_user_mfa_enabled_console_access"],
+ )
+ ],
+ )
+ ]
+
+ ocsf = OCSF([finding])
+ output_data = ocsf.data[0]
+
+ assert output_data.finding_info.attacks is not None
+ assert len(output_data.finding_info.attacks) == 2
+ attack = output_data.finding_info.attacks[0]
+ assert isinstance(attack, MITREAttack)
+ assert attack.technique.uid == "T4242"
+ assert attack.technique.name == "Synthetic Valid Accounts"
+ assert attack.technique.src_url == "https://attack.mitre.org/techniques/T4242/"
+ assert attack.tactic is not None
+ assert [attack.tactic.name for attack in output_data.finding_info.attacks] == [
+ "Persistence",
+ "Privilege Escalation",
+ ]
+
+ def test_transform_mitre_attacks_unknown_technique(self):
+ finding = generate_finding_output(
+ provider="aws",
+ compliance={"MITRE-ATTACK": ["T9999"]},
+ )
+ finding.metadata.Compliance = [
+ Compliance(
+ Framework="MITRE-ATTACK",
+ Name="MITRE ATT&CK compliance framework",
+ Provider="AWS",
+ Version="",
+ Description="MITRE ATT&CK test framework",
+ Requirements=[],
+ )
+ ]
+
+ ocsf = OCSF([finding])
+ assert ocsf.data[0].finding_info.attacks is None
+
+ def test_transform_mitre_attacks_without_mitre_metadata(self):
+ finding = generate_finding_output(
+ provider="kubernetes",
+ compliance={"MITRE-ATTACK": ["T1078"]},
+ check_type=[],
+ )
+
+ ocsf = OCSF([finding])
+ assert ocsf.data[0].finding_info.attacks is None
+
def test_scan_id_is_unique_per_provider_and_account(self):
findings = [
generate_finding_output(provider="aws", account_uid="111111111111"),
@@ -231,6 +333,13 @@ class TestOCSF:
"activity_name": "Create",
"activity_id": 1,
"finding_info": {
+ "analytic": {
+ "name": "service_test_check_id",
+ "uid": "service_test_check_id",
+ "type_id": 1,
+ "type": "Rule",
+ "category": "service",
+ },
"created_time": int(datetime.now().timestamp()),
"created_time_dt": datetime.now().isoformat(),
"desc": "check description",
diff --git a/tests/providers/aws/services/ec2/ec2_instance_stopped_older_than_specific_days/ec2_instance_stopped_older_than_specific_days_test.py b/tests/providers/aws/services/ec2/ec2_instance_stopped_older_than_specific_days/ec2_instance_stopped_older_than_specific_days_test.py
new file mode 100644
index 0000000000..01926f52d3
--- /dev/null
+++ b/tests/providers/aws/services/ec2/ec2_instance_stopped_older_than_specific_days/ec2_instance_stopped_older_than_specific_days_test.py
@@ -0,0 +1,252 @@
+from datetime import datetime, timedelta, timezone
+from re import search
+from unittest import mock
+
+from boto3 import resource
+from moto import mock_aws
+
+from tests.providers.aws.utils import (
+ AWS_REGION_EU_WEST_1,
+ AWS_REGION_US_EAST_1,
+ set_mocked_aws_provider,
+)
+
+EXAMPLE_AMI_ID = "ami-12c6146b"
+
+
+class Test_ec2_instance_stopped_older_than_specific_days:
+ @mock_aws
+ def test_ec2_no_instances(self):
+ from prowler.providers.aws.services.ec2.ec2_service import EC2
+
+ aws_provider = set_mocked_aws_provider(
+ [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
+ )
+ aws_provider._audit_config = {"max_ec2_instance_stopped_days": 30}
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=aws_provider,
+ ),
+ mock.patch(
+ "prowler.providers.aws.services.ec2.ec2_instance_stopped_older_than_specific_days.ec2_instance_stopped_older_than_specific_days.ec2_client",
+ new=EC2(aws_provider),
+ ),
+ ):
+ from prowler.providers.aws.services.ec2.ec2_instance_stopped_older_than_specific_days.ec2_instance_stopped_older_than_specific_days import (
+ ec2_instance_stopped_older_than_specific_days,
+ )
+
+ check = ec2_instance_stopped_older_than_specific_days()
+ result = check.execute()
+
+ assert len(result) == 0
+
+ @mock_aws
+ def test_running_ec2(self):
+ ec2 = resource("ec2", region_name=AWS_REGION_US_EAST_1)
+ instance = ec2.create_instances(
+ ImageId=EXAMPLE_AMI_ID,
+ MinCount=1,
+ MaxCount=1,
+ UserData="This is some user_data",
+ )[0]
+
+ from prowler.providers.aws.services.ec2.ec2_service import EC2
+
+ aws_provider = set_mocked_aws_provider(
+ [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
+ )
+ aws_provider._audit_config = {"max_ec2_instance_stopped_days": 30}
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=aws_provider,
+ ),
+ mock.patch(
+ "prowler.providers.aws.services.ec2.ec2_instance_stopped_older_than_specific_days.ec2_instance_stopped_older_than_specific_days.ec2_client",
+ new=EC2(aws_provider),
+ ),
+ ):
+ from prowler.providers.aws.services.ec2.ec2_instance_stopped_older_than_specific_days.ec2_instance_stopped_older_than_specific_days import (
+ ec2_instance_stopped_older_than_specific_days,
+ )
+
+ check = ec2_instance_stopped_older_than_specific_days()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert result[0].region == AWS_REGION_US_EAST_1
+ assert result[0].resource_tags is None
+ assert search(
+ f"EC2 Instance {instance.id} is not stopped",
+ result[0].status_extended,
+ )
+ assert result[0].resource_id == instance.id
+ assert (
+ result[0].resource_arn
+ == f"arn:{aws_provider.identity.partition}:ec2:{AWS_REGION_US_EAST_1}:{aws_provider.identity.account}:instance/{instance.id}"
+ )
+
+ @mock_aws
+ def test_stopped_ec2_within_threshold(self):
+ ec2 = resource("ec2", region_name=AWS_REGION_US_EAST_1)
+ instance = ec2.create_instances(
+ ImageId=EXAMPLE_AMI_ID,
+ MinCount=1,
+ MaxCount=1,
+ UserData="This is some user_data",
+ )[0]
+
+ from prowler.providers.aws.services.ec2.ec2_service import EC2
+
+ aws_provider = set_mocked_aws_provider(
+ [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
+ )
+ aws_provider._audit_config = {"max_ec2_instance_stopped_days": 30}
+
+ # Boundary: stopped exactly 30 days ago must remain PASS
+ # (threshold is exclusive: days_stopped > max_ec2_instance_stopped_days).
+ fixed_now = datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)
+ recent_stop = fixed_now - timedelta(days=30)
+ stop_reason = recent_stop.strftime("User initiated (%Y-%m-%d %H:%M:%S GMT)")
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=aws_provider,
+ ),
+ mock.patch(
+ "prowler.providers.aws.services.ec2.ec2_instance_stopped_older_than_specific_days.ec2_instance_stopped_older_than_specific_days.ec2_client",
+ new=EC2(aws_provider),
+ ) as service_client,
+ mock.patch(
+ "prowler.providers.aws.services.ec2.ec2_instance_stopped_older_than_specific_days.ec2_instance_stopped_older_than_specific_days.datetime"
+ ) as mock_datetime,
+ ):
+ from prowler.providers.aws.services.ec2.ec2_instance_stopped_older_than_specific_days.ec2_instance_stopped_older_than_specific_days import (
+ ec2_instance_stopped_older_than_specific_days,
+ )
+
+ mock_datetime.now.return_value = fixed_now
+ mock_datetime.strptime = datetime.strptime
+
+ service_client.instances[0].state = "stopped"
+ service_client.instances[0].state_transition_reason = stop_reason
+
+ check = ec2_instance_stopped_older_than_specific_days()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert result[0].region == AWS_REGION_US_EAST_1
+ assert search(
+ f"EC2 Instance {instance.id} has not been stopped longer than",
+ result[0].status_extended,
+ )
+ assert result[0].resource_id == instance.id
+ assert (
+ result[0].resource_arn
+ == f"arn:{aws_provider.identity.partition}:ec2:{AWS_REGION_US_EAST_1}:{aws_provider.identity.account}:instance/{instance.id}"
+ )
+
+ @mock_aws
+ def test_stopped_ec2_older_than_threshold(self):
+ ec2 = resource("ec2", region_name=AWS_REGION_US_EAST_1)
+ instance = ec2.create_instances(
+ ImageId=EXAMPLE_AMI_ID,
+ MinCount=1,
+ MaxCount=1,
+ UserData="This is some user_data",
+ )[0]
+
+ from prowler.providers.aws.services.ec2.ec2_service import EC2
+
+ aws_provider = set_mocked_aws_provider(
+ [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
+ )
+ aws_provider._audit_config = {"max_ec2_instance_stopped_days": 30}
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=aws_provider,
+ ),
+ mock.patch(
+ "prowler.providers.aws.services.ec2.ec2_instance_stopped_older_than_specific_days.ec2_instance_stopped_older_than_specific_days.ec2_client",
+ new=EC2(aws_provider),
+ ) as service_client,
+ ):
+ from prowler.providers.aws.services.ec2.ec2_instance_stopped_older_than_specific_days.ec2_instance_stopped_older_than_specific_days import (
+ ec2_instance_stopped_older_than_specific_days,
+ )
+
+ service_client.instances[0].state = "stopped"
+ service_client.instances[0].state_transition_reason = (
+ "User initiated (2021-11-01 17:18:00 GMT)"
+ )
+
+ check = ec2_instance_stopped_older_than_specific_days()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert result[0].region == AWS_REGION_US_EAST_1
+ assert search(
+ f"EC2 Instance {instance.id} has been stopped longer than",
+ result[0].status_extended,
+ )
+ assert result[0].resource_id == instance.id
+ assert (
+ result[0].resource_arn
+ == f"arn:{aws_provider.identity.partition}:ec2:{AWS_REGION_US_EAST_1}:{aws_provider.identity.account}:instance/{instance.id}"
+ )
+
+ @mock_aws
+ def test_stopped_ec2_unknown_stop_time(self):
+ ec2 = resource("ec2", region_name=AWS_REGION_US_EAST_1)
+ instance = ec2.create_instances(
+ ImageId=EXAMPLE_AMI_ID,
+ MinCount=1,
+ MaxCount=1,
+ UserData="This is some user_data",
+ )[0]
+
+ from prowler.providers.aws.services.ec2.ec2_service import EC2
+
+ aws_provider = set_mocked_aws_provider(
+ [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
+ )
+ aws_provider._audit_config = {"max_ec2_instance_stopped_days": 30}
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=aws_provider,
+ ),
+ mock.patch(
+ "prowler.providers.aws.services.ec2.ec2_instance_stopped_older_than_specific_days.ec2_instance_stopped_older_than_specific_days.ec2_client",
+ new=EC2(aws_provider),
+ ) as service_client,
+ ):
+ from prowler.providers.aws.services.ec2.ec2_instance_stopped_older_than_specific_days.ec2_instance_stopped_older_than_specific_days import (
+ ec2_instance_stopped_older_than_specific_days,
+ )
+
+ service_client.instances[0].state = "stopped"
+ service_client.instances[0].state_transition_reason = ""
+
+ check = ec2_instance_stopped_older_than_specific_days()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert result[0].region == AWS_REGION_US_EAST_1
+ assert search(
+ f"EC2 Instance {instance.id} is stopped but stop time could not be determined",
+ result[0].status_extended,
+ )
+ assert result[0].resource_id == instance.id
diff --git a/tests/providers/aws/services/ec2/ec2_service_test.py b/tests/providers/aws/services/ec2/ec2_service_test.py
index 3fe154b536..5cb5a15950 100644
--- a/tests/providers/aws/services/ec2/ec2_service_test.py
+++ b/tests/providers/aws/services/ec2/ec2_service_test.py
@@ -26,6 +26,7 @@ from tests.providers.aws.utils import (
EXAMPLE_AMI_ID = "ami-12c6146b"
MOCK_DATETIME = datetime(2023, 1, 4, 7, 27, 30, tzinfo=tzutc())
+MOCK_STATE_TRANSITION_REASON = "User initiated (2021-11-01 17:18:00 GMT)"
make_api_call = botocore.client.BaseClient._make_api_call
@@ -66,6 +67,15 @@ def mock_make_api_call(self, operation_name, kwarg):
return make_api_call(self, operation_name, kwarg)
+def mock_make_api_call_with_state_transition_reason(self, operation_name, kwarg):
+ response = make_api_call(self, operation_name, kwarg)
+ if operation_name == "DescribeInstances":
+ for reservation in response.get("Reservations", []):
+ for instance in reservation.get("Instances", []):
+ instance["StateTransitionReason"] = MOCK_STATE_TRANSITION_REASON
+ return response
+
+
class Test_EC2_Service:
# Test EC2 Service
@mock_aws
@@ -319,6 +329,10 @@ class Test_EC2_Service:
assert ec2.images_by_id == {}
# Test EC2 Describe Instances
+ @mock.patch(
+ "botocore.client.BaseClient._make_api_call",
+ new=mock_make_api_call_with_state_transition_reason,
+ )
@mock_aws
@freeze_time(MOCK_DATETIME)
def test_describe_instances(self):
@@ -349,6 +363,7 @@ class Test_EC2_Service:
assert ec2.instances[0].state == "running"
assert re.match(r"ami-[0-9a-z]{8}", ec2.instances[0].image_id)
assert ec2.instances[0].launch_time == MOCK_DATETIME
+ assert ec2.instances[0].state_transition_reason == MOCK_STATE_TRANSITION_REASON
assert not ec2.instances[0].user_data
assert ec2.instances[0].http_tokens == "optional"
assert ec2.instances[0].http_endpoint == "enabled"
diff --git a/tests/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/sagemaker_notebook_instance_no_secrets_test.py b/tests/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/sagemaker_notebook_instance_no_secrets_test.py
new file mode 100644
index 0000000000..106a3f2b1b
--- /dev/null
+++ b/tests/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/sagemaker_notebook_instance_no_secrets_test.py
@@ -0,0 +1,269 @@
+from unittest import mock
+
+from prowler.lib.utils.utils import SecretsScanError
+from prowler.providers.aws.services.sagemaker.sagemaker_service import (
+ NotebookInstance,
+)
+from tests.providers.aws.utils import (
+ AWS_ACCOUNT_NUMBER,
+ AWS_REGION_EU_WEST_1,
+ set_mocked_aws_provider,
+)
+
+test_notebook_instance = "test-notebook-instance"
+notebook_instance_arn = (
+ f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:"
+ f"{AWS_ACCOUNT_NUMBER}:notebook-instance/{test_notebook_instance}"
+)
+
+other_notebook_instance = "other-notebook-instance"
+other_notebook_instance_arn = (
+ f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:"
+ f"{AWS_ACCOUNT_NUMBER}:notebook-instance/{other_notebook_instance}"
+)
+
+CHECK_MODULE = "prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_no_secrets.sagemaker_notebook_instance_no_secrets"
+
+
+class Test_sagemaker_notebook_instance_no_secrets:
+ def test_no_instances(self):
+ sagemaker_client = mock.MagicMock
+ sagemaker_client.sagemaker_notebook_instances = []
+ sagemaker_client.audit_config = {}
+
+ aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=aws_provider,
+ ),
+ mock.patch(f"{CHECK_MODULE}.sagemaker_client", sagemaker_client),
+ ):
+ from prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_no_secrets.sagemaker_notebook_instance_no_secrets import (
+ sagemaker_notebook_instance_no_secrets,
+ )
+
+ check = sagemaker_notebook_instance_no_secrets()
+ result = check.execute()
+
+ assert len(result) == 0
+
+ def test_pass_no_lifecycle_config(self):
+ sagemaker_client = mock.MagicMock
+ sagemaker_client.audit_config = {}
+ sagemaker_client.sagemaker_notebook_instances = [
+ NotebookInstance(
+ name=test_notebook_instance,
+ arn=notebook_instance_arn,
+ region=AWS_REGION_EU_WEST_1,
+ lifecycle_config_name=None,
+ )
+ ]
+
+ aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=aws_provider,
+ ),
+ mock.patch(f"{CHECK_MODULE}.sagemaker_client", sagemaker_client),
+ mock.patch(
+ f"{CHECK_MODULE}.detect_secrets_scan_batch",
+ return_value={},
+ ),
+ ):
+ from prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_no_secrets.sagemaker_notebook_instance_no_secrets import (
+ sagemaker_notebook_instance_no_secrets,
+ )
+
+ check = sagemaker_notebook_instance_no_secrets()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ "does not have a lifecycle configuration" in result[0].status_extended
+ )
+ assert result[0].resource_id == test_notebook_instance
+ assert result[0].resource_arn == notebook_instance_arn
+
+ def test_pass_lifecycle_config_scanned_clean(self):
+ sagemaker_client = mock.MagicMock
+ sagemaker_client.audit_config = {}
+ sagemaker_client.sagemaker_notebook_instances = [
+ NotebookInstance(
+ name=test_notebook_instance,
+ arn=notebook_instance_arn,
+ region=AWS_REGION_EU_WEST_1,
+ lifecycle_config_name="test-lifecycle-config",
+ lifecycle_scripts={"OnCreate[0]": "echo hello"},
+ )
+ ]
+
+ aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=aws_provider,
+ ),
+ mock.patch(f"{CHECK_MODULE}.sagemaker_client", sagemaker_client),
+ mock.patch(
+ f"{CHECK_MODULE}.detect_secrets_scan_batch",
+ return_value={},
+ ),
+ ):
+ from prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_no_secrets.sagemaker_notebook_instance_no_secrets import (
+ sagemaker_notebook_instance_no_secrets,
+ )
+
+ check = sagemaker_notebook_instance_no_secrets()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert "No secrets found" in result[0].status_extended
+ assert result[0].resource_id == test_notebook_instance
+ assert result[0].resource_arn == notebook_instance_arn
+
+ def test_fail_secret_found(self):
+ notebook_instance = NotebookInstance(
+ name=test_notebook_instance,
+ arn=notebook_instance_arn,
+ region=AWS_REGION_EU_WEST_1,
+ lifecycle_config_name="test-lifecycle-config",
+ lifecycle_scripts={"OnCreate[0]": "echo API_KEY=12345"},
+ )
+
+ sagemaker_client = mock.MagicMock
+ sagemaker_client.audit_config = {}
+ sagemaker_client.sagemaker_notebook_instances = [notebook_instance]
+
+ fake_secret = {"type": "Secret Keyword", "line_number": 1}
+ aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=aws_provider,
+ ),
+ mock.patch(f"{CHECK_MODULE}.sagemaker_client", sagemaker_client),
+ mock.patch(
+ f"{CHECK_MODULE}.detect_secrets_scan_batch",
+ return_value={(notebook_instance_arn, "OnCreate[0]"): [fake_secret]},
+ ),
+ mock.patch(
+ f"{CHECK_MODULE}.annotate_verified_secrets",
+ lambda *_: None,
+ ),
+ ):
+ from prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_no_secrets.sagemaker_notebook_instance_no_secrets import (
+ sagemaker_notebook_instance_no_secrets,
+ )
+
+ check = sagemaker_notebook_instance_no_secrets()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "Secret Keyword" in result[0].status_extended
+ assert "OnCreate[0]" in result[0].status_extended
+ assert result[0].resource_id == test_notebook_instance
+ assert result[0].resource_arn == notebook_instance_arn
+
+ def test_manual_lifecycle_describe_failed(self):
+ # Service could not fully describe/decode the lifecycle config.
+ notebook_instance = NotebookInstance(
+ name=test_notebook_instance,
+ arn=notebook_instance_arn,
+ region=AWS_REGION_EU_WEST_1,
+ lifecycle_config_name="test-lifecycle-config",
+ lifecycle_scripts={},
+ lifecycle_scan_failed=True,
+ )
+
+ sagemaker_client = mock.MagicMock
+ sagemaker_client.audit_config = {}
+ sagemaker_client.sagemaker_notebook_instances = [notebook_instance]
+
+ aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=aws_provider,
+ ),
+ mock.patch(f"{CHECK_MODULE}.sagemaker_client", sagemaker_client),
+ mock.patch(
+ f"{CHECK_MODULE}.detect_secrets_scan_batch",
+ return_value={},
+ ),
+ ):
+ from prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_no_secrets.sagemaker_notebook_instance_no_secrets import (
+ sagemaker_notebook_instance_no_secrets,
+ )
+
+ check = sagemaker_notebook_instance_no_secrets()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "MANUAL"
+ assert result[0].resource_id == test_notebook_instance
+ assert result[0].resource_arn == notebook_instance_arn
+
+ def test_manual_scan_error_only_scanned_instances(self):
+ # Batch scan fails. The instance with scripts must be MANUAL; the
+ # instance without a lifecycle config (nothing to scan) must PASS.
+ scanned_instance = NotebookInstance(
+ name=test_notebook_instance,
+ arn=notebook_instance_arn,
+ region=AWS_REGION_EU_WEST_1,
+ lifecycle_config_name="test-lifecycle-config",
+ lifecycle_scripts={"OnStart[0]": "echo hello"},
+ )
+ unscanned_instance = NotebookInstance(
+ name=other_notebook_instance,
+ arn=other_notebook_instance_arn,
+ region=AWS_REGION_EU_WEST_1,
+ lifecycle_config_name=None,
+ lifecycle_scripts={},
+ )
+
+ sagemaker_client = mock.MagicMock
+ sagemaker_client.audit_config = {}
+ sagemaker_client.sagemaker_notebook_instances = [
+ scanned_instance,
+ unscanned_instance,
+ ]
+
+ aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=aws_provider,
+ ),
+ mock.patch(f"{CHECK_MODULE}.sagemaker_client", sagemaker_client),
+ mock.patch(
+ f"{CHECK_MODULE}.detect_secrets_scan_batch",
+ side_effect=SecretsScanError("scan failed"),
+ ),
+ ):
+ from prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_no_secrets.sagemaker_notebook_instance_no_secrets import (
+ sagemaker_notebook_instance_no_secrets,
+ )
+
+ check = sagemaker_notebook_instance_no_secrets()
+ result = check.execute()
+
+ assert len(result) == 2
+ results_by_id = {report.resource_id: report for report in result}
+
+ assert results_by_id[test_notebook_instance].status == "MANUAL"
+ assert results_by_id[other_notebook_instance].status == "PASS"
+ assert (
+ "does not have a lifecycle configuration"
+ in results_by_id[other_notebook_instance].status_extended
+ )
diff --git a/tests/providers/aws/services/sagemaker/sagemaker_service_test.py b/tests/providers/aws/services/sagemaker/sagemaker_service_test.py
index 50431c2e13..bfadd59efe 100644
--- a/tests/providers/aws/services/sagemaker/sagemaker_service_test.py
+++ b/tests/providers/aws/services/sagemaker/sagemaker_service_test.py
@@ -28,6 +28,10 @@ test_training_job = "test-training-job"
test_arn_training_job = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:training-job/{test_model}"
subnet_id = "subnet-" + str(uuid4())
kms_key_id = str(uuid4())
+lifecycle_config_name = "test-lifecycle-config"
+# base64 of "echo OnCreate" / "echo OnStart"
+lifecycle_on_create_b64 = "ZWNobyBPbkNyZWF0ZQ=="
+lifecycle_on_start_b64 = "ZWNobyBPblN0YXJ0"
endpoint_config_name = "endpoint-config-test"
endpoint_config_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:endpoint-config/{endpoint_config_name}"
prod_variant_name = "Variant1"
@@ -76,6 +80,12 @@ def mock_make_api_call(self, operation_name, kwarg):
"KmsKeyId": kms_key_id,
"DirectInternetAccess": "Enabled",
"RootAccess": "Enabled",
+ "NotebookInstanceLifecycleConfigName": lifecycle_config_name,
+ }
+ if operation_name == "DescribeNotebookInstanceLifecycleConfig":
+ return {
+ "OnCreate": [{"Content": lifecycle_on_create_b64}],
+ "OnStart": [{"Content": lifecycle_on_start_b64}],
}
if operation_name == "DescribeModel":
return {
@@ -247,6 +257,21 @@ class Test_SageMaker_Service:
assert sagemaker.sagemaker_notebook_instances[0].subnet_id == subnet_id
assert sagemaker.sagemaker_notebook_instances[0].direct_internet_access
assert sagemaker.sagemaker_notebook_instances[0].kms_key_id == kms_key_id
+ assert (
+ sagemaker.sagemaker_notebook_instances[0].lifecycle_config_name
+ == lifecycle_config_name
+ )
+
+ # Test SageMaker describe notebook instance lifecycle config
+ def test_describe_notebook_instance_lifecycle_config(self):
+ aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
+ sagemaker = SageMaker(aws_provider)
+ notebook_instance = sagemaker.sagemaker_notebook_instances[0]
+ assert notebook_instance.lifecycle_scan_failed is False
+ assert notebook_instance.lifecycle_scripts == {
+ "OnCreate[0]": "echo OnCreate",
+ "OnStart[0]": "echo OnStart",
+ }
# Test SageMaker describe model
def test_describe_model(self):
diff --git a/tests/providers/gcp/services/compute/compute_firewall_rdp_access_from_the_internet_allowed/compute_firewall_rdp_access_from_the_internet_allowed_test.py b/tests/providers/gcp/services/compute/compute_firewall_rdp_access_from_the_internet_allowed/compute_firewall_rdp_access_from_the_internet_allowed_test.py
index 930ee14605..71e44fb702 100644
--- a/tests/providers/gcp/services/compute/compute_firewall_rdp_access_from_the_internet_allowed/compute_firewall_rdp_access_from_the_internet_allowed_test.py
+++ b/tests/providers/gcp/services/compute/compute_firewall_rdp_access_from_the_internet_allowed/compute_firewall_rdp_access_from_the_internet_allowed_test.py
@@ -279,6 +279,48 @@ class Test_compute_firewall_rdp_access_from_the_internet_allowed:
)
assert result[0].resource_id == firewall.id
+ def test_one_non_compliant_rule_with_multiple_ports(self):
+ from prowler.providers.gcp.services.compute.compute_service import Firewall
+
+ firewall = Firewall(
+ name="test",
+ id="1234567890",
+ source_ranges=["0.0.0.0/0"],
+ direction="INGRESS",
+ allowed_rules=[{"IPProtocol": "tcp", "ports": ["80", "3389"]}],
+ project_id=GCP_PROJECT_ID,
+ )
+
+ compute_client = mock.MagicMock()
+ compute_client.project_ids = [GCP_PROJECT_ID]
+ compute_client.firewalls = [firewall]
+ compute_client.region = "global"
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_gcp_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.gcp.services.compute.compute_firewall_rdp_access_from_the_internet_allowed.compute_firewall_rdp_access_from_the_internet_allowed.compute_client",
+ new=compute_client,
+ ),
+ ):
+ from prowler.providers.gcp.services.compute.compute_firewall_rdp_access_from_the_internet_allowed.compute_firewall_rdp_access_from_the_internet_allowed import (
+ compute_firewall_rdp_access_from_the_internet_allowed,
+ )
+
+ check = compute_firewall_rdp_access_from_the_internet_allowed()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert search(
+ f"Firewall {firewall.name} does exposes port 3389",
+ result[0].status_extended,
+ )
+ assert result[0].resource_id == firewall.id
+
def test_one_non_compliant_rule_with_port_range(self):
from prowler.providers.gcp.services.compute.compute_service import Firewall
diff --git a/tests/providers/gcp/services/compute/compute_firewall_ssh_access_from_the_internet_allowed/compute_firewall_ssh_access_from_the_internet_allowed_test.py b/tests/providers/gcp/services/compute/compute_firewall_ssh_access_from_the_internet_allowed/compute_firewall_ssh_access_from_the_internet_allowed_test.py
index 9939415ab7..315a899768 100644
--- a/tests/providers/gcp/services/compute/compute_firewall_ssh_access_from_the_internet_allowed/compute_firewall_ssh_access_from_the_internet_allowed_test.py
+++ b/tests/providers/gcp/services/compute/compute_firewall_ssh_access_from_the_internet_allowed/compute_firewall_ssh_access_from_the_internet_allowed_test.py
@@ -279,6 +279,48 @@ class Test_compute_firewall_ssh_access_from_the_internet_allowed:
)
assert result[0].resource_id == firewall.id
+ def test_one_non_compliant_rule_with_multiple_ports(self):
+ from prowler.providers.gcp.services.compute.compute_service import Firewall
+
+ firewall = Firewall(
+ name="test",
+ id="1234567890",
+ source_ranges=["0.0.0.0/0"],
+ direction="INGRESS",
+ allowed_rules=[{"IPProtocol": "tcp", "ports": ["80", "22"]}],
+ project_id=GCP_PROJECT_ID,
+ )
+
+ compute_client = mock.MagicMock()
+ compute_client.project_ids = [GCP_PROJECT_ID]
+ compute_client.firewalls = [firewall]
+ compute_client.region = "global"
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_gcp_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.gcp.services.compute.compute_firewall_ssh_access_from_the_internet_allowed.compute_firewall_ssh_access_from_the_internet_allowed.compute_client",
+ new=compute_client,
+ ),
+ ):
+ from prowler.providers.gcp.services.compute.compute_firewall_ssh_access_from_the_internet_allowed.compute_firewall_ssh_access_from_the_internet_allowed import (
+ compute_firewall_ssh_access_from_the_internet_allowed,
+ )
+
+ check = compute_firewall_ssh_access_from_the_internet_allowed()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert search(
+ f"Firewall {firewall.name} does exposes port 22",
+ result[0].status_extended,
+ )
+ assert result[0].resource_id == firewall.id
+
def test_one_non_compliant_rule_with_port_range(self):
from prowler.providers.gcp.services.compute.compute_service import Firewall
diff --git a/tests/providers/huaweicloud/huaweicloud_fixtures.py b/tests/providers/huaweicloud/huaweicloud_fixtures.py
new file mode 100644
index 0000000000..b121c9ea3e
--- /dev/null
+++ b/tests/providers/huaweicloud/huaweicloud_fixtures.py
@@ -0,0 +1,57 @@
+from unittest.mock import MagicMock
+
+from prowler.providers.common.models import Audit_Metadata
+from prowler.providers.huaweicloud.models import HuaweiCloudIdentityInfo
+
+
+def set_mocked_huaweicloud_provider(
+ account_id: str = "123456789012",
+ account_name: str = "test-account",
+ domain_id: str = "123456789012",
+ user_id: str = "123456",
+ user_name: str = "test-user",
+ region: str = "la-south-2",
+) -> MagicMock:
+ """Create a mocked Huawei Cloud provider for service unit tests."""
+ provider = MagicMock()
+ provider.type = "huaweicloud"
+
+ provider.identity = HuaweiCloudIdentityInfo(
+ account_id=account_id,
+ account_name=account_name,
+ domain_id=domain_id,
+ user_id=user_id,
+ user_name=user_name,
+ identity_type="user",
+ regions={region},
+ profile="default",
+ profile_region=region,
+ )
+
+ provider.audit_metadata = Audit_Metadata(
+ services_scanned=0,
+ expected_checks=[],
+ completed_checks=0,
+ audit_progress=0,
+ )
+ provider.audit_resources = []
+ provider.audit_config = {}
+ provider.fixer_config = {}
+ provider.mutelist = MagicMock()
+ provider.mutelist.is_muted = MagicMock(return_value=False)
+
+ # Session/client mocks
+ provider.session = MagicMock()
+ provider.session.client = MagicMock(return_value=MagicMock(region=region))
+
+ # Region helpers
+ provider.get_default_region = MagicMock(return_value=region)
+
+ def mock_generate_regional_clients(service_name):
+ return {region: MagicMock(region=region)}
+
+ provider.generate_regional_clients = MagicMock(
+ side_effect=mock_generate_regional_clients
+ )
+
+ return provider
diff --git a/tests/providers/huaweicloud/huaweicloud_metadata_test.py b/tests/providers/huaweicloud/huaweicloud_metadata_test.py
new file mode 100644
index 0000000000..26cabab3c4
--- /dev/null
+++ b/tests/providers/huaweicloud/huaweicloud_metadata_test.py
@@ -0,0 +1,34 @@
+from pathlib import Path
+
+import pytest
+
+from prowler.lib.check.models import CheckMetadata
+
+METADATA_FILES = sorted(
+ Path("prowler/providers/huaweicloud").glob("services/**/*.metadata.json")
+)
+
+
+@pytest.mark.parametrize("metadata_file", METADATA_FILES)
+def test_huaweicloud_check_metadata_is_valid(metadata_file):
+ metadata = CheckMetadata.parse_file(metadata_file)
+ assert metadata.Provider == "huaweicloud"
+ assert metadata.CheckID == metadata_file.stem.replace(".metadata", "")
+
+
+@pytest.mark.parametrize("metadata_file", METADATA_FILES)
+def test_huaweicloud_check_metadata_servicename_matches_folder(metadata_file):
+ metadata = CheckMetadata.parse_file(metadata_file)
+ service_folder = metadata_file.relative_to(
+ Path("prowler/providers/huaweicloud/services")
+ ).parts[0]
+ assert metadata.ServiceName == service_folder
+
+
+@pytest.mark.parametrize("metadata_file", METADATA_FILES)
+def test_huaweicloud_check_metadata_uses_canonical_hub_urls(metadata_file):
+ metadata = CheckMetadata.parse_file(metadata_file)
+ url = metadata.Remediation.Recommendation.Url
+ assert not url.startswith(
+ "https://hub.prowler.com/checks/huaweicloud/"
+ ), f"{metadata_file}: non-canonical hub URL {url}"
diff --git a/tests/providers/huaweicloud/huaweicloud_provider_test.py b/tests/providers/huaweicloud/huaweicloud_provider_test.py
new file mode 100644
index 0000000000..6efb94eb66
--- /dev/null
+++ b/tests/providers/huaweicloud/huaweicloud_provider_test.py
@@ -0,0 +1,529 @@
+import os
+from types import SimpleNamespace
+from unittest import mock
+
+import pytest
+
+from prowler.providers.huaweicloud.exceptions.exceptions import (
+ HuaweiCloudAssumeRoleError,
+ HuaweiCloudAuthenticationError,
+ HuaweiCloudBaseException,
+ HuaweiCloudCredentialsError,
+ HuaweiCloudIdentityError,
+ HuaweiCloudInvalidProviderIdError,
+ HuaweiCloudInvalidRegionError,
+ HuaweiCloudServiceError,
+ HuaweiCloudSetUpSessionError,
+)
+from prowler.providers.huaweicloud.huaweicloud_provider import HuaweicloudProvider
+from prowler.providers.huaweicloud.models import (
+ HuaweiCloudCallerIdentity,
+ HuaweiCloudCredentials,
+ HuaweiCloudSession,
+)
+
+ACCESS_KEY = "AKIAmockaccesskey"
+SECRET_KEY = "mocksecretkey"
+
+
+class TestHuaweiCloudProviderSetupSession:
+ def test_missing_credentials_raises(self):
+ with mock.patch.dict(os.environ, {}, clear=True):
+ with pytest.raises(HuaweiCloudCredentialsError):
+ HuaweicloudProvider.setup_session()
+
+ def test_returns_session_with_explicit_credentials(self):
+ with mock.patch.dict(os.environ, {}, clear=True):
+ session = HuaweicloudProvider.setup_session(
+ access_key_id=ACCESS_KEY,
+ secret_access_key=SECRET_KEY,
+ )
+ assert isinstance(session, HuaweiCloudSession)
+ assert session.get_credentials().ak == ACCESS_KEY
+
+ def test_reads_credentials_from_env(self):
+ env = {
+ "HUAWEICLOUD_ACCESS_KEY_ID": ACCESS_KEY,
+ "HUAWEICLOUD_SECRET_ACCESS_KEY": SECRET_KEY,
+ }
+ with mock.patch.dict(os.environ, env, clear=True):
+ session = HuaweicloudProvider.setup_session()
+ assert session.get_credentials().ak == ACCESS_KEY
+
+
+class TestHuaweiCloudProviderValidateCredentials:
+ def test_resolves_caller_identity_from_iam(self):
+ session = HuaweiCloudSession(
+ HuaweiCloudCredentials(ak=ACCESS_KEY, sk=SECRET_KEY, domain_id="domain-1")
+ )
+
+ domain = mock.MagicMock(id="domain-1")
+ domain.name = "my-account"
+ user = mock.MagicMock(id="user-1")
+ user.name = "admin"
+ iam_client = mock.MagicMock()
+ iam_client.keystone_list_auth_domains.return_value = mock.MagicMock(
+ domains=[domain]
+ )
+ iam_client.show_user.return_value = mock.MagicMock(user=user)
+
+ builder = mock.MagicMock()
+ builder.with_credentials.return_value.with_region.return_value.build.return_value = (
+ iam_client
+ )
+
+ with (
+ mock.patch(
+ "huaweicloudsdkiam.v3.IamClient.new_builder", return_value=builder
+ ),
+ mock.patch("huaweicloudsdkcore.auth.credentials.BasicCredentials"),
+ mock.patch("huaweicloudsdkiam.v3.region.iam_region.IamRegion"),
+ ):
+ identity = HuaweicloudProvider.validate_credentials(session=session)
+
+ assert isinstance(identity, HuaweiCloudCallerIdentity)
+ assert identity.domain_id == "domain-1"
+ assert identity.account_name == "my-account"
+ assert identity.user_name == "admin"
+
+
+class TestHuaweiCloudProviderGetRegionsToAudit:
+ @staticmethod
+ def _provider():
+ # Bare instance is enough: get_regions_to_audit only reads the module-level
+ # HUAWEICLOUD_REGIONS and guards access to self._identity with hasattr.
+ return HuaweicloudProvider.__new__(HuaweicloudProvider)
+
+ def test_valid_regions(self):
+ regions = self._provider().get_regions_to_audit(["cn-north-4"])
+ assert [r.region_id for r in regions] == ["cn-north-4"]
+
+ def test_no_regions_returns_all(self):
+ from prowler.providers.huaweicloud.config import HUAWEICLOUD_REGIONS
+
+ regions = self._provider().get_regions_to_audit(None)
+ assert len(regions) == len(HUAWEICLOUD_REGIONS)
+
+ def test_all_invalid_regions_raises(self):
+ with pytest.raises(HuaweiCloudInvalidRegionError):
+ self._provider().get_regions_to_audit(["not-a-region"])
+
+ def test_partial_invalid_regions_keeps_valid(self):
+ regions = self._provider().get_regions_to_audit(["cn-north-4", "not-a-region"])
+ assert [r.region_id for r in regions] == ["cn-north-4"]
+
+
+class TestHuaweiCloudProviderResolveRegions:
+ def test_flag_takes_precedence_over_env(self):
+ with mock.patch.dict(
+ os.environ, {"HUAWEICLOUD_REGION": "eu-west-101"}, clear=True
+ ):
+ assert HuaweicloudProvider._resolve_regions(["ap-southeast-1"]) == [
+ "ap-southeast-1"
+ ]
+
+ def test_falls_back_to_env_region(self):
+ with mock.patch.dict(
+ os.environ, {"HUAWEICLOUD_REGION": "eu-west-101"}, clear=True
+ ):
+ assert HuaweicloudProvider._resolve_regions(None) == ["eu-west-101"]
+
+ def test_env_region_supports_multiple(self):
+ with mock.patch.dict(
+ os.environ,
+ {"HUAWEICLOUD_REGION": "eu-west-101, ap-southeast-1"},
+ clear=True,
+ ):
+ assert HuaweicloudProvider._resolve_regions(None) == [
+ "eu-west-101",
+ "ap-southeast-1",
+ ]
+
+ def test_hw_region_alias(self):
+ with mock.patch.dict(os.environ, {"HW_REGION": "eu-west-0"}, clear=True):
+ assert HuaweicloudProvider._resolve_regions(None) == ["eu-west-0"]
+
+ def test_no_flag_no_env_returns_none(self):
+ with mock.patch.dict(os.environ, {}, clear=True):
+ assert HuaweicloudProvider._resolve_regions(None) is None
+
+ def test_cloud_selector_expands_to_cloud_regions(self):
+ with mock.patch.dict(os.environ, {}, clear=True):
+ assert HuaweicloudProvider._resolve_regions(None, "europe") == [
+ "eu-west-101"
+ ]
+
+ def test_cloud_selector_from_env(self):
+ with mock.patch.dict(os.environ, {"HUAWEICLOUD_CLOUD": "europe"}, clear=True):
+ assert HuaweicloudProvider._resolve_regions(None) == ["eu-west-101"]
+
+ def test_region_overrides_cloud_selector(self):
+ with mock.patch.dict(os.environ, {}, clear=True):
+ assert HuaweicloudProvider._resolve_regions(
+ ["ap-southeast-1"], "europe"
+ ) == ["ap-southeast-1"]
+
+ def test_env_region_overrides_cloud_env(self):
+ with mock.patch.dict(
+ os.environ,
+ {"HUAWEICLOUD_REGION": "eu-west-101", "HUAWEICLOUD_CLOUD": "china"},
+ clear=True,
+ ):
+ assert HuaweicloudProvider._resolve_regions(None) == ["eu-west-101"]
+
+ def test_cloud_alias_and_case_insensitive(self):
+ with mock.patch.dict(os.environ, {}, clear=True):
+ assert HuaweicloudProvider._resolve_regions(None, "EU") == ["eu-west-101"]
+
+
+class TestHuaweiCloudProviderRegionsForCloud:
+ def test_europe_is_only_eu_endpoint_regions(self):
+ assert HuaweicloudProvider._regions_for_cloud("europe") == ["eu-west-101"]
+
+ def test_china_is_cn_prefixed_regions(self):
+ regions = HuaweicloudProvider._regions_for_cloud("china")
+ assert regions
+ assert all(region.startswith("cn-") for region in regions)
+
+ def test_international_excludes_china_and_europe(self):
+ regions = HuaweicloudProvider._regions_for_cloud("international")
+ assert regions
+ assert all(not region.startswith("cn-") for region in regions)
+ assert "eu-west-101" not in regions
+ # eu-west-0 is an International (.com) region despite the eu- prefix
+ assert "eu-west-0" in regions
+
+ def test_clouds_partition_all_regions_without_overlap(self):
+ from prowler.providers.huaweicloud.config import HUAWEICLOUD_REGIONS
+
+ europe = set(HuaweicloudProvider._regions_for_cloud("europe"))
+ china = set(HuaweicloudProvider._regions_for_cloud("china"))
+ international = set(HuaweicloudProvider._regions_for_cloud("international"))
+ assert europe & china == set()
+ assert europe & international == set()
+ assert china & international == set()
+ assert europe | china | international == set(HUAWEICLOUD_REGIONS)
+
+ def test_alias_maps_to_canonical_cloud(self):
+ assert HuaweicloudProvider._regions_for_cloud(
+ "intl"
+ ) == HuaweicloudProvider._regions_for_cloud("international")
+ assert HuaweicloudProvider._regions_for_cloud(
+ "com"
+ ) == HuaweicloudProvider._regions_for_cloud("international")
+ assert HuaweicloudProvider._regions_for_cloud(
+ "cn"
+ ) == HuaweicloudProvider._regions_for_cloud("china")
+
+ def test_unknown_cloud_returns_empty(self):
+ assert HuaweicloudProvider._regions_for_cloud("mars") == []
+
+
+class TestHuaweiCloudBaseModel:
+ def test_none_coerced_to_default_for_str_fields(self):
+ from typing import Optional
+
+ from prowler.providers.huaweicloud.models import HuaweiCloudBaseModel
+
+ class _Resource(HuaweiCloudBaseModel):
+ required_str: str
+ defaulted_str: str = "d"
+ optional_str: Optional[str] = None
+ optional_int: Optional[int] = None
+
+ # None on required and defaulted str fields is coerced, not rejected.
+ resource = _Resource(
+ required_str=None,
+ defaulted_str=None,
+ optional_str=None,
+ optional_int=None,
+ )
+ assert resource.required_str == ""
+ assert resource.defaulted_str == "d" # falls back to the field default
+ assert resource.optional_str is None # Optional still accepts None
+ assert resource.optional_int is None
+
+ def test_real_values_pass_through(self):
+ from prowler.providers.huaweicloud.models import HuaweiCloudBaseModel
+
+ class _Resource(HuaweiCloudBaseModel):
+ name: str = ""
+
+ assert _Resource(name="prod").name == "prod"
+
+
+class TestHuaweiCloudEndpointAlignment:
+ def test_eu_region_corrects_com_service_endpoint(self):
+ from prowler.providers.huaweicloud.models import _align_endpoint_tld
+
+ # ECS/VPC/ELB/EVS/WAF ship .com endpoints for the Europe (.eu) region.
+ assert (
+ _align_endpoint_tld(
+ "eu-west-101", "https://ecs.eu-west-101.myhuaweicloud.com"
+ )
+ == "https://ecs.eu-west-101.myhuaweicloud.eu"
+ )
+
+ def test_eu_region_leaves_correct_eu_endpoint(self):
+ from prowler.providers.huaweicloud.models import _align_endpoint_tld
+
+ endpoint = "https://iam.eu-west-101.myhuaweicloud.eu"
+ assert _align_endpoint_tld("eu-west-101", endpoint) == endpoint
+
+ def test_com_region_is_untouched(self):
+ from prowler.providers.huaweicloud.models import _align_endpoint_tld
+
+ for region in ("ap-southeast-1", "cn-north-4"):
+ endpoint = f"https://ecs.{region}.myhuaweicloud.com"
+ assert _align_endpoint_tld(region, endpoint) == endpoint
+
+ def test_aligned_region_returns_region_with_eu_endpoint(self):
+ from huaweicloudsdkecs.v2.region.ecs_region import EcsRegion
+
+ from prowler.providers.huaweicloud.models import _aligned_region
+
+ region = _aligned_region(EcsRegion, "eu-west-101")
+ assert region.endpoints[0] == "https://ecs.eu-west-101.myhuaweicloud.eu"
+
+ def test_aligned_region_unknown_region_falls_through(self):
+ from prowler.providers.huaweicloud.models import _align_endpoint_tld
+
+ # A region with no IAM endpoint cannot be classified; leave as-is.
+ endpoint = "https://ecs.af-north-1.myhuaweicloud.com"
+ assert _align_endpoint_tld("af-north-1", endpoint) == endpoint
+
+
+class TestHuaweiCloudProviderValidationRegion:
+ def test_no_regions_uses_default(self):
+ from prowler.providers.huaweicloud.config import HUAWEICLOUD_DEFAULT_REGION
+
+ assert (
+ HuaweicloudProvider._validation_region(None) == HUAWEICLOUD_DEFAULT_REGION
+ )
+
+ def test_picks_first_iam_capable_requested_region(self):
+ assert (
+ HuaweicloudProvider._validation_region(["ap-southeast-1", "af-south-1"])
+ == "af-south-1"
+ )
+
+ def test_skips_non_iam_regions_when_iam_region_present(self):
+ # af-north-1 has no IAM endpoint; ap-southeast-1 does and sorts after it,
+ # so validation must skip the non-IAM region.
+ assert (
+ HuaweicloudProvider._validation_region(["af-north-1", "ap-southeast-1"])
+ == "ap-southeast-1"
+ )
+
+ def test_falls_back_to_same_cloud_iam_region_international(self):
+ # Only a non-IAM International region requested -> validate against an
+ # IAM-capable International region.
+ region = HuaweicloudProvider._validation_region(["af-north-1"])
+ from prowler.providers.huaweicloud.models import _iam_endpoint_for_region
+
+ assert _iam_endpoint_for_region(region)
+ assert not region.startswith("cn-")
+
+ def test_falls_back_to_same_cloud_iam_region_china(self):
+ region = HuaweicloudProvider._validation_region(["cn-south-4"])
+ from prowler.providers.huaweicloud.models import _iam_endpoint_for_region
+
+ assert _iam_endpoint_for_region(region)
+ assert region.startswith("cn-")
+
+
+class TestHuaweiCloudProviderTestConnection:
+ def test_successful_connection(self):
+ fake_identity = HuaweiCloudCallerIdentity(
+ domain_id="d",
+ user_id="u",
+ user_name="n",
+ account_id="123456789012",
+ account_name="acct",
+ type="user",
+ )
+ with mock.patch.dict(os.environ, {}, clear=True):
+ with (
+ mock.patch.object(
+ HuaweicloudProvider,
+ "setup_session",
+ return_value=mock.MagicMock(),
+ ),
+ mock.patch.object(
+ HuaweicloudProvider,
+ "validate_credentials",
+ return_value=fake_identity,
+ ),
+ ):
+ connection = HuaweicloudProvider.test_connection(
+ access_key_id=ACCESS_KEY,
+ secret_access_key=SECRET_KEY,
+ provider_id="123456789012",
+ )
+ assert connection.is_connected
+ assert connection.error is None
+
+ def test_provider_id_mismatch_raises(self):
+ fake_identity = HuaweiCloudCallerIdentity(
+ domain_id="d",
+ user_id="u",
+ user_name="n",
+ account_id="111111111111",
+ account_name="acct",
+ type="user",
+ )
+ with mock.patch.dict(os.environ, {}, clear=True):
+ with (
+ mock.patch.object(
+ HuaweicloudProvider,
+ "setup_session",
+ return_value=mock.MagicMock(),
+ ),
+ mock.patch.object(
+ HuaweicloudProvider,
+ "validate_credentials",
+ return_value=fake_identity,
+ ),
+ ):
+ with pytest.raises(HuaweiCloudInvalidProviderIdError):
+ HuaweicloudProvider.test_connection(
+ access_key_id=ACCESS_KEY,
+ secret_access_key=SECRET_KEY,
+ provider_id="999999999999",
+ raise_on_exception=True,
+ )
+
+ def test_missing_credentials_returns_error_when_not_raising(self):
+ with mock.patch.dict(os.environ, {}, clear=True):
+ connection = HuaweicloudProvider.test_connection(raise_on_exception=False)
+ assert connection.is_connected is not True
+ assert connection.error is not None
+
+
+def _agency_builder(credential):
+ """Return a mocked IamClient builder chain for agency assumption."""
+ client = mock.MagicMock()
+ client.create_temporary_access_key_by_agency.return_value = SimpleNamespace(
+ credential=credential
+ )
+ builder = mock.MagicMock()
+ builder.with_credentials.return_value.with_region.return_value.build.return_value = (
+ client
+ )
+ return builder, client
+
+
+class TestHuaweiCloudProviderAssumeAgency:
+ def test_returns_temporary_credentials(self):
+ credential = SimpleNamespace(
+ access="tmp-ak",
+ secret="tmp-sk",
+ securitytoken="tmp-token",
+ expires_at="2026-01-01T00:00:00Z",
+ )
+ builder, client = _agency_builder(credential)
+ base = HuaweiCloudCredentials(
+ ak="base-ak", sk="base-sk", domain_id="base-domain"
+ )
+
+ with (
+ mock.patch(
+ "huaweicloudsdkiam.v3.IamClient.new_builder", return_value=builder
+ ),
+ mock.patch("huaweicloudsdkiam.v3.region.iam_region.IamRegion"),
+ ):
+ result = HuaweicloudProvider.assume_agency(
+ credentials=base,
+ agency_name="prowler-agency",
+ assume_domain_id="target-domain",
+ )
+
+ assert result.ak == "tmp-ak"
+ assert result.sk == "tmp-sk"
+ assert result.security_token == "tmp-token"
+ assert result.domain_id == "target-domain"
+
+ request = client.create_temporary_access_key_by_agency.call_args[0][0]
+ assume_role = request.body.auth.identity.assume_role
+ assert assume_role.agency_name == "prowler-agency"
+ assert assume_role.domain_id == "target-domain"
+
+ def test_requires_target_domain(self):
+ base = HuaweiCloudCredentials(ak="a", sk="b")
+ with pytest.raises(HuaweiCloudAssumeRoleError):
+ HuaweicloudProvider.assume_agency(
+ credentials=base, agency_name="prowler-agency"
+ )
+
+ def test_sdk_failure_raises_assume_role_error(self):
+ builder, client = _agency_builder(None)
+ client.create_temporary_access_key_by_agency.side_effect = Exception("denied")
+ base = HuaweiCloudCredentials(ak="a", sk="b")
+
+ with (
+ mock.patch(
+ "huaweicloudsdkiam.v3.IamClient.new_builder", return_value=builder
+ ),
+ mock.patch("huaweicloudsdkiam.v3.region.iam_region.IamRegion"),
+ ):
+ with pytest.raises(HuaweiCloudAssumeRoleError):
+ HuaweicloudProvider.assume_agency(
+ credentials=base,
+ agency_name="prowler-agency",
+ assume_domain_name="target-account",
+ )
+
+ def test_setup_session_assumes_agency_from_env(self):
+ credential = SimpleNamespace(
+ access="tmp-ak",
+ secret="tmp-sk",
+ securitytoken="tmp-token",
+ expires_at="",
+ )
+ builder, _ = _agency_builder(credential)
+ env = {
+ "HUAWEICLOUD_ACCESS_KEY_ID": ACCESS_KEY,
+ "HUAWEICLOUD_SECRET_ACCESS_KEY": SECRET_KEY,
+ "HUAWEICLOUD_AGENCY_NAME": "prowler-agency",
+ "HUAWEICLOUD_ASSUME_DOMAIN_ID": "target-domain",
+ }
+
+ with (
+ mock.patch.dict(os.environ, env, clear=True),
+ mock.patch(
+ "huaweicloudsdkiam.v3.IamClient.new_builder", return_value=builder
+ ),
+ mock.patch("huaweicloudsdkiam.v3.region.iam_region.IamRegion"),
+ ):
+ session = HuaweicloudProvider.setup_session()
+
+ assert session.get_credentials().ak == "tmp-ak"
+ assert session.get_credentials().security_token == "tmp-token"
+
+
+class TestHuaweiCloudExceptions:
+ def test_error_codes_are_unique_and_in_reserved_range(self):
+ classes = [
+ HuaweiCloudCredentialsError,
+ HuaweiCloudAuthenticationError,
+ HuaweiCloudSetUpSessionError,
+ HuaweiCloudIdentityError,
+ HuaweiCloudInvalidRegionError,
+ HuaweiCloudInvalidProviderIdError,
+ HuaweiCloudServiceError,
+ HuaweiCloudAssumeRoleError,
+ ]
+ codes = set()
+ for cls in classes:
+ error = cls(file="huaweicloud_provider.py")
+ assert isinstance(error, HuaweiCloudBaseException)
+ assert 19000 <= error.code <= 19099
+ assert error.message
+ assert error.remediation
+ codes.add(error.code)
+ assert len(codes) == len(classes)
+
+ def test_custom_message_override(self):
+ error = HuaweiCloudServiceError(message="custom service failure")
+ assert error.message == "custom service failure"
+ assert error.code == 19006
diff --git a/tests/providers/huaweicloud/lib/mutelist/huaweicloud_mutelist_test.py b/tests/providers/huaweicloud/lib/mutelist/huaweicloud_mutelist_test.py
new file mode 100644
index 0000000000..15ce21d11d
--- /dev/null
+++ b/tests/providers/huaweicloud/lib/mutelist/huaweicloud_mutelist_test.py
@@ -0,0 +1,100 @@
+from unittest.mock import MagicMock
+
+from prowler.providers.huaweicloud.lib.mutelist.mutelist import HuaweiCloudMutelist
+
+ACCOUNT_ID = "123456789012"
+
+
+def _finding(
+ check_id="obs_bucket_public_access",
+ region="cn-north-4",
+ resource_id="bucket-1",
+ tags=None,
+):
+ finding = MagicMock()
+ finding.check_metadata = MagicMock()
+ finding.check_metadata.CheckID = check_id
+ finding.region = region
+ finding.resource_id = resource_id
+ finding.resource_tags = tags or []
+ return finding
+
+
+class TestHuaweiCloudMutelist:
+ def test_empty_mutelist_not_muted(self):
+ mutelist = HuaweiCloudMutelist(mutelist_content={})
+ assert not mutelist.is_finding_muted(_finding(), ACCOUNT_ID)
+
+ def test_matching_finding_is_muted(self):
+ content = {
+ "Accounts": {
+ ACCOUNT_ID: {
+ "Checks": {
+ "obs_bucket_public_access": {
+ "Regions": ["*"],
+ "Resources": ["bucket-1"],
+ }
+ }
+ }
+ }
+ }
+ mutelist = HuaweiCloudMutelist(mutelist_content=content)
+ assert mutelist.is_finding_muted(_finding(), ACCOUNT_ID)
+
+ def test_non_matching_resource_not_muted(self):
+ content = {
+ "Accounts": {
+ ACCOUNT_ID: {
+ "Checks": {
+ "obs_bucket_public_access": {
+ "Regions": ["*"],
+ "Resources": ["other-bucket"],
+ }
+ }
+ }
+ }
+ }
+ mutelist = HuaweiCloudMutelist(mutelist_content=content)
+ assert not mutelist.is_finding_muted(_finding(), ACCOUNT_ID)
+
+ def test_wildcard_account_and_check_mutes(self):
+ content = {
+ "Accounts": {"*": {"Checks": {"*": {"Regions": ["*"], "Resources": ["*"]}}}}
+ }
+ mutelist = HuaweiCloudMutelist(mutelist_content=content)
+ assert mutelist.is_finding_muted(_finding(), ACCOUNT_ID)
+
+ def test_region_filter_excludes(self):
+ content = {
+ "Accounts": {
+ ACCOUNT_ID: {
+ "Checks": {
+ "obs_bucket_public_access": {
+ "Regions": ["cn-east-3"],
+ "Resources": ["*"],
+ }
+ }
+ }
+ }
+ }
+ mutelist = HuaweiCloudMutelist(mutelist_content=content)
+ assert not mutelist.is_finding_muted(_finding(region="cn-north-4"), ACCOUNT_ID)
+
+ def test_exception_resource_not_muted(self):
+ content = {
+ "Accounts": {
+ ACCOUNT_ID: {
+ "Checks": {
+ "obs_bucket_public_access": {
+ "Regions": ["*"],
+ "Resources": ["*"],
+ "Exceptions": {"Resources": ["bucket-1"]},
+ }
+ }
+ }
+ }
+ }
+ mutelist = HuaweiCloudMutelist(mutelist_content=content)
+ assert not mutelist.is_finding_muted(
+ _finding(resource_id="bucket-1"), ACCOUNT_ID
+ )
diff --git a/tests/providers/huaweicloud/lib/service/huaweicloud_service_test.py b/tests/providers/huaweicloud/lib/service/huaweicloud_service_test.py
new file mode 100644
index 0000000000..28f3ffd7fa
--- /dev/null
+++ b/tests/providers/huaweicloud/lib/service/huaweicloud_service_test.py
@@ -0,0 +1,77 @@
+import pytest
+
+from prowler.providers.huaweicloud.lib.service.service import HuaweiCloudService
+
+
+def _error(message="boom", error_code=None, status_code=None):
+ error = Exception(message)
+ if error_code is not None:
+ error.error_code = error_code
+ if status_code is not None:
+ error.status_code = status_code
+ return error
+
+
+class TestHuaweiCloudServiceIsRetriableError:
+ def test_retriable_by_error_code(self):
+ assert HuaweiCloudService._is_retriable_error(_error(error_code="Throttling"))
+
+ def test_retriable_by_status_code(self):
+ assert HuaweiCloudService._is_retriable_error(_error(status_code=503))
+
+ def test_retriable_by_message_substring(self):
+ assert HuaweiCloudService._is_retriable_error(
+ _error(message="the request timed out")
+ )
+
+ def test_non_retriable_error(self):
+ assert not HuaweiCloudService._is_retriable_error(
+ _error(message="AccessDenied", error_code="Forbidden", status_code=403)
+ )
+
+
+class TestHuaweiCloudServiceCallWithRetries:
+ @staticmethod
+ def _service():
+ return HuaweiCloudService.__new__(HuaweiCloudService)
+
+ def test_returns_result_on_success(self):
+ service = self._service()
+ assert service._call_with_retries(lambda: "ok") == "ok"
+
+ def test_retries_once_then_succeeds(self):
+ service = self._service()
+ calls = {"n": 0}
+
+ def flaky():
+ calls["n"] += 1
+ if calls["n"] == 1:
+ raise _error(error_code="Throttling")
+ return "recovered"
+
+ assert service._call_with_retries(flaky) == "recovered"
+ assert calls["n"] == 2
+
+ def test_non_retriable_error_raises_immediately(self):
+ service = self._service()
+ calls = {"n": 0}
+
+ def boom():
+ calls["n"] += 1
+ raise _error(message="AccessDenied", status_code=403)
+
+ with pytest.raises(Exception):
+ service._call_with_retries(boom)
+ assert calls["n"] == 1
+
+ def test_exhausts_retries_and_raises(self):
+ service = self._service()
+ calls = {"n": 0}
+
+ def always_throttled():
+ calls["n"] += 1
+ raise _error(error_code="Throttling")
+
+ with pytest.raises(Exception):
+ service._call_with_retries(always_throttled, retries=1)
+ assert calls["n"] == 2
diff --git a/tests/providers/huaweicloud/services/cts/cts_enabled/cts_enabled_test.py b/tests/providers/huaweicloud/services/cts/cts_enabled/cts_enabled_test.py
new file mode 100644
index 0000000000..5be7c37b09
--- /dev/null
+++ b/tests/providers/huaweicloud/services/cts/cts_enabled/cts_enabled_test.py
@@ -0,0 +1,108 @@
+from unittest import mock
+
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+
+class TestCtsEnabled:
+ def test_tracker_enabled_passes(self):
+ cts_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.cts.cts_enabled.cts_enabled.cts_client",
+ new=cts_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.cts.cts_enabled.cts_enabled import (
+ cts_enabled,
+ )
+ from prowler.providers.huaweicloud.services.cts.cts_service import Tracker
+
+ tracker = Tracker(
+ id="tracker-1",
+ name="system",
+ status="enabled",
+ is_enabled=True,
+ region="la-south-2",
+ )
+ cts_client.trackers = [tracker]
+ cts_client.audited_account = "123456789012"
+
+ check = cts_enabled()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert "enabled" in result[0].status_extended
+
+ def test_tracker_disabled_fails(self):
+ cts_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.cts.cts_enabled.cts_enabled.cts_client",
+ new=cts_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.cts.cts_enabled.cts_enabled import (
+ cts_enabled,
+ )
+ from prowler.providers.huaweicloud.services.cts.cts_service import Tracker
+
+ tracker = Tracker(
+ id="tracker-1",
+ name="system",
+ status="disabled",
+ is_enabled=False,
+ region="la-south-2",
+ )
+ cts_client.trackers = [tracker]
+ cts_client.audited_account = "123456789012"
+
+ check = cts_enabled()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "not enabled" in result[0].status_extended
+
+ def test_no_trackers_fails(self):
+ cts_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.cts.cts_enabled.cts_enabled.cts_client",
+ new=cts_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.cts.cts_enabled.cts_enabled import (
+ cts_enabled,
+ )
+
+ cts_client.trackers = []
+ cts_client.audited_account = "123456789012"
+ cts_client.region = "la-south-2"
+
+ check = cts_enabled()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ # Singleton finding must carry a resource_name so reporting does
+ # not emit "has no resource_name".
+ assert result[0].resource_name == "123456789012-cts-tracker"
+ assert result[0].resource_id == "123456789012-cts-tracker"
diff --git a/tests/providers/huaweicloud/services/cts/huaweicloud_cts_service_test.py b/tests/providers/huaweicloud/services/cts/huaweicloud_cts_service_test.py
new file mode 100644
index 0000000000..3fea370106
--- /dev/null
+++ b/tests/providers/huaweicloud/services/cts/huaweicloud_cts_service_test.py
@@ -0,0 +1,81 @@
+from types import SimpleNamespace
+from unittest import mock
+
+from prowler.providers.huaweicloud.services.cts.cts_service import CTS, Tracker
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+REGION = "la-south-2"
+
+
+def _build_cts(service_client):
+ """Build a CTS service whose fetch runs against the given mocked client.
+
+ CTS is regional, so it fetches through the per-region clients returned by
+ ``generate_regional_clients`` and dispatched with ``__threading_call__``.
+ """
+ provider = set_mocked_huaweicloud_provider(region=REGION)
+ provider.generate_regional_clients = mock.MagicMock(
+ return_value={REGION: service_client}
+ )
+ return CTS(provider)
+
+
+class TestCTSService:
+ def test_list_trackers_parses_trackers(self):
+ trackers = [
+ SimpleNamespace(
+ id="tracker-1",
+ tracker_name="system",
+ tracker_type="system",
+ status="enabled",
+ obs_info=SimpleNamespace(
+ bucket_name="audit-bucket", file_prefix_name="cts/"
+ ),
+ ),
+ SimpleNamespace(
+ id="tracker-2",
+ tracker_name="data-tracker",
+ tracker_type="data",
+ status="disabled",
+ obs_info=None,
+ ),
+ ]
+ service_client = mock.MagicMock(region=REGION)
+ service_client.list_trackers.return_value = SimpleNamespace(trackers=trackers)
+
+ cts = _build_cts(service_client)
+
+ assert len(cts.trackers) == 2
+ by_id = {tracker.id: tracker for tracker in cts.trackers}
+
+ enabled = by_id["tracker-1"]
+ assert isinstance(enabled, Tracker)
+ assert enabled.name == "system"
+ assert enabled.region == REGION
+ assert enabled.is_enabled is True
+ assert enabled.bucket_name == "audit-bucket"
+ assert enabled.file_prefix_name == "cts/"
+
+ disabled = by_id["tracker-2"]
+ assert disabled.name == "data-tracker"
+ assert disabled.is_enabled is False
+ assert disabled.bucket_name == ""
+
+ def test_list_trackers_empty(self):
+ service_client = mock.MagicMock(region=REGION)
+ service_client.list_trackers.return_value = SimpleNamespace(trackers=[])
+
+ cts = _build_cts(service_client)
+
+ assert cts.trackers == []
+
+ def test_list_trackers_handles_sdk_error(self):
+ service_client = mock.MagicMock(region=REGION)
+ service_client.list_trackers.side_effect = Exception("boom")
+
+ cts = _build_cts(service_client)
+
+ # Errors are logged and swallowed; no partial/garbage resources.
+ assert cts.trackers == []
diff --git a/tests/providers/huaweicloud/services/ecs/ecs_instance_key_pair/ecs_instance_key_pair_test.py b/tests/providers/huaweicloud/services/ecs/ecs_instance_key_pair/ecs_instance_key_pair_test.py
new file mode 100644
index 0000000000..2c45846ad3
--- /dev/null
+++ b/tests/providers/huaweicloud/services/ecs/ecs_instance_key_pair/ecs_instance_key_pair_test.py
@@ -0,0 +1,103 @@
+from unittest import mock
+
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+
+class TestEcsInstanceKeyPair:
+ def test_instance_with_key_pair_passes(self):
+ ecs_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.ecs.ecs_instance_key_pair.ecs_instance_key_pair.ecs_client",
+ new=ecs_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.ecs.ecs_instance_key_pair.ecs_instance_key_pair import (
+ ecs_instance_key_pair,
+ )
+ from prowler.providers.huaweicloud.services.ecs.ecs_service import Instance
+
+ instance = Instance(
+ id="inst-1",
+ name="web-server",
+ region="la-south-2",
+ status="ACTIVE",
+ key_name="my-keypair",
+ )
+ ecs_client.instances = {instance.id: instance}
+ ecs_client.audited_account = "123456789012"
+
+ check = ecs_instance_key_pair()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert result[0].resource_id == "inst-1"
+ assert "my-keypair" in result[0].status_extended
+
+ def test_instance_without_key_pair_fails(self):
+ ecs_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.ecs.ecs_instance_key_pair.ecs_instance_key_pair.ecs_client",
+ new=ecs_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.ecs.ecs_instance_key_pair.ecs_instance_key_pair import (
+ ecs_instance_key_pair,
+ )
+ from prowler.providers.huaweicloud.services.ecs.ecs_service import Instance
+
+ instance = Instance(
+ id="inst-1",
+ name="web-server",
+ region="la-south-2",
+ status="ACTIVE",
+ key_name="",
+ )
+ ecs_client.instances = {instance.id: instance}
+ ecs_client.audited_account = "123456789012"
+
+ check = ecs_instance_key_pair()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "does not use an SSH key pair" in result[0].status_extended
+
+ def test_no_instances(self):
+ ecs_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.ecs.ecs_instance_key_pair.ecs_instance_key_pair.ecs_client",
+ new=ecs_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.ecs.ecs_instance_key_pair.ecs_instance_key_pair import (
+ ecs_instance_key_pair,
+ )
+
+ ecs_client.instances = {}
+ ecs_client.audited_account = "123456789012"
+
+ check = ecs_instance_key_pair()
+ result = check.execute()
+
+ assert len(result) == 0
diff --git a/tests/providers/huaweicloud/services/ecs/ecs_instance_no_default_security_group/ecs_instance_no_default_security_group_test.py b/tests/providers/huaweicloud/services/ecs/ecs_instance_no_default_security_group/ecs_instance_no_default_security_group_test.py
new file mode 100644
index 0000000000..946ab711c1
--- /dev/null
+++ b/tests/providers/huaweicloud/services/ecs/ecs_instance_no_default_security_group/ecs_instance_no_default_security_group_test.py
@@ -0,0 +1,104 @@
+from unittest import mock
+
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+
+class TestEcsInstanceNoDefaultSecurityGroup:
+ def test_instance_with_default_security_group_fails(self):
+ ecs_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.ecs.ecs_instance_no_default_security_group.ecs_instance_no_default_security_group.ecs_client",
+ new=ecs_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.ecs.ecs_instance_no_default_security_group.ecs_instance_no_default_security_group import (
+ ecs_instance_no_default_security_group,
+ )
+ from prowler.providers.huaweicloud.services.ecs.ecs_service import Instance
+
+ instance = Instance(
+ id="inst-1",
+ name="web-server",
+ region="la-south-2",
+ status="ACTIVE",
+ security_groups={"sg-001": "default"},
+ )
+ ecs_client.instances = {instance.id: instance}
+ ecs_client.audited_account = "123456789012"
+
+ check = ecs_instance_no_default_security_group()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "uses the default security group" in result[0].status_extended
+
+ def test_instance_without_default_security_group_passes(self):
+ ecs_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.ecs.ecs_instance_no_default_security_group.ecs_instance_no_default_security_group.ecs_client",
+ new=ecs_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.ecs.ecs_instance_no_default_security_group.ecs_instance_no_default_security_group import (
+ ecs_instance_no_default_security_group,
+ )
+ from prowler.providers.huaweicloud.services.ecs.ecs_service import Instance
+
+ instance = Instance(
+ id="inst-1",
+ name="web-server",
+ region="la-south-2",
+ status="ACTIVE",
+ security_groups={"sg-002": "custom-sg"},
+ )
+ ecs_client.instances = {instance.id: instance}
+ ecs_client.audited_account = "123456789012"
+
+ check = ecs_instance_no_default_security_group()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ "does not use the default security group" in result[0].status_extended
+ )
+
+ def test_no_instances(self):
+ ecs_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.ecs.ecs_instance_no_default_security_group.ecs_instance_no_default_security_group.ecs_client",
+ new=ecs_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.ecs.ecs_instance_no_default_security_group.ecs_instance_no_default_security_group import (
+ ecs_instance_no_default_security_group,
+ )
+
+ ecs_client.instances = {}
+ ecs_client.audited_account = "123456789012"
+
+ check = ecs_instance_no_default_security_group()
+ result = check.execute()
+
+ assert len(result) == 0
diff --git a/tests/providers/huaweicloud/services/ecs/ecs_instance_public_ip/ecs_instance_public_ip_test.py b/tests/providers/huaweicloud/services/ecs/ecs_instance_public_ip/ecs_instance_public_ip_test.py
new file mode 100644
index 0000000000..c32c51b974
--- /dev/null
+++ b/tests/providers/huaweicloud/services/ecs/ecs_instance_public_ip/ecs_instance_public_ip_test.py
@@ -0,0 +1,102 @@
+from unittest import mock
+
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+
+class TestEcsInstancePublicIp:
+ def test_instance_with_public_ip_fails(self):
+ ecs_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.ecs.ecs_instance_public_ip.ecs_instance_public_ip.ecs_client",
+ new=ecs_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.ecs.ecs_instance_public_ip.ecs_instance_public_ip import (
+ ecs_instance_public_ip,
+ )
+ from prowler.providers.huaweicloud.services.ecs.ecs_service import Instance
+
+ instance = Instance(
+ id="inst-1",
+ name="web-server",
+ region="la-south-2",
+ status="ACTIVE",
+ public_ip="1.2.3.4",
+ )
+ ecs_client.instances = {instance.id: instance}
+ ecs_client.audited_account = "123456789012"
+
+ check = ecs_instance_public_ip()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "1.2.3.4" in result[0].status_extended
+
+ def test_instance_without_public_ip_passes(self):
+ ecs_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.ecs.ecs_instance_public_ip.ecs_instance_public_ip.ecs_client",
+ new=ecs_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.ecs.ecs_instance_public_ip.ecs_instance_public_ip import (
+ ecs_instance_public_ip,
+ )
+ from prowler.providers.huaweicloud.services.ecs.ecs_service import Instance
+
+ instance = Instance(
+ id="inst-1",
+ name="internal-server",
+ region="la-south-2",
+ status="ACTIVE",
+ public_ip="",
+ )
+ ecs_client.instances = {instance.id: instance}
+ ecs_client.audited_account = "123456789012"
+
+ check = ecs_instance_public_ip()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert "does not have a public IP" in result[0].status_extended
+
+ def test_no_instances(self):
+ ecs_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.ecs.ecs_instance_public_ip.ecs_instance_public_ip.ecs_client",
+ new=ecs_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.ecs.ecs_instance_public_ip.ecs_instance_public_ip import (
+ ecs_instance_public_ip,
+ )
+
+ ecs_client.instances = {}
+ ecs_client.audited_account = "123456789012"
+
+ check = ecs_instance_public_ip()
+ result = check.execute()
+
+ assert len(result) == 0
diff --git a/tests/providers/huaweicloud/services/ecs/ecs_instance_security_groups_attached/ecs_instance_security_groups_attached_test.py b/tests/providers/huaweicloud/services/ecs/ecs_instance_security_groups_attached/ecs_instance_security_groups_attached_test.py
new file mode 100644
index 0000000000..1ea63ea924
--- /dev/null
+++ b/tests/providers/huaweicloud/services/ecs/ecs_instance_security_groups_attached/ecs_instance_security_groups_attached_test.py
@@ -0,0 +1,105 @@
+from unittest import mock
+
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+
+class TestEcsInstanceSecurityGroupsAttached:
+ def test_instance_with_security_groups_passes(self):
+ ecs_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.ecs.ecs_instance_security_groups_attached.ecs_instance_security_groups_attached.ecs_client",
+ new=ecs_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.ecs.ecs_instance_security_groups_attached.ecs_instance_security_groups_attached import (
+ ecs_instance_security_groups_attached,
+ )
+ from prowler.providers.huaweicloud.services.ecs.ecs_service import Instance
+
+ instance = Instance(
+ id="inst-1",
+ name="web-server",
+ region="la-south-2",
+ status="ACTIVE",
+ security_groups={"sg-001": "web-sg"},
+ )
+ ecs_client.instances = {instance.id: instance}
+ ecs_client.audited_account = "123456789012"
+
+ check = ecs_instance_security_groups_attached()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert "web-sg" in result[0].status_extended
+
+ def test_instance_without_security_groups_fails(self):
+ ecs_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.ecs.ecs_instance_security_groups_attached.ecs_instance_security_groups_attached.ecs_client",
+ new=ecs_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.ecs.ecs_instance_security_groups_attached.ecs_instance_security_groups_attached import (
+ ecs_instance_security_groups_attached,
+ )
+ from prowler.providers.huaweicloud.services.ecs.ecs_service import Instance
+
+ instance = Instance(
+ id="inst-1",
+ name="web-server",
+ region="la-south-2",
+ status="ACTIVE",
+ security_groups={},
+ )
+ ecs_client.instances = {instance.id: instance}
+ ecs_client.audited_account = "123456789012"
+
+ check = ecs_instance_security_groups_attached()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ "does not have any security groups attached"
+ in result[0].status_extended
+ )
+
+ def test_no_instances(self):
+ ecs_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.ecs.ecs_instance_security_groups_attached.ecs_instance_security_groups_attached.ecs_client",
+ new=ecs_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.ecs.ecs_instance_security_groups_attached.ecs_instance_security_groups_attached import (
+ ecs_instance_security_groups_attached,
+ )
+
+ ecs_client.instances = {}
+ ecs_client.audited_account = "123456789012"
+
+ check = ecs_instance_security_groups_attached()
+ result = check.execute()
+
+ assert len(result) == 0
diff --git a/tests/providers/huaweicloud/services/ecs/huaweicloud_ecs_service_test.py b/tests/providers/huaweicloud/services/ecs/huaweicloud_ecs_service_test.py
new file mode 100644
index 0000000000..33c6f66040
--- /dev/null
+++ b/tests/providers/huaweicloud/services/ecs/huaweicloud_ecs_service_test.py
@@ -0,0 +1,68 @@
+from types import SimpleNamespace
+from unittest import mock
+
+from prowler.providers.huaweicloud.services.ecs.ecs_service import ECS, Instance
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+REGION = "la-south-2"
+
+
+def _provider_with_client(regional_client):
+ """Return a mocked provider whose regional client is the given mock."""
+ provider = set_mocked_huaweicloud_provider(region=REGION)
+ provider.generate_regional_clients = mock.MagicMock(
+ return_value={REGION: regional_client}
+ )
+ return provider
+
+
+class TestECSService:
+ def test_list_servers_parses_instances(self):
+ server = SimpleNamespace(
+ id="ecs-1",
+ name="web-server",
+ status="ACTIVE",
+ flavor=None,
+ access_i_pv4="1.2.3.4",
+ security_groups=[SimpleNamespace(id="sg-1", name="web-sg")],
+ enterprise_project_id="",
+ created=None,
+ key_name="my-keypair",
+ metadata=None,
+ )
+ regional_client = mock.MagicMock(region=REGION)
+ regional_client.list_servers_details.return_value = SimpleNamespace(
+ count=1, servers=[server]
+ )
+
+ ecs = ECS(_provider_with_client(regional_client))
+
+ assert len(ecs.instances) == 1
+ instance = ecs.instances["ecs-1"]
+ assert isinstance(instance, Instance)
+ assert instance.name == "web-server"
+ assert instance.region == REGION
+ assert instance.public_ip == "1.2.3.4"
+ assert instance.key_name == "my-keypair"
+ assert instance.security_groups == {"sg-1": "web-sg"}
+
+ def test_list_servers_empty(self):
+ regional_client = mock.MagicMock(region=REGION)
+ regional_client.list_servers_details.return_value = SimpleNamespace(
+ count=0, servers=[]
+ )
+
+ ecs = ECS(_provider_with_client(regional_client))
+
+ assert ecs.instances == {}
+
+ def test_list_servers_handles_sdk_error(self):
+ regional_client = mock.MagicMock(region=REGION)
+ regional_client.list_servers_details.side_effect = Exception("boom")
+
+ ecs = ECS(_provider_with_client(regional_client))
+
+ # Errors are logged and swallowed; no partial/garbage resources.
+ assert ecs.instances == {}
diff --git a/tests/providers/huaweicloud/services/elb/elb_public_exposure/elb_public_exposure_test.py b/tests/providers/huaweicloud/services/elb/elb_public_exposure/elb_public_exposure_test.py
new file mode 100644
index 0000000000..45fc452680
--- /dev/null
+++ b/tests/providers/huaweicloud/services/elb/elb_public_exposure/elb_public_exposure_test.py
@@ -0,0 +1,103 @@
+from unittest import mock
+
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+
+class TestElbPublicExposure:
+ def test_public_load_balancer_fails(self):
+ elb_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.elb.elb_public_exposure.elb_public_exposure.elb_client",
+ new=elb_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.elb.elb_public_exposure.elb_public_exposure import (
+ elb_public_exposure,
+ )
+ from prowler.providers.huaweicloud.services.elb.elb_service import (
+ LoadBalancer,
+ )
+
+ lb = LoadBalancer(
+ id="lb-1",
+ name="public-lb",
+ is_public=True,
+ region="la-south-2",
+ )
+ elb_client.load_balancers = [lb]
+ elb_client.audited_account = "123456789012"
+
+ check = elb_public_exposure()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "public" in result[0].status_extended
+
+ def test_internal_load_balancer_passes(self):
+ elb_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.elb.elb_public_exposure.elb_public_exposure.elb_client",
+ new=elb_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.elb.elb_public_exposure.elb_public_exposure import (
+ elb_public_exposure,
+ )
+ from prowler.providers.huaweicloud.services.elb.elb_service import (
+ LoadBalancer,
+ )
+
+ lb = LoadBalancer(
+ id="lb-1",
+ name="internal-lb",
+ is_public=False,
+ region="la-south-2",
+ )
+ elb_client.load_balancers = [lb]
+ elb_client.audited_account = "123456789012"
+
+ check = elb_public_exposure()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+
+ def test_no_load_balancers(self):
+ elb_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.elb.elb_public_exposure.elb_public_exposure.elb_client",
+ new=elb_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.elb.elb_public_exposure.elb_public_exposure import (
+ elb_public_exposure,
+ )
+
+ elb_client.load_balancers = []
+ elb_client.audited_account = "123456789012"
+
+ check = elb_public_exposure()
+ result = check.execute()
+
+ assert len(result) == 0
diff --git a/tests/providers/huaweicloud/services/elb/huaweicloud_elb_service_test.py b/tests/providers/huaweicloud/services/elb/huaweicloud_elb_service_test.py
new file mode 100644
index 0000000000..533b848d80
--- /dev/null
+++ b/tests/providers/huaweicloud/services/elb/huaweicloud_elb_service_test.py
@@ -0,0 +1,102 @@
+from types import SimpleNamespace
+from unittest import mock
+
+from prowler.providers.huaweicloud.services.elb.elb_service import ELB, LoadBalancer
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+REGION = "la-south-2"
+
+
+def _provider_with_client(regional_client):
+ """Return a mocked provider whose regional client is the given mock."""
+ provider = set_mocked_huaweicloud_provider(region=REGION)
+ provider.generate_regional_clients = mock.MagicMock(
+ return_value={REGION: regional_client}
+ )
+ return provider
+
+
+class TestELBService:
+ def test_list_load_balancers_public_via_publicips(self):
+ lb_data = SimpleNamespace(
+ id="lb-1",
+ name="public-lb",
+ vip_address="10.0.0.5",
+ publicips=[SimpleNamespace(publicip_address="1.2.3.4")],
+ eips=None,
+ )
+ regional_client = mock.MagicMock(region=REGION)
+ regional_client.list_load_balancers.return_value = SimpleNamespace(
+ loadbalancers=[lb_data]
+ )
+
+ elb = ELB(_provider_with_client(regional_client))
+
+ assert len(elb.load_balancers) == 1
+ lb = elb.load_balancers[0]
+ assert isinstance(lb, LoadBalancer)
+ assert lb.id == "lb-1"
+ assert lb.name == "public-lb"
+ assert lb.vip_address == "10.0.0.5"
+ assert lb.public_ip == "1.2.3.4"
+ assert lb.is_public is True
+ assert lb.region == REGION
+
+ def test_list_load_balancers_public_via_eips(self):
+ lb_data = SimpleNamespace(
+ id="lb-2",
+ name="eip-lb",
+ vip_address="10.0.0.6",
+ publicips=None,
+ eips=[SimpleNamespace(eip_address="5.6.7.8")],
+ )
+ regional_client = mock.MagicMock(region=REGION)
+ regional_client.list_load_balancers.return_value = SimpleNamespace(
+ loadbalancers=[lb_data]
+ )
+
+ elb = ELB(_provider_with_client(regional_client))
+
+ lb = elb.load_balancers[0]
+ assert lb.public_ip == "5.6.7.8"
+ assert lb.is_public is True
+
+ def test_list_load_balancers_private(self):
+ lb_data = SimpleNamespace(
+ id="lb-3",
+ name="private-lb",
+ vip_address="10.0.0.7",
+ publicips=None,
+ eips=None,
+ )
+ regional_client = mock.MagicMock(region=REGION)
+ regional_client.list_load_balancers.return_value = SimpleNamespace(
+ loadbalancers=[lb_data]
+ )
+
+ elb = ELB(_provider_with_client(regional_client))
+
+ lb = elb.load_balancers[0]
+ assert lb.public_ip == ""
+ assert lb.is_public is False
+
+ def test_list_load_balancers_empty(self):
+ regional_client = mock.MagicMock(region=REGION)
+ regional_client.list_load_balancers.return_value = SimpleNamespace(
+ loadbalancers=[]
+ )
+
+ elb = ELB(_provider_with_client(regional_client))
+
+ assert elb.load_balancers == []
+
+ def test_list_load_balancers_handles_sdk_error(self):
+ regional_client = mock.MagicMock(region=REGION)
+ regional_client.list_load_balancers.side_effect = Exception("boom")
+
+ elb = ELB(_provider_with_client(regional_client))
+
+ # Errors are logged and swallowed; no partial/garbage resources.
+ assert elb.load_balancers == []
diff --git a/tests/providers/huaweicloud/services/evs/evs_volume_encryption/evs_volume_encryption_test.py b/tests/providers/huaweicloud/services/evs/evs_volume_encryption/evs_volume_encryption_test.py
new file mode 100644
index 0000000000..b39fc8b0be
--- /dev/null
+++ b/tests/providers/huaweicloud/services/evs/evs_volume_encryption/evs_volume_encryption_test.py
@@ -0,0 +1,101 @@
+from unittest import mock
+
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+
+class TestEvsVolumeEncryption:
+ def test_encrypted_volume_passes(self):
+ evs_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.evs.evs_volume_encryption.evs_volume_encryption.evs_client",
+ new=evs_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.evs.evs_service import Volume
+ from prowler.providers.huaweicloud.services.evs.evs_volume_encryption.evs_volume_encryption import (
+ evs_volume_encryption,
+ )
+
+ volume = Volume(
+ id="vol-1",
+ name="encrypted-vol",
+ is_encrypted=True,
+ kms_key_id="kms-key-1",
+ region="la-south-2",
+ )
+ evs_client.volumes = [volume]
+ evs_client.audited_account = "123456789012"
+
+ check = evs_volume_encryption()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert "encrypted" in result[0].status_extended
+
+ def test_unencrypted_volume_fails(self):
+ evs_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.evs.evs_volume_encryption.evs_volume_encryption.evs_client",
+ new=evs_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.evs.evs_service import Volume
+ from prowler.providers.huaweicloud.services.evs.evs_volume_encryption.evs_volume_encryption import (
+ evs_volume_encryption,
+ )
+
+ volume = Volume(
+ id="vol-1",
+ name="plain-vol",
+ is_encrypted=False,
+ region="la-south-2",
+ )
+ evs_client.volumes = [volume]
+ evs_client.audited_account = "123456789012"
+
+ check = evs_volume_encryption()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "not encrypted" in result[0].status_extended
+
+ def test_no_volumes(self):
+ evs_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.evs.evs_volume_encryption.evs_volume_encryption.evs_client",
+ new=evs_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.evs.evs_volume_encryption.evs_volume_encryption import (
+ evs_volume_encryption,
+ )
+
+ evs_client.volumes = []
+ evs_client.audited_account = "123456789012"
+
+ check = evs_volume_encryption()
+ result = check.execute()
+
+ assert len(result) == 0
diff --git a/tests/providers/huaweicloud/services/evs/huaweicloud_evs_service_test.py b/tests/providers/huaweicloud/services/evs/huaweicloud_evs_service_test.py
new file mode 100644
index 0000000000..581ae63f66
--- /dev/null
+++ b/tests/providers/huaweicloud/services/evs/huaweicloud_evs_service_test.py
@@ -0,0 +1,108 @@
+from types import SimpleNamespace
+from unittest import mock
+
+from prowler.providers.huaweicloud.services.evs.evs_service import EVS, Volume
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+REGION = "la-south-2"
+
+
+def _provider_with_client(regional_client):
+ """Return a mocked provider whose regional client is the given mock."""
+ provider = set_mocked_huaweicloud_provider(region=REGION)
+ provider.generate_regional_clients = mock.MagicMock(
+ return_value={REGION: regional_client}
+ )
+ return provider
+
+
+class TestEVSService:
+ def test_list_volumes_encrypted_via_flag(self):
+ vol_data = SimpleNamespace(
+ id="vol-1",
+ name="encrypted-vol",
+ encrypted=True,
+ metadata={"__system__cmkid": "cmk-123"},
+ )
+ regional_client = mock.MagicMock(region=REGION)
+ regional_client.list_volumes.return_value = SimpleNamespace(volumes=[vol_data])
+
+ evs = EVS(_provider_with_client(regional_client))
+
+ assert len(evs.volumes) == 1
+ vol = evs.volumes[0]
+ assert isinstance(vol, Volume)
+ assert vol.id == "vol-1"
+ assert vol.name == "encrypted-vol"
+ assert vol.is_encrypted is True
+ assert vol.kms_key_id == "cmk-123"
+ assert vol.region == REGION
+
+ def test_list_volumes_encrypted_via_metadata(self):
+ vol_data = SimpleNamespace(
+ id="vol-2",
+ name="meta-encrypted",
+ encrypted=False,
+ metadata={"__system__encrypted": "1", "__system__cmkid": "cmk-9"},
+ )
+ regional_client = mock.MagicMock(region=REGION)
+ regional_client.list_volumes.return_value = SimpleNamespace(volumes=[vol_data])
+
+ evs = EVS(_provider_with_client(regional_client))
+
+ vol = evs.volumes[0]
+ assert vol.is_encrypted is True
+ assert vol.kms_key_id == "cmk-9"
+
+ def test_list_volumes_not_encrypted(self):
+ vol_data = SimpleNamespace(
+ id="vol-3",
+ name="plain-vol",
+ encrypted=False,
+ metadata={"__system__encrypted": "0"},
+ )
+ regional_client = mock.MagicMock(region=REGION)
+ regional_client.list_volumes.return_value = SimpleNamespace(volumes=[vol_data])
+
+ evs = EVS(_provider_with_client(regional_client))
+
+ vol = evs.volumes[0]
+ assert vol.is_encrypted is False
+ assert vol.kms_key_id == ""
+
+ def test_list_volumes_none_metadata(self):
+ vol_data = SimpleNamespace(
+ id="vol-4",
+ name="no-meta-vol",
+ encrypted=False,
+ metadata=None,
+ )
+ regional_client = mock.MagicMock(region=REGION)
+ regional_client.list_volumes.return_value = SimpleNamespace(volumes=[vol_data])
+
+ evs = EVS(_provider_with_client(regional_client))
+
+ # None metadata must not crash the parser.
+ vol = evs.volumes[0]
+ assert vol.id == "vol-4"
+ assert vol.is_encrypted is False
+ assert vol.kms_key_id == ""
+
+ def test_list_volumes_empty(self):
+ regional_client = mock.MagicMock(region=REGION)
+ regional_client.list_volumes.return_value = SimpleNamespace(volumes=[])
+
+ evs = EVS(_provider_with_client(regional_client))
+
+ assert evs.volumes == []
+
+ def test_list_volumes_handles_sdk_error(self):
+ regional_client = mock.MagicMock(region=REGION)
+ regional_client.list_volumes.side_effect = Exception("boom")
+
+ evs = EVS(_provider_with_client(regional_client))
+
+ # Errors are logged and swallowed; no partial/garbage resources.
+ assert evs.volumes == []
diff --git a/tests/providers/huaweicloud/services/iam/huaweicloud_iam_service_test.py b/tests/providers/huaweicloud/services/iam/huaweicloud_iam_service_test.py
new file mode 100644
index 0000000000..c7f6d47b7b
--- /dev/null
+++ b/tests/providers/huaweicloud/services/iam/huaweicloud_iam_service_test.py
@@ -0,0 +1,156 @@
+from types import SimpleNamespace
+from unittest import mock
+
+from prowler.providers.huaweicloud.services.iam.iam_service import (
+ IAM,
+ IAMUser,
+ MFADevice,
+)
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+REGION = "la-south-2"
+DOMAIN_ID = "123456789012"
+
+
+def _build_client(
+ password_policy=None,
+ users=None,
+ mfa_devices=None,
+ operation_protection=True,
+):
+ """Build a single mock IAM client serving all four IAM fetch calls."""
+ client = mock.MagicMock()
+
+ if password_policy is None:
+ password_policy = SimpleNamespace(
+ minimum_password_length=8,
+ maximum_password_length=32,
+ minimum_password_age=1,
+ password_validity_period=90,
+ password_char_combination=3,
+ maximum_consecutive_identical_chars=2,
+ number_of_recent_passwords_disallowed=5,
+ password_not_username_or_invert=True,
+ )
+ client.show_domain_password_policy.return_value = SimpleNamespace(
+ password_policy=password_policy
+ )
+
+ if users is None:
+ users = []
+ client.keystone_list_users.return_value = SimpleNamespace(users=users)
+
+ if mfa_devices is None:
+ mfa_devices = []
+ client.list_user_mfa_devices.return_value = SimpleNamespace(
+ virtual_mfa_devices=mfa_devices
+ )
+
+ client.show_domain_protect_policy.return_value = SimpleNamespace(
+ protect_policy=SimpleNamespace(operation_protection=operation_protection)
+ )
+
+ return client
+
+
+def _provider_with_client(client):
+ """Return a mocked global-service provider whose single client is the mock."""
+ provider = set_mocked_huaweicloud_provider(region=REGION, domain_id=DOMAIN_ID)
+ provider.session.client = mock.MagicMock(return_value=client)
+ return provider
+
+
+class TestIAMService:
+ def test_parses_all_resources(self):
+ users = [
+ SimpleNamespace(
+ id="user-1",
+ name="alice",
+ enabled=True,
+ password_expires_at="2026-12-31T00:00:00Z",
+ ),
+ SimpleNamespace(
+ id="user-2",
+ name="bob",
+ enabled=False,
+ password_expires_at=None,
+ ),
+ ]
+ mfa_devices = [
+ SimpleNamespace(serial_number="mfa-serial-1", user_id="user-1"),
+ ]
+ client = _build_client(
+ users=users,
+ mfa_devices=mfa_devices,
+ operation_protection=True,
+ )
+
+ iam = IAM(_provider_with_client(client))
+
+ # Password policy
+ assert iam.password_policy.minimum_password_length == 8
+ assert iam.password_policy.maximum_password_length == 32
+ assert iam.password_policy.minimum_password_age == 1
+ assert iam.password_policy.password_validity_period == 90
+ assert iam.password_policy.password_char_combination == 3
+ assert iam.password_policy.maximum_consecutive_identical_chars == 2
+ assert iam.password_policy.number_of_recent_passwords_disallowed == 5
+ assert iam.password_policy.password_not_username_or_invert is True
+
+ # Users
+ assert len(iam.users) == 2
+ assert all(isinstance(u, IAMUser) for u in iam.users)
+ alice = iam.users[0]
+ assert alice.id == "user-1"
+ assert alice.name == "alice"
+ assert alice.enabled is True
+ assert alice.password_expires_at == "2026-12-31T00:00:00Z"
+ bob = iam.users[1]
+ assert bob.name == "bob"
+ assert bob.enabled is False
+
+ # MFA devices
+ assert len(iam.mfa_devices) == 1
+ assert isinstance(iam.mfa_devices[0], MFADevice)
+ assert iam.mfa_devices[0].serial_number == "mfa-serial-1"
+ assert iam.mfa_devices[0].user_id == "user-1"
+
+ # Operation protection
+ assert iam.operation_protection.enabled is True
+ assert iam.operation_protection.account_id == DOMAIN_ID
+
+ def test_operation_protection_disabled(self):
+ client = _build_client(operation_protection=False)
+
+ iam = IAM(_provider_with_client(client))
+
+ assert iam.operation_protection.enabled is False
+
+ def test_empty_users_and_mfa_devices(self):
+ client = _build_client(users=[], mfa_devices=[])
+
+ iam = IAM(_provider_with_client(client))
+
+ assert iam.users == []
+ assert iam.mfa_devices == []
+
+ def test_list_users_sdk_error_is_swallowed(self):
+ client = _build_client(
+ mfa_devices=[
+ SimpleNamespace(serial_number="mfa-serial-1", user_id="user-1")
+ ],
+ operation_protection=True,
+ )
+ # keystone_list_users raises; other fetches must still succeed.
+ client.keystone_list_users.side_effect = Exception("boom")
+
+ iam = IAM(_provider_with_client(client))
+
+ # Failed fetch leaves its default empty list.
+ assert iam.users == []
+ # Other fetches unaffected.
+ assert iam.password_policy.minimum_password_length == 8
+ assert len(iam.mfa_devices) == 1
+ assert iam.operation_protection.enabled is True
diff --git a/tests/providers/huaweicloud/services/iam/iam_account_password_policy/iam_account_password_policy_test.py b/tests/providers/huaweicloud/services/iam/iam_account_password_policy/iam_account_password_policy_test.py
new file mode 100644
index 0000000000..a1e4649042
--- /dev/null
+++ b/tests/providers/huaweicloud/services/iam/iam_account_password_policy/iam_account_password_policy_test.py
@@ -0,0 +1,100 @@
+from unittest import mock
+
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+
+class TestIamAccountPasswordPolicy:
+ def test_password_policy_min_length_14_passes(self):
+ iam_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.iam.iam_account_password_policy.iam_account_password_policy.iam_client",
+ new=iam_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.iam.iam_account_password_policy.iam_account_password_policy import (
+ iam_account_password_policy,
+ )
+ from prowler.providers.huaweicloud.services.iam.iam_service import (
+ PasswordPolicy,
+ )
+
+ iam_client.password_policy = PasswordPolicy(minimum_password_length=14)
+ iam_client.audited_account = "123456789012"
+ iam_client.region = "la-south-2"
+
+ check = iam_account_password_policy()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert "14" in result[0].status_extended
+
+ def test_password_policy_min_length_8_fails(self):
+ iam_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.iam.iam_account_password_policy.iam_account_password_policy.iam_client",
+ new=iam_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.iam.iam_account_password_policy.iam_account_password_policy import (
+ iam_account_password_policy,
+ )
+ from prowler.providers.huaweicloud.services.iam.iam_service import (
+ PasswordPolicy,
+ )
+
+ iam_client.password_policy = PasswordPolicy(minimum_password_length=8)
+ iam_client.audited_account = "123456789012"
+ iam_client.region = "la-south-2"
+
+ check = iam_account_password_policy()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "8" in result[0].status_extended
+ assert "14" in result[0].status_extended
+
+ def test_no_password_policy(self):
+ iam_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.iam.iam_account_password_policy.iam_account_password_policy.iam_client",
+ new=iam_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.iam.iam_account_password_policy.iam_account_password_policy import (
+ iam_account_password_policy,
+ )
+ from prowler.providers.huaweicloud.services.iam.iam_service import (
+ PasswordPolicy,
+ )
+
+ iam_client.password_policy = PasswordPolicy()
+ iam_client.audited_account = "123456789012"
+ iam_client.region = "la-south-2"
+
+ check = iam_account_password_policy()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
diff --git a/tests/providers/huaweicloud/services/iam/iam_password_policy_char_combination/iam_password_policy_char_combination_test.py b/tests/providers/huaweicloud/services/iam/iam_password_policy_char_combination/iam_password_policy_char_combination_test.py
new file mode 100644
index 0000000000..7c2b4bf532
--- /dev/null
+++ b/tests/providers/huaweicloud/services/iam/iam_password_policy_char_combination/iam_password_policy_char_combination_test.py
@@ -0,0 +1,99 @@
+from unittest import mock
+
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+
+class TestIamPasswordPolicyCharCombination:
+ def test_char_combination_3_passes(self):
+ iam_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.iam.iam_password_policy_char_combination.iam_password_policy_char_combination.iam_client",
+ new=iam_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.iam.iam_password_policy_char_combination.iam_password_policy_char_combination import (
+ iam_password_policy_char_combination,
+ )
+ from prowler.providers.huaweicloud.services.iam.iam_service import (
+ PasswordPolicy,
+ )
+
+ iam_client.password_policy = PasswordPolicy(
+ password_char_combination=3,
+ )
+ iam_client.audited_account = "123456789012"
+ iam_client.region = "la-south-2"
+
+ check = iam_password_policy_char_combination()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert "at least 3 character types" in result[0].status_extended
+
+ def test_char_combination_2_fails(self):
+ iam_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.iam.iam_password_policy_char_combination.iam_password_policy_char_combination.iam_client",
+ new=iam_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.iam.iam_password_policy_char_combination.iam_password_policy_char_combination import (
+ iam_password_policy_char_combination,
+ )
+ from prowler.providers.huaweicloud.services.iam.iam_service import (
+ PasswordPolicy,
+ )
+
+ iam_client.password_policy = PasswordPolicy(
+ password_char_combination=2,
+ )
+ iam_client.audited_account = "123456789012"
+ iam_client.region = "la-south-2"
+
+ check = iam_password_policy_char_combination()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "less than the recommended 3" in result[0].status_extended
+
+ def test_no_password_policy(self):
+ iam_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.iam.iam_password_policy_char_combination.iam_password_policy_char_combination.iam_client",
+ new=iam_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.iam.iam_password_policy_char_combination.iam_password_policy_char_combination import (
+ iam_password_policy_char_combination,
+ )
+
+ iam_client.password_policy = None
+ iam_client.audited_account = "123456789012"
+ iam_client.region = "la-south-2"
+
+ check = iam_password_policy_char_combination()
+ result = check.execute()
+
+ assert len(result) == 0
diff --git a/tests/providers/huaweicloud/services/iam/iam_password_policy_expires_passwords/iam_password_policy_expires_passwords_test.py b/tests/providers/huaweicloud/services/iam/iam_password_policy_expires_passwords/iam_password_policy_expires_passwords_test.py
new file mode 100644
index 0000000000..ac58055378
--- /dev/null
+++ b/tests/providers/huaweicloud/services/iam/iam_password_policy_expires_passwords/iam_password_policy_expires_passwords_test.py
@@ -0,0 +1,99 @@
+from unittest import mock
+
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+
+class TestIamPasswordPolicyExpiresPasswords:
+ def test_password_validity_period_set_passes(self):
+ iam_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.iam.iam_password_policy_expires_passwords.iam_password_policy_expires_passwords.iam_client",
+ new=iam_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.iam.iam_password_policy_expires_passwords.iam_password_policy_expires_passwords import (
+ iam_password_policy_expires_passwords,
+ )
+ from prowler.providers.huaweicloud.services.iam.iam_service import (
+ PasswordPolicy,
+ )
+
+ iam_client.password_policy = PasswordPolicy(
+ password_validity_period=90,
+ )
+ iam_client.audited_account = "123456789012"
+ iam_client.region = "la-south-2"
+
+ check = iam_password_policy_expires_passwords()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert "expire after 90 days" in result[0].status_extended
+
+ def test_password_validity_period_zero_fails(self):
+ iam_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.iam.iam_password_policy_expires_passwords.iam_password_policy_expires_passwords.iam_client",
+ new=iam_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.iam.iam_password_policy_expires_passwords.iam_password_policy_expires_passwords import (
+ iam_password_policy_expires_passwords,
+ )
+ from prowler.providers.huaweicloud.services.iam.iam_service import (
+ PasswordPolicy,
+ )
+
+ iam_client.password_policy = PasswordPolicy(
+ password_validity_period=0,
+ )
+ iam_client.audited_account = "123456789012"
+ iam_client.region = "la-south-2"
+
+ check = iam_password_policy_expires_passwords()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "does not require passwords to expire" in result[0].status_extended
+
+ def test_no_password_policy(self):
+ iam_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.iam.iam_password_policy_expires_passwords.iam_password_policy_expires_passwords.iam_client",
+ new=iam_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.iam.iam_password_policy_expires_passwords.iam_password_policy_expires_passwords import (
+ iam_password_policy_expires_passwords,
+ )
+
+ iam_client.password_policy = None
+ iam_client.audited_account = "123456789012"
+ iam_client.region = "la-south-2"
+
+ check = iam_password_policy_expires_passwords()
+ result = check.execute()
+
+ assert len(result) == 0
diff --git a/tests/providers/huaweicloud/services/iam/iam_password_policy_minimum_age/iam_password_policy_minimum_age_test.py b/tests/providers/huaweicloud/services/iam/iam_password_policy_minimum_age/iam_password_policy_minimum_age_test.py
new file mode 100644
index 0000000000..423eabb999
--- /dev/null
+++ b/tests/providers/huaweicloud/services/iam/iam_password_policy_minimum_age/iam_password_policy_minimum_age_test.py
@@ -0,0 +1,101 @@
+from unittest import mock
+
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+
+class TestIamPasswordPolicyMinimumAge:
+ def test_minimum_age_set_passes(self):
+ iam_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.iam.iam_password_policy_minimum_age.iam_password_policy_minimum_age.iam_client",
+ new=iam_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.iam.iam_password_policy_minimum_age.iam_password_policy_minimum_age import (
+ iam_password_policy_minimum_age,
+ )
+ from prowler.providers.huaweicloud.services.iam.iam_service import (
+ PasswordPolicy,
+ )
+
+ iam_client.password_policy = PasswordPolicy(
+ minimum_password_age=2,
+ )
+ iam_client.audited_account = "123456789012"
+ iam_client.region = "la-south-2"
+
+ check = iam_password_policy_minimum_age()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert "minimum password age of 2 days" in result[0].status_extended
+
+ def test_minimum_age_zero_fails(self):
+ iam_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.iam.iam_password_policy_minimum_age.iam_password_policy_minimum_age.iam_client",
+ new=iam_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.iam.iam_password_policy_minimum_age.iam_password_policy_minimum_age import (
+ iam_password_policy_minimum_age,
+ )
+ from prowler.providers.huaweicloud.services.iam.iam_service import (
+ PasswordPolicy,
+ )
+
+ iam_client.password_policy = PasswordPolicy(
+ minimum_password_age=0,
+ )
+ iam_client.audited_account = "123456789012"
+ iam_client.region = "la-south-2"
+
+ check = iam_password_policy_minimum_age()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ "does not enforce a minimum password age" in result[0].status_extended
+ )
+
+ def test_no_password_policy(self):
+ iam_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.iam.iam_password_policy_minimum_age.iam_password_policy_minimum_age.iam_client",
+ new=iam_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.iam.iam_password_policy_minimum_age.iam_password_policy_minimum_age import (
+ iam_password_policy_minimum_age,
+ )
+
+ iam_client.password_policy = None
+ iam_client.audited_account = "123456789012"
+ iam_client.region = "la-south-2"
+
+ check = iam_password_policy_minimum_age()
+ result = check.execute()
+
+ assert len(result) == 0
diff --git a/tests/providers/huaweicloud/services/iam/iam_password_policy_reuse_prevention/iam_password_policy_reuse_prevention_test.py b/tests/providers/huaweicloud/services/iam/iam_password_policy_reuse_prevention/iam_password_policy_reuse_prevention_test.py
new file mode 100644
index 0000000000..781c850667
--- /dev/null
+++ b/tests/providers/huaweicloud/services/iam/iam_password_policy_reuse_prevention/iam_password_policy_reuse_prevention_test.py
@@ -0,0 +1,101 @@
+from unittest import mock
+
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+
+class TestIamPasswordPolicyReusePrevention:
+ def test_reuse_prevention_3_passes(self):
+ iam_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.iam.iam_password_policy_reuse_prevention.iam_password_policy_reuse_prevention.iam_client",
+ new=iam_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.iam.iam_password_policy_reuse_prevention.iam_password_policy_reuse_prevention import (
+ iam_password_policy_reuse_prevention,
+ )
+ from prowler.providers.huaweicloud.services.iam.iam_service import (
+ PasswordPolicy,
+ )
+
+ iam_client.password_policy = PasswordPolicy(
+ number_of_recent_passwords_disallowed=3,
+ )
+ iam_client.audited_account = "123456789012"
+ iam_client.region = "la-south-2"
+
+ check = iam_password_policy_reuse_prevention()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ "disallows reuse of the last 3 passwords" in result[0].status_extended
+ )
+
+ def test_reuse_prevention_1_fails(self):
+ iam_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.iam.iam_password_policy_reuse_prevention.iam_password_policy_reuse_prevention.iam_client",
+ new=iam_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.iam.iam_password_policy_reuse_prevention.iam_password_policy_reuse_prevention import (
+ iam_password_policy_reuse_prevention,
+ )
+ from prowler.providers.huaweicloud.services.iam.iam_service import (
+ PasswordPolicy,
+ )
+
+ iam_client.password_policy = PasswordPolicy(
+ number_of_recent_passwords_disallowed=1,
+ )
+ iam_client.audited_account = "123456789012"
+ iam_client.region = "la-south-2"
+
+ check = iam_password_policy_reuse_prevention()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "less than the recommended 3" in result[0].status_extended
+
+ def test_no_password_policy(self):
+ iam_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.iam.iam_password_policy_reuse_prevention.iam_password_policy_reuse_prevention.iam_client",
+ new=iam_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.iam.iam_password_policy_reuse_prevention.iam_password_policy_reuse_prevention import (
+ iam_password_policy_reuse_prevention,
+ )
+
+ iam_client.password_policy = None
+ iam_client.audited_account = "123456789012"
+ iam_client.region = "la-south-2"
+
+ check = iam_password_policy_reuse_prevention()
+ result = check.execute()
+
+ assert len(result) == 0
diff --git a/tests/providers/huaweicloud/services/iam/iam_root_hardware_mfa_enabled/huaweicloud_iam_root_hardware_mfa_enabled_test.py b/tests/providers/huaweicloud/services/iam/iam_root_hardware_mfa_enabled/huaweicloud_iam_root_hardware_mfa_enabled_test.py
new file mode 100644
index 0000000000..7ca15668d7
--- /dev/null
+++ b/tests/providers/huaweicloud/services/iam/iam_root_hardware_mfa_enabled/huaweicloud_iam_root_hardware_mfa_enabled_test.py
@@ -0,0 +1,68 @@
+from unittest import mock
+
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+CHECK_MODULE = "prowler.providers.huaweicloud.services.iam.iam_root_hardware_mfa_enabled.iam_root_hardware_mfa_enabled"
+
+
+class TestIamRootHardwareMfaEnabled:
+ def test_operation_protection_enabled_passes(self):
+ iam_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(f"{CHECK_MODULE}.iam_client", new=iam_client),
+ ):
+ from prowler.providers.huaweicloud.services.iam.iam_root_hardware_mfa_enabled.iam_root_hardware_mfa_enabled import (
+ iam_root_hardware_mfa_enabled,
+ )
+ from prowler.providers.huaweicloud.services.iam.iam_service import (
+ OperationProtection,
+ )
+
+ iam_client.operation_protection = OperationProtection(
+ account_id="123456789012", enabled=True
+ )
+ iam_client.audited_account = "123456789012"
+ iam_client.region = "la-south-2"
+
+ result = iam_root_hardware_mfa_enabled().execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert result[0].resource_id == "123456789012-operation-protection"
+ assert "enabled" in result[0].status_extended
+
+ def test_operation_protection_disabled_fails(self):
+ iam_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(f"{CHECK_MODULE}.iam_client", new=iam_client),
+ ):
+ from prowler.providers.huaweicloud.services.iam.iam_root_hardware_mfa_enabled.iam_root_hardware_mfa_enabled import (
+ iam_root_hardware_mfa_enabled,
+ )
+ from prowler.providers.huaweicloud.services.iam.iam_service import (
+ OperationProtection,
+ )
+
+ iam_client.operation_protection = OperationProtection(
+ account_id="123456789012", enabled=False
+ )
+ iam_client.audited_account = "123456789012"
+ iam_client.region = "la-south-2"
+
+ result = iam_root_hardware_mfa_enabled().execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "not enabled" in result[0].status_extended
diff --git a/tests/providers/huaweicloud/services/iam/iam_user_disabled/iam_user_disabled_test.py b/tests/providers/huaweicloud/services/iam/iam_user_disabled/iam_user_disabled_test.py
new file mode 100644
index 0000000000..68b583a017
--- /dev/null
+++ b/tests/providers/huaweicloud/services/iam/iam_user_disabled/iam_user_disabled_test.py
@@ -0,0 +1,101 @@
+from unittest import mock
+
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+
+class TestIamUserDisabled:
+ def test_enabled_user_passes(self):
+ iam_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.iam.iam_user_disabled.iam_user_disabled.iam_client",
+ new=iam_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.iam.iam_service import IAMUser
+ from prowler.providers.huaweicloud.services.iam.iam_user_disabled.iam_user_disabled import (
+ iam_user_disabled,
+ )
+
+ user = IAMUser(
+ id="user-1",
+ name="active-user",
+ enabled=True,
+ )
+ iam_client.users = [user]
+ iam_client.audited_account = "123456789012"
+ iam_client.region = "la-south-2"
+
+ check = iam_user_disabled()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert "is enabled" in result[0].status_extended
+
+ def test_disabled_user_fails(self):
+ iam_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.iam.iam_user_disabled.iam_user_disabled.iam_client",
+ new=iam_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.iam.iam_service import IAMUser
+ from prowler.providers.huaweicloud.services.iam.iam_user_disabled.iam_user_disabled import (
+ iam_user_disabled,
+ )
+
+ user = IAMUser(
+ id="user-1",
+ name="inactive-user",
+ enabled=False,
+ )
+ iam_client.users = [user]
+ iam_client.audited_account = "123456789012"
+ iam_client.region = "la-south-2"
+
+ check = iam_user_disabled()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "is disabled" in result[0].status_extended
+
+ def test_no_users(self):
+ iam_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.iam.iam_user_disabled.iam_user_disabled.iam_client",
+ new=iam_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.iam.iam_user_disabled.iam_user_disabled import (
+ iam_user_disabled,
+ )
+
+ iam_client.users = []
+ iam_client.audited_account = "123456789012"
+ iam_client.region = "la-south-2"
+
+ check = iam_user_disabled()
+ result = check.execute()
+
+ assert len(result) == 0
diff --git a/tests/providers/huaweicloud/services/iam/iam_user_mfa_enabled/iam_user_mfa_enabled_test.py b/tests/providers/huaweicloud/services/iam/iam_user_mfa_enabled/iam_user_mfa_enabled_test.py
new file mode 100644
index 0000000000..898ac4b836
--- /dev/null
+++ b/tests/providers/huaweicloud/services/iam/iam_user_mfa_enabled/iam_user_mfa_enabled_test.py
@@ -0,0 +1,111 @@
+from unittest import mock
+
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+
+class TestIamUserMfaEnabled:
+ def test_user_with_mfa_passes(self):
+ iam_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.iam.iam_user_mfa_enabled.iam_user_mfa_enabled.iam_client",
+ new=iam_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.iam.iam_service import (
+ IAMUser,
+ MFADevice,
+ )
+ from prowler.providers.huaweicloud.services.iam.iam_user_mfa_enabled.iam_user_mfa_enabled import (
+ iam_user_mfa_enabled,
+ )
+
+ regular_user = IAMUser(
+ id="user-1",
+ name="regular-user",
+ )
+ mfa_device = MFADevice(
+ serial_number="mfa-1",
+ user_id="user-1",
+ )
+ iam_client.users = [regular_user]
+ iam_client.mfa_devices = [mfa_device]
+ iam_client.audited_account = "123456789012"
+ iam_client.region = "la-south-2"
+
+ check = iam_user_mfa_enabled()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert "MFA enabled" in result[0].status_extended
+
+ def test_user_without_mfa_fails(self):
+ iam_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.iam.iam_user_mfa_enabled.iam_user_mfa_enabled.iam_client",
+ new=iam_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.iam.iam_service import (
+ IAMUser,
+ )
+ from prowler.providers.huaweicloud.services.iam.iam_user_mfa_enabled.iam_user_mfa_enabled import (
+ iam_user_mfa_enabled,
+ )
+
+ regular_user = IAMUser(
+ id="user-1",
+ name="regular-user",
+ )
+ iam_client.users = [regular_user]
+ iam_client.mfa_devices = []
+ iam_client.audited_account = "123456789012"
+ iam_client.region = "la-south-2"
+
+ check = iam_user_mfa_enabled()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "does not have MFA enabled" in result[0].status_extended
+
+ def test_no_users(self):
+ iam_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.iam.iam_user_mfa_enabled.iam_user_mfa_enabled.iam_client",
+ new=iam_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.iam.iam_user_mfa_enabled.iam_user_mfa_enabled import (
+ iam_user_mfa_enabled,
+ )
+
+ iam_client.users = []
+ iam_client.mfa_devices = []
+ iam_client.audited_account = "123456789012"
+ iam_client.region = "la-south-2"
+
+ check = iam_user_mfa_enabled()
+ result = check.execute()
+
+ assert len(result) == 0
diff --git a/tests/providers/huaweicloud/services/kms/huaweicloud_kms_service_test.py b/tests/providers/huaweicloud/services/kms/huaweicloud_kms_service_test.py
new file mode 100644
index 0000000000..f01a5f6df3
--- /dev/null
+++ b/tests/providers/huaweicloud/services/kms/huaweicloud_kms_service_test.py
@@ -0,0 +1,106 @@
+from types import SimpleNamespace
+from unittest import mock
+
+from prowler.providers.huaweicloud.services.kms.kms_service import KMS, KMSKey
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+REGION = "la-south-2"
+
+
+def _provider_with_client(regional_client):
+ """Return a mocked provider whose regional client is the given mock."""
+ provider = set_mocked_huaweicloud_provider(region=REGION)
+ provider.generate_regional_clients = mock.MagicMock(
+ return_value={REGION: regional_client}
+ )
+ return provider
+
+
+class TestKMSService:
+ def test_list_keys_parses_keys(self):
+ key_data = SimpleNamespace(
+ key_id="key-1",
+ domain_id="domain-1",
+ key_alias="my-key",
+ key_state="2",
+ )
+ regional_client = mock.MagicMock(region=REGION)
+ regional_client.list_keys.return_value = SimpleNamespace(key_details=[key_data])
+ regional_client.show_key_rotation_status.return_value = SimpleNamespace(
+ key_rotation_enabled=True,
+ rotation_interval="365",
+ )
+
+ kms = KMS(_provider_with_client(regional_client))
+
+ assert len(kms.keys) == 1
+ key = kms.keys[0]
+ assert isinstance(key, KMSKey)
+ assert key.id == "key-1"
+ assert key.domain_id == "domain-1"
+ assert key.alias == "my-key"
+ assert key.state == "2"
+ assert key.is_rotation_enabled is True
+ assert key.rotation_period == "365"
+ assert key.region == REGION
+
+ def test_list_keys_rotation_disabled(self):
+ key_data = SimpleNamespace(
+ key_id="key-2",
+ domain_id="domain-1",
+ key_alias="",
+ key_state="2",
+ )
+ regional_client = mock.MagicMock(region=REGION)
+ regional_client.list_keys.return_value = SimpleNamespace(key_details=[key_data])
+ regional_client.show_key_rotation_status.return_value = SimpleNamespace(
+ key_rotation_enabled=False,
+ rotation_interval="",
+ )
+
+ kms = KMS(_provider_with_client(regional_client))
+
+ assert len(kms.keys) == 1
+ key = kms.keys[0]
+ assert key.state == "2"
+ assert key.is_rotation_enabled is False
+ assert key.rotation_period == ""
+
+ def test_list_keys_rotation_error_swallowed(self):
+ key_data = SimpleNamespace(
+ key_id="key-3",
+ domain_id="domain-1",
+ key_alias="k3",
+ key_state="2",
+ )
+ regional_client = mock.MagicMock(region=REGION)
+ regional_client.list_keys.return_value = SimpleNamespace(key_details=[key_data])
+ regional_client.show_key_rotation_status.side_effect = Exception("boom")
+
+ kms = KMS(_provider_with_client(regional_client))
+
+ # The key is still parsed; rotation defaults are used.
+ assert len(kms.keys) == 1
+ key = kms.keys[0]
+ assert key.id == "key-3"
+ assert key.is_rotation_enabled is False
+ assert key.rotation_period == ""
+
+ def test_list_keys_empty(self):
+ regional_client = mock.MagicMock(region=REGION)
+ regional_client.list_keys.return_value = SimpleNamespace(key_details=[])
+
+ kms = KMS(_provider_with_client(regional_client))
+
+ assert kms.keys == []
+
+ def test_list_keys_handles_sdk_error(self):
+ regional_client = mock.MagicMock(region=REGION)
+ regional_client.list_keys.side_effect = Exception("boom")
+
+ kms = KMS(_provider_with_client(regional_client))
+
+ # Errors are logged and swallowed; no partial/garbage resources.
+ assert kms.keys == []
diff --git a/tests/providers/huaweicloud/services/kms/kms_key_not_pending_deletion/kms_key_not_pending_deletion_test.py b/tests/providers/huaweicloud/services/kms/kms_key_not_pending_deletion/kms_key_not_pending_deletion_test.py
new file mode 100644
index 0000000000..5f255e8a9f
--- /dev/null
+++ b/tests/providers/huaweicloud/services/kms/kms_key_not_pending_deletion/kms_key_not_pending_deletion_test.py
@@ -0,0 +1,104 @@
+from unittest import mock
+
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+
+class TestKmsKeyNotPendingDeletion:
+ def test_key_active_passes(self):
+ kms_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.kms.kms_key_not_pending_deletion.kms_key_not_pending_deletion.kms_client",
+ new=kms_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.kms.kms_key_not_pending_deletion.kms_key_not_pending_deletion import (
+ kms_key_not_pending_deletion,
+ )
+ from prowler.providers.huaweicloud.services.kms.kms_service import KMSKey
+
+ key = KMSKey(
+ id="key-1",
+ alias="alias/key-1",
+ state="1",
+ is_rotation_enabled=False,
+ rotation_period=0,
+ region="la-south-2",
+ )
+ kms_client.keys = [key]
+ kms_client.audited_account = "123456789012"
+
+ check = kms_key_not_pending_deletion()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert "not in pending deletion state" in result[0].status_extended
+
+ def test_key_pending_deletion_fails(self):
+ kms_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.kms.kms_key_not_pending_deletion.kms_key_not_pending_deletion.kms_client",
+ new=kms_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.kms.kms_key_not_pending_deletion.kms_key_not_pending_deletion import (
+ kms_key_not_pending_deletion,
+ )
+ from prowler.providers.huaweicloud.services.kms.kms_service import KMSKey
+
+ key = KMSKey(
+ id="key-1",
+ alias="alias/key-1",
+ state="4",
+ is_rotation_enabled=False,
+ rotation_period=0,
+ region="la-south-2",
+ )
+ kms_client.keys = [key]
+ kms_client.audited_account = "123456789012"
+
+ check = kms_key_not_pending_deletion()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "pending deletion state" in result[0].status_extended
+
+ def test_no_keys(self):
+ kms_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.kms.kms_key_not_pending_deletion.kms_key_not_pending_deletion.kms_client",
+ new=kms_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.kms.kms_key_not_pending_deletion.kms_key_not_pending_deletion import (
+ kms_key_not_pending_deletion,
+ )
+
+ kms_client.keys = []
+ kms_client.audited_account = "123456789012"
+
+ check = kms_key_not_pending_deletion()
+ result = check.execute()
+
+ assert len(result) == 0
diff --git a/tests/providers/huaweicloud/services/kms/kms_key_rotation_enabled/huaweicloud_kms_key_rotation_enabled_test.py b/tests/providers/huaweicloud/services/kms/kms_key_rotation_enabled/huaweicloud_kms_key_rotation_enabled_test.py
new file mode 100644
index 0000000000..4daa3531ac
--- /dev/null
+++ b/tests/providers/huaweicloud/services/kms/kms_key_rotation_enabled/huaweicloud_kms_key_rotation_enabled_test.py
@@ -0,0 +1,100 @@
+from unittest import mock
+
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+
+class TestKmsKeyRotationEnabled:
+ def test_rotation_enabled_passes(self):
+ kms_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled.kms_client",
+ new=kms_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled import (
+ kms_key_rotation_enabled,
+ )
+ from prowler.providers.huaweicloud.services.kms.kms_service import KMSKey
+
+ key = KMSKey(
+ id="key-1",
+ alias="rotated-key",
+ is_rotation_enabled=True,
+ region="la-south-2",
+ )
+ kms_client.keys = [key]
+ kms_client.audited_account = "123456789012"
+
+ check = kms_key_rotation_enabled()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert "enabled" in result[0].status_extended
+
+ def test_rotation_disabled_fails(self):
+ kms_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled.kms_client",
+ new=kms_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled import (
+ kms_key_rotation_enabled,
+ )
+ from prowler.providers.huaweicloud.services.kms.kms_service import KMSKey
+
+ key = KMSKey(
+ id="key-1",
+ alias="static-key",
+ is_rotation_enabled=False,
+ region="la-south-2",
+ )
+ kms_client.keys = [key]
+ kms_client.audited_account = "123456789012"
+
+ check = kms_key_rotation_enabled()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "does not have rotation enabled" in result[0].status_extended
+
+ def test_no_keys(self):
+ kms_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled.kms_client",
+ new=kms_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled import (
+ kms_key_rotation_enabled,
+ )
+
+ kms_client.keys = []
+ kms_client.audited_account = "123456789012"
+
+ check = kms_key_rotation_enabled()
+ result = check.execute()
+
+ assert len(result) == 0
diff --git a/tests/providers/huaweicloud/services/obs/huaweicloud_obs_service_test.py b/tests/providers/huaweicloud/services/obs/huaweicloud_obs_service_test.py
new file mode 100644
index 0000000000..87c7809263
--- /dev/null
+++ b/tests/providers/huaweicloud/services/obs/huaweicloud_obs_service_test.py
@@ -0,0 +1,85 @@
+from types import SimpleNamespace
+from unittest import mock
+
+from prowler.providers.huaweicloud.services.obs.obs_service import OBS, Bucket
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+REGION = "la-south-2"
+
+
+def _provider_with_client(service_client):
+ """Return a mocked provider whose single (global) client is the given mock."""
+ provider = set_mocked_huaweicloud_provider(region=REGION)
+ provider.session.client = mock.MagicMock(return_value=service_client)
+ return provider
+
+
+def _buckets_response(*bucket_items):
+ """Wrap bucket items in the nested OBS SDK response shape (.buckets.bucket)."""
+ return SimpleNamespace(buckets=SimpleNamespace(bucket=list(bucket_items)))
+
+
+class TestOBSService:
+ def test_list_buckets_parses_buckets(self):
+ bucket_items = [
+ SimpleNamespace(name="public-acl", location="ap-southeast-1"),
+ SimpleNamespace(name="public-policy", location="ap-southeast-2"),
+ SimpleNamespace(name="private-bucket", location=None),
+ ]
+ service_client = mock.MagicMock(region=REGION)
+ service_client.list_buckets.return_value = _buckets_response(*bucket_items)
+
+ def public_status(request):
+ # public-acl is public via the bucket public status endpoint.
+ return SimpleNamespace(is_public=request.bucket_name == "public-acl")
+
+ def policy_public_status(request):
+ # public-policy is public via the bucket policy public status endpoint.
+ return SimpleNamespace(is_public=request.bucket_name == "public-policy")
+
+ service_client.get_bucket_public_status.side_effect = public_status
+ service_client.get_bucket_policy_public_status.side_effect = (
+ policy_public_status
+ )
+
+ obs = OBS(_provider_with_client(service_client))
+
+ assert len(obs.buckets) == 3
+ by_name = {bucket.name: bucket for bucket in obs.buckets}
+
+ acl_public = by_name["public-acl"]
+ assert isinstance(acl_public, Bucket)
+ assert acl_public.region == "ap-southeast-1"
+ assert acl_public.is_public is True
+ assert acl_public.acl == "public"
+
+ # Public via the policy status endpoint (bucket public status is False).
+ policy_public = by_name["public-policy"]
+ assert policy_public.region == "ap-southeast-2"
+ assert policy_public.is_public is True
+ assert policy_public.acl == "public"
+
+ # Neither endpoint reports public; location None falls back to the region.
+ private = by_name["private-bucket"]
+ assert private.region == REGION
+ assert private.is_public is False
+ assert private.acl == "private"
+
+ def test_list_buckets_empty(self):
+ service_client = mock.MagicMock(region=REGION)
+ service_client.list_buckets.return_value = _buckets_response()
+
+ obs = OBS(_provider_with_client(service_client))
+
+ assert obs.buckets == []
+
+ def test_list_buckets_handles_sdk_error(self):
+ service_client = mock.MagicMock(region=REGION)
+ service_client.list_buckets.side_effect = Exception("boom")
+
+ obs = OBS(_provider_with_client(service_client))
+
+ # Errors are logged and swallowed; no partial/garbage resources.
+ assert obs.buckets == []
diff --git a/tests/providers/huaweicloud/services/obs/obs_bucket_public_access/obs_bucket_public_access_test.py b/tests/providers/huaweicloud/services/obs/obs_bucket_public_access/obs_bucket_public_access_test.py
new file mode 100644
index 0000000000..d446a75d34
--- /dev/null
+++ b/tests/providers/huaweicloud/services/obs/obs_bucket_public_access/obs_bucket_public_access_test.py
@@ -0,0 +1,98 @@
+from unittest import mock
+
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+
+class TestObsBucketPublicAccess:
+ def test_private_bucket_passes(self):
+ obs_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.obs.obs_bucket_public_access.obs_bucket_public_access.obs_client",
+ new=obs_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.obs.obs_bucket_public_access.obs_bucket_public_access import (
+ obs_bucket_public_access,
+ )
+ from prowler.providers.huaweicloud.services.obs.obs_service import Bucket
+
+ bucket = Bucket(
+ name="private-bucket",
+ is_public=False,
+ region="la-south-2",
+ )
+ obs_client.buckets = [bucket]
+ obs_client.audited_account = "123456789012"
+
+ check = obs_bucket_public_access()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert "not public" in result[0].status_extended
+
+ def test_public_bucket_fails(self):
+ obs_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.obs.obs_bucket_public_access.obs_bucket_public_access.obs_client",
+ new=obs_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.obs.obs_bucket_public_access.obs_bucket_public_access import (
+ obs_bucket_public_access,
+ )
+ from prowler.providers.huaweicloud.services.obs.obs_service import Bucket
+
+ bucket = Bucket(
+ name="public-bucket",
+ is_public=True,
+ region="la-south-2",
+ )
+ obs_client.buckets = [bucket]
+ obs_client.audited_account = "123456789012"
+
+ check = obs_bucket_public_access()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "public" in result[0].status_extended
+
+ def test_no_buckets(self):
+ obs_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.obs.obs_bucket_public_access.obs_bucket_public_access.obs_client",
+ new=obs_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.obs.obs_bucket_public_access.obs_bucket_public_access import (
+ obs_bucket_public_access,
+ )
+
+ obs_client.buckets = []
+ obs_client.audited_account = "123456789012"
+
+ check = obs_bucket_public_access()
+ result = check.execute()
+
+ assert len(result) == 0
diff --git a/tests/providers/huaweicloud/services/rds/huaweicloud_rds_service_test.py b/tests/providers/huaweicloud/services/rds/huaweicloud_rds_service_test.py
new file mode 100644
index 0000000000..866626648b
--- /dev/null
+++ b/tests/providers/huaweicloud/services/rds/huaweicloud_rds_service_test.py
@@ -0,0 +1,91 @@
+from types import SimpleNamespace
+from unittest import mock
+
+from prowler.providers.huaweicloud.services.rds.rds_service import RDS, RDSInstance
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+REGION = "la-south-2"
+
+
+def _provider_with_client(regional_client):
+ """Return a mocked provider whose regional client is the given mock.
+
+ RDS iterates ``self.regional_clients`` in ``_list_instances``, so the
+ controlled client is wired through ``generate_regional_clients``.
+ """
+ provider = set_mocked_huaweicloud_provider(region=REGION)
+ provider.generate_regional_clients = mock.MagicMock(
+ return_value={REGION: regional_client}
+ )
+ return provider
+
+
+class TestRDSService:
+ def test_list_instances_parses(self):
+ public_instance = SimpleNamespace(
+ id="rds-public",
+ name="public-db",
+ status="ACTIVE",
+ public_ips=["1.2.3.4"],
+ backup_strategy=SimpleNamespace(keep_days=7),
+ datastore=SimpleNamespace(type="MySQL", version="8.0"),
+ disk_encryption_id="kms-key-1",
+ )
+ private_instance = SimpleNamespace(
+ id="rds-private",
+ name="private-db",
+ status="ACTIVE",
+ public_ips=[],
+ backup_strategy=SimpleNamespace(keep_days=0),
+ datastore=SimpleNamespace(type="PostgreSQL", version="14"),
+ disk_encryption_id="",
+ )
+
+ regional_client = mock.MagicMock(region=REGION)
+ regional_client.list_instances.return_value = SimpleNamespace(
+ instances=[public_instance, private_instance]
+ )
+
+ rds = RDS(_provider_with_client(regional_client))
+
+ assert len(rds.instances) == 2
+ by_id = {inst.id: inst for inst in rds.instances}
+
+ public_db = by_id["rds-public"]
+ assert isinstance(public_db, RDSInstance)
+ assert public_db.name == "public-db"
+ assert public_db.region == REGION
+ assert public_db.engine == "MySQL"
+ assert public_db.engine_version == "8.0"
+ # is_public derives from public_ips list
+ assert public_db.is_public is True
+ assert public_db.public_ip == "1.2.3.4"
+ # backup_enabled derives from backup_strategy.keep_days
+ assert public_db.backup_enabled is True
+ assert public_db.disk_encryption_id == "kms-key-1"
+
+ private_db = by_id["rds-private"]
+ assert private_db.is_public is False
+ assert private_db.public_ip == ""
+ assert private_db.backup_enabled is False
+ assert private_db.engine == "PostgreSQL"
+ assert private_db.disk_encryption_id == ""
+
+ def test_list_instances_empty(self):
+ regional_client = mock.MagicMock(region=REGION)
+ regional_client.list_instances.return_value = SimpleNamespace(instances=[])
+
+ rds = RDS(_provider_with_client(regional_client))
+
+ assert rds.instances == []
+
+ def test_list_instances_handles_sdk_error(self):
+ regional_client = mock.MagicMock(region=REGION)
+ regional_client.list_instances.side_effect = Exception("boom")
+
+ rds = RDS(_provider_with_client(regional_client))
+
+ # Errors are logged and swallowed; no partial/garbage resources.
+ assert rds.instances == []
diff --git a/tests/providers/huaweicloud/services/rds/rds_backup_enabled/rds_backup_enabled_test.py b/tests/providers/huaweicloud/services/rds/rds_backup_enabled/rds_backup_enabled_test.py
new file mode 100644
index 0000000000..7349ac4456
--- /dev/null
+++ b/tests/providers/huaweicloud/services/rds/rds_backup_enabled/rds_backup_enabled_test.py
@@ -0,0 +1,104 @@
+from unittest import mock
+
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+
+class TestRdsBackupEnabled:
+ def test_backup_enabled_passes(self):
+ rds_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.rds.rds_backup_enabled.rds_backup_enabled.rds_client",
+ new=rds_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.rds.rds_backup_enabled.rds_backup_enabled import (
+ rds_backup_enabled,
+ )
+ from prowler.providers.huaweicloud.services.rds.rds_service import (
+ RDSInstance,
+ )
+
+ instance = RDSInstance(
+ id="rds-1",
+ name="backed-up-db",
+ backup_enabled=True,
+ region="la-south-2",
+ )
+ rds_client.instances = [instance]
+ rds_client.audited_account = "123456789012"
+
+ check = rds_backup_enabled()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert "backup enabled" in result[0].status_extended
+
+ def test_backup_disabled_fails(self):
+ rds_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.rds.rds_backup_enabled.rds_backup_enabled.rds_client",
+ new=rds_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.rds.rds_backup_enabled.rds_backup_enabled import (
+ rds_backup_enabled,
+ )
+ from prowler.providers.huaweicloud.services.rds.rds_service import (
+ RDSInstance,
+ )
+
+ instance = RDSInstance(
+ id="rds-1",
+ name="no-backup-db",
+ backup_enabled=False,
+ region="la-south-2",
+ )
+ rds_client.instances = [instance]
+ rds_client.audited_account = "123456789012"
+
+ check = rds_backup_enabled()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "does not have automated backup" in result[0].status_extended
+
+ def test_no_instances(self):
+ rds_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.rds.rds_backup_enabled.rds_backup_enabled.rds_client",
+ new=rds_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.rds.rds_backup_enabled.rds_backup_enabled import (
+ rds_backup_enabled,
+ )
+
+ rds_client.instances = []
+ rds_client.audited_account = "123456789012"
+
+ check = rds_backup_enabled()
+ result = check.execute()
+
+ assert len(result) == 0
diff --git a/tests/providers/huaweicloud/services/rds/rds_instance_disk_encryption/rds_instance_disk_encryption_test.py b/tests/providers/huaweicloud/services/rds/rds_instance_disk_encryption/rds_instance_disk_encryption_test.py
new file mode 100644
index 0000000000..92aabacaa8
--- /dev/null
+++ b/tests/providers/huaweicloud/services/rds/rds_instance_disk_encryption/rds_instance_disk_encryption_test.py
@@ -0,0 +1,105 @@
+from unittest import mock
+
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+
+class TestRdsInstanceDiskEncryption:
+ def test_instance_with_disk_encryption_passes(self):
+ rds_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.rds.rds_instance_disk_encryption.rds_instance_disk_encryption.rds_client",
+ new=rds_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.rds.rds_instance_disk_encryption.rds_instance_disk_encryption import (
+ rds_instance_disk_encryption,
+ )
+ from prowler.providers.huaweicloud.services.rds.rds_service import (
+ RDSInstance,
+ )
+
+ instance = RDSInstance(
+ id="rds-1",
+ name="encrypted-db",
+ region="la-south-2",
+ disk_encryption_id="kms-key-123",
+ )
+ rds_client.instances = [instance]
+ rds_client.audited_account = "123456789012"
+
+ check = rds_instance_disk_encryption()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert result[0].resource_id == "rds-1"
+ assert "kms-key-123" in result[0].status_extended
+
+ def test_instance_without_disk_encryption_fails(self):
+ rds_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.rds.rds_instance_disk_encryption.rds_instance_disk_encryption.rds_client",
+ new=rds_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.rds.rds_instance_disk_encryption.rds_instance_disk_encryption import (
+ rds_instance_disk_encryption,
+ )
+ from prowler.providers.huaweicloud.services.rds.rds_service import (
+ RDSInstance,
+ )
+
+ instance = RDSInstance(
+ id="rds-1",
+ name="plain-db",
+ region="la-south-2",
+ disk_encryption_id="",
+ )
+ rds_client.instances = [instance]
+ rds_client.audited_account = "123456789012"
+
+ check = rds_instance_disk_encryption()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "does not have disk encryption enabled" in result[0].status_extended
+
+ def test_no_instances(self):
+ rds_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.rds.rds_instance_disk_encryption.rds_instance_disk_encryption.rds_client",
+ new=rds_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.rds.rds_instance_disk_encryption.rds_instance_disk_encryption import (
+ rds_instance_disk_encryption,
+ )
+
+ rds_client.instances = []
+ rds_client.audited_account = "123456789012"
+
+ check = rds_instance_disk_encryption()
+ result = check.execute()
+
+ assert len(result) == 0
diff --git a/tests/providers/huaweicloud/services/rds/rds_public_access/rds_public_access_test.py b/tests/providers/huaweicloud/services/rds/rds_public_access/rds_public_access_test.py
new file mode 100644
index 0000000000..d2452970a6
--- /dev/null
+++ b/tests/providers/huaweicloud/services/rds/rds_public_access/rds_public_access_test.py
@@ -0,0 +1,106 @@
+from unittest import mock
+
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+
+class TestRdsPublicAccess:
+ def test_public_instance_fails(self):
+ rds_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.rds.rds_public_access.rds_public_access.rds_client",
+ new=rds_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.rds.rds_public_access.rds_public_access import (
+ rds_public_access,
+ )
+ from prowler.providers.huaweicloud.services.rds.rds_service import (
+ RDSInstance,
+ )
+
+ instance = RDSInstance(
+ id="rds-1",
+ name="public-db",
+ public_ip="1.2.3.4",
+ is_public=True,
+ region="la-south-2",
+ )
+ rds_client.instances = [instance]
+ rds_client.audited_account = "123456789012"
+
+ check = rds_public_access()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "1.2.3.4" in result[0].status_extended
+
+ def test_private_instance_passes(self):
+ rds_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.rds.rds_public_access.rds_public_access.rds_client",
+ new=rds_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.rds.rds_public_access.rds_public_access import (
+ rds_public_access,
+ )
+ from prowler.providers.huaweicloud.services.rds.rds_service import (
+ RDSInstance,
+ )
+
+ instance = RDSInstance(
+ id="rds-1",
+ name="private-db",
+ public_ip="",
+ is_public=False,
+ region="la-south-2",
+ )
+ rds_client.instances = [instance]
+ rds_client.audited_account = "123456789012"
+
+ check = rds_public_access()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert "does not have a public IP" in result[0].status_extended
+
+ def test_no_instances(self):
+ rds_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.rds.rds_public_access.rds_public_access.rds_client",
+ new=rds_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.rds.rds_public_access.rds_public_access import (
+ rds_public_access,
+ )
+
+ rds_client.instances = []
+ rds_client.audited_account = "123456789012"
+
+ check = rds_public_access()
+ result = check.execute()
+
+ assert len(result) == 0
diff --git a/tests/providers/huaweicloud/services/vpc/huaweicloud_vpc_service_test.py b/tests/providers/huaweicloud/services/vpc/huaweicloud_vpc_service_test.py
new file mode 100644
index 0000000000..54b289aa99
--- /dev/null
+++ b/tests/providers/huaweicloud/services/vpc/huaweicloud_vpc_service_test.py
@@ -0,0 +1,163 @@
+from types import SimpleNamespace
+from unittest import mock
+
+from prowler.providers.huaweicloud.services.vpc.vpc_service import (
+ VPC,
+ SecurityGroupRule,
+ SecurityGroups,
+ VPCs,
+)
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+REGION = "la-south-2"
+
+
+def _provider_with_client(regional_client):
+ """Return a mocked provider whose regional client is the given mock."""
+ provider = set_mocked_huaweicloud_provider(region=REGION)
+ provider.generate_regional_clients = mock.MagicMock(
+ return_value={REGION: regional_client}
+ )
+ return provider
+
+
+class TestVPCService:
+ def test_list_vpcs_and_security_groups_parses(self):
+ vpc = SimpleNamespace(
+ id="vpc-1",
+ name="default-vpc",
+ cidr="10.0.0.0/16",
+ status="ACTIVE",
+ description="primary vpc",
+ created_at="2024-01-01T00:00:00Z",
+ )
+ rule = SimpleNamespace(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ethertype="IPv4",
+ port_range_min=22,
+ port_range_max=22,
+ remote_ip_prefix="0.0.0.0/0",
+ remote_group_id="",
+ description="ssh open",
+ )
+ sg = SimpleNamespace(
+ id="sg-1",
+ name="web-sg",
+ vpc_id="vpc-1",
+ description="web security group",
+ security_group_rules=[rule],
+ )
+
+ regional_client = mock.MagicMock(region=REGION)
+ regional_client.list_vpcs.return_value = SimpleNamespace(vpcs=[vpc])
+ regional_client.list_security_groups.return_value = SimpleNamespace(
+ security_groups=[sg]
+ )
+
+ vpc_service = VPC(_provider_with_client(regional_client))
+
+ # VPCs
+ assert len(vpc_service.vpcs) == 1
+ parsed_vpc = vpc_service.vpcs["vpc-1"]
+ assert isinstance(parsed_vpc, VPCs)
+ assert parsed_vpc.name == "default-vpc"
+ assert parsed_vpc.region == REGION
+ assert parsed_vpc.cidr == "10.0.0.0/16"
+ assert parsed_vpc.status == "ACTIVE"
+
+ # Security Groups
+ assert len(vpc_service.security_groups) == 1
+ parsed_sg = vpc_service.security_groups["sg-1"]
+ assert isinstance(parsed_sg, SecurityGroups)
+ assert parsed_sg.name == "web-sg"
+ assert parsed_sg.region == REGION
+ assert parsed_sg.vpc_id == "vpc-1"
+ assert len(parsed_sg.rules) == 1
+
+ # Security Group Rule (fields the checks depend on)
+ parsed_rule = parsed_sg.rules[0]
+ assert isinstance(parsed_rule, SecurityGroupRule)
+ assert parsed_rule.direction == "ingress"
+ assert parsed_rule.protocol == "tcp"
+ assert parsed_rule.remote_ip_prefix == "0.0.0.0/0"
+ assert parsed_rule.port_range_min == 22
+ assert parsed_rule.port_range_max == 22
+
+ def test_rule_fields_none_from_sdk_are_coerced(self):
+ # The Huawei SDK returns optional rule fields explicitly set to None
+ # (protocol/remote_ip_prefix/description), which must not raise a
+ # pydantic ValidationError.
+ rule = SimpleNamespace(
+ id="rule-1",
+ direction=None,
+ protocol=None,
+ ethertype=None,
+ port_range_min=None,
+ port_range_max=None,
+ remote_ip_prefix=None,
+ remote_group_id=None,
+ description=None,
+ )
+ sg = SimpleNamespace(
+ id="sg-1",
+ name=None,
+ vpc_id=None,
+ description=None,
+ security_group_rules=[rule],
+ )
+ vpc = SimpleNamespace(
+ id="vpc-1",
+ name=None,
+ cidr=None,
+ status=None,
+ description=None,
+ created_at=None,
+ )
+
+ regional_client = mock.MagicMock(region=REGION)
+ regional_client.list_vpcs.return_value = SimpleNamespace(vpcs=[vpc])
+ regional_client.list_security_groups.return_value = SimpleNamespace(
+ security_groups=[sg]
+ )
+
+ vpc_service = VPC(_provider_with_client(regional_client))
+
+ parsed_vpc = vpc_service.vpcs["vpc-1"]
+ assert parsed_vpc.name == "vpc-1" # falls back to id
+ assert parsed_vpc.cidr == ""
+
+ parsed_sg = vpc_service.security_groups["sg-1"]
+ assert parsed_sg.name == "sg-1" # falls back to id
+ assert parsed_sg.vpc_id == ""
+ parsed_rule = parsed_sg.rules[0]
+ assert parsed_rule.protocol == ""
+ assert parsed_rule.remote_ip_prefix == ""
+ assert parsed_rule.description == ""
+ assert parsed_rule.direction == ""
+
+ def test_list_security_groups_empty(self):
+ regional_client = mock.MagicMock(region=REGION)
+ regional_client.list_vpcs.return_value = SimpleNamespace(vpcs=[])
+ regional_client.list_security_groups.return_value = SimpleNamespace(
+ security_groups=[]
+ )
+
+ vpc_service = VPC(_provider_with_client(regional_client))
+
+ assert vpc_service.vpcs == {}
+ assert vpc_service.security_groups == {}
+
+ def test_list_security_groups_handles_sdk_error(self):
+ regional_client = mock.MagicMock(region=REGION)
+ regional_client.list_vpcs.side_effect = Exception("boom")
+ regional_client.list_security_groups.side_effect = Exception("boom")
+
+ vpc_service = VPC(_provider_with_client(regional_client))
+
+ # Errors are logged and swallowed; no partial/garbage resources.
+ assert vpc_service.vpcs == {}
+ assert vpc_service.security_groups == {}
diff --git a/tests/providers/huaweicloud/services/vpc/vpc_default_security_group_restricts_all_traffic/vpc_default_security_group_restricts_all_traffic_test.py b/tests/providers/huaweicloud/services/vpc/vpc_default_security_group_restricts_all_traffic/vpc_default_security_group_restricts_all_traffic_test.py
new file mode 100644
index 0000000000..e554ac91d2
--- /dev/null
+++ b/tests/providers/huaweicloud/services/vpc/vpc_default_security_group_restricts_all_traffic/vpc_default_security_group_restricts_all_traffic_test.py
@@ -0,0 +1,235 @@
+from unittest import mock
+
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+
+class TestVpcDefaultSecurityGroupRestrictsAllTraffic:
+ def _run_check(self, security_groups):
+ vpc_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.vpc.vpc_default_security_group_restricts_all_traffic.vpc_default_security_group_restricts_all_traffic.vpc_client",
+ new=vpc_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.vpc.vpc_default_security_group_restricts_all_traffic.vpc_default_security_group_restricts_all_traffic import (
+ vpc_default_security_group_restricts_all_traffic,
+ )
+
+ vpc_client.security_groups = {sg.id: sg for sg in security_groups}
+ vpc_client.audited_account = "123456789012"
+
+ check = vpc_default_security_group_restricts_all_traffic()
+ return check.execute()
+
+ def test_sg_with_open_ingress_fails(self):
+ from prowler.providers.huaweicloud.services.vpc.vpc_service import (
+ SecurityGroupRule,
+ SecurityGroups,
+ )
+
+ rule = SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ethertype="IPv4",
+ remote_ip_prefix="0.0.0.0/0",
+ )
+ sg = SecurityGroups(
+ id="sg-1",
+ name="default",
+ region="la-south-2",
+ rules=[rule],
+ )
+
+ result = self._run_check([sg])
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "ingress" in result[0].status_extended
+ assert "open to any source" in result[0].status_extended
+
+ def test_sg_with_open_egress_fails(self):
+ from prowler.providers.huaweicloud.services.vpc.vpc_service import (
+ SecurityGroupRule,
+ SecurityGroups,
+ )
+
+ rule = SecurityGroupRule(
+ id="rule-1",
+ direction="egress",
+ protocol="tcp",
+ ethertype="IPv4",
+ remote_ip_prefix="::/0",
+ )
+ sg = SecurityGroups(
+ id="sg-1",
+ name="default",
+ region="la-south-2",
+ rules=[rule],
+ )
+
+ result = self._run_check([sg])
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "egress" in result[0].status_extended
+
+ def test_sg_with_empty_source_fields_fails(self):
+ """Both remote_ip_prefix and remote_group_id empty means "any source"."""
+ from prowler.providers.huaweicloud.services.vpc.vpc_service import (
+ SecurityGroupRule,
+ SecurityGroups,
+ )
+
+ rule = SecurityGroupRule(
+ id="rule-1",
+ direction="egress",
+ protocol="",
+ ethertype="IPv4",
+ remote_ip_prefix="",
+ remote_group_id="",
+ )
+ sg = SecurityGroups(
+ id="sg-1",
+ name="Sys-default",
+ region="eu-west-101",
+ rules=[rule],
+ )
+
+ result = self._run_check([sg])
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "egress" in result[0].status_extended
+
+ def test_sg_with_remote_group_reference_passes(self):
+ """A rule with empty remote_ip_prefix but a remote_group_id is NOT open."""
+ from prowler.providers.huaweicloud.services.vpc.vpc_service import (
+ SecurityGroupRule,
+ SecurityGroups,
+ )
+
+ rule = SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="",
+ ethertype="IPv4",
+ remote_ip_prefix="",
+ remote_group_id="sg-1",
+ )
+ sg = SecurityGroups(
+ id="sg-1",
+ name="default",
+ region="la-south-2",
+ rules=[rule],
+ )
+
+ result = self._run_check([sg])
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+
+ def test_sg_restricted_passes(self):
+ from prowler.providers.huaweicloud.services.vpc.vpc_service import (
+ SecurityGroupRule,
+ SecurityGroups,
+ )
+
+ rule = SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ethertype="IPv4",
+ remote_ip_prefix="10.0.0.0/24",
+ )
+ sg = SecurityGroups(
+ id="sg-1",
+ name="default",
+ region="la-south-2",
+ rules=[rule],
+ )
+
+ result = self._run_check([sg])
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert "does not have any rule open to any source" in result[0].status_extended
+
+ def test_europe_default_sg_name_matched(self):
+ """The Europe cloud names the auto-created SG 'Sys-default'."""
+ from prowler.providers.huaweicloud.services.vpc.vpc_service import (
+ SecurityGroupRule,
+ SecurityGroups,
+ )
+
+ rule = SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ethertype="IPv4",
+ remote_ip_prefix="0.0.0.0/0",
+ )
+ sg = SecurityGroups(
+ id="sg-1",
+ name="Sys-default",
+ region="eu-west-101",
+ rules=[rule],
+ )
+
+ result = self._run_check([sg])
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+
+ def test_non_default_sg_ignored(self):
+ """SGs not named default/Sys-default are ignored by this check."""
+ from prowler.providers.huaweicloud.services.vpc.vpc_service import (
+ SecurityGroupRule,
+ SecurityGroups,
+ )
+
+ rule = SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ethertype="IPv4",
+ remote_ip_prefix="0.0.0.0/0",
+ )
+ sg = SecurityGroups(
+ id="sg-custom",
+ name="my-custom-sg",
+ region="la-south-2",
+ rules=[rule],
+ )
+
+ result = self._run_check([sg])
+
+ assert len(result) == 0
+
+ def test_sg_empty_name_skipped(self):
+ from prowler.providers.huaweicloud.services.vpc.vpc_service import (
+ SecurityGroups,
+ )
+
+ sg = SecurityGroups(
+ id="sg-1",
+ name="",
+ region="la-south-2",
+ rules=[],
+ )
+
+ result = self._run_check([sg])
+
+ assert len(result) == 0
+
+ def test_no_security_groups(self):
+ result = self._run_check([])
+ assert len(result) == 0
diff --git a/tests/providers/huaweicloud/services/vpc/vpc_security_group_all_protocols_open/vpc_security_group_all_protocols_open_test.py b/tests/providers/huaweicloud/services/vpc/vpc_security_group_all_protocols_open/vpc_security_group_all_protocols_open_test.py
new file mode 100644
index 0000000000..ec0ec8dcbc
--- /dev/null
+++ b/tests/providers/huaweicloud/services/vpc/vpc_security_group_all_protocols_open/vpc_security_group_all_protocols_open_test.py
@@ -0,0 +1,147 @@
+from unittest import mock
+
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+
+class TestVpcSecurityGroupAllProtocolsOpen:
+ def test_security_group_all_protocols_open_fails(self):
+ vpc_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.vpc.vpc_security_group_all_protocols_open.vpc_security_group_all_protocols_open.vpc_client",
+ new=vpc_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.vpc.vpc_security_group_all_protocols_open.vpc_security_group_all_protocols_open import (
+ vpc_security_group_all_protocols_open,
+ )
+ from prowler.providers.huaweicloud.services.vpc.vpc_service import (
+ SecurityGroupRule,
+ SecurityGroups,
+ )
+
+ sg = SecurityGroups(
+ id="sg-1",
+ name="open-sg",
+ region="la-south-2",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="",
+ ethertype="IPv4",
+ remote_ip_prefix="0.0.0.0/0",
+ port_range_min=None,
+ port_range_max=None,
+ )
+ ],
+ )
+ vpc_client.security_groups = {sg.id: sg}
+ vpc_client.audited_account = "123456789012"
+
+ check = vpc_security_group_all_protocols_open()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert result[0].resource_id == "sg-1"
+ assert "all ports/protocols" in result[0].status_extended
+
+ def test_security_group_restricted_passes(self):
+ vpc_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.vpc.vpc_security_group_all_protocols_open.vpc_security_group_all_protocols_open.vpc_client",
+ new=vpc_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.vpc.vpc_security_group_all_protocols_open.vpc_security_group_all_protocols_open import (
+ vpc_security_group_all_protocols_open,
+ )
+ from prowler.providers.huaweicloud.services.vpc.vpc_service import (
+ SecurityGroupRule,
+ SecurityGroups,
+ )
+
+ sg = SecurityGroups(
+ id="sg-1",
+ name="restricted-sg",
+ region="la-south-2",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ethertype="IPv4",
+ remote_ip_prefix="0.0.0.0/0",
+ port_range_min=443,
+ port_range_max=443,
+ ),
+ # All-protocols rule but not from a public CIDR -> must not trigger
+ SecurityGroupRule(
+ id="rule-2",
+ direction="ingress",
+ protocol="",
+ ethertype="IPv4",
+ remote_ip_prefix="192.168.0.0/16",
+ port_range_min=None,
+ port_range_max=None,
+ ),
+ # All-protocols rule from public CIDR but egress -> must not trigger
+ SecurityGroupRule(
+ id="rule-3",
+ direction="egress",
+ protocol="",
+ ethertype="IPv4",
+ remote_ip_prefix="0.0.0.0/0",
+ port_range_min=None,
+ port_range_max=None,
+ ),
+ ],
+ )
+ vpc_client.security_groups = {sg.id: sg}
+ vpc_client.audited_account = "123456789012"
+
+ check = vpc_security_group_all_protocols_open()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert "does not allow ingress from 0.0.0.0/0" in result[0].status_extended
+
+ def test_no_security_groups(self):
+ vpc_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.vpc.vpc_security_group_all_protocols_open.vpc_security_group_all_protocols_open.vpc_client",
+ new=vpc_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.vpc.vpc_security_group_all_protocols_open.vpc_security_group_all_protocols_open import (
+ vpc_security_group_all_protocols_open,
+ )
+
+ vpc_client.security_groups = {}
+ vpc_client.audited_account = "123456789012"
+
+ check = vpc_security_group_all_protocols_open()
+ result = check.execute()
+
+ assert len(result) == 0
diff --git a/tests/providers/huaweicloud/services/vpc/vpc_security_group_open_ingress/vpc_security_group_open_ingress_test.py b/tests/providers/huaweicloud/services/vpc/vpc_security_group_open_ingress/vpc_security_group_open_ingress_test.py
new file mode 100644
index 0000000000..b2774c97b0
--- /dev/null
+++ b/tests/providers/huaweicloud/services/vpc/vpc_security_group_open_ingress/vpc_security_group_open_ingress_test.py
@@ -0,0 +1,374 @@
+from unittest import mock
+
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+
+class TestVpcSecurityGroupOpenIngress:
+ def test_no_open_ingress_passes(self):
+ vpc_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_ingress.vpc_security_group_open_ingress.vpc_client",
+ new=vpc_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_ingress.vpc_security_group_open_ingress import (
+ vpc_security_group_open_ingress,
+ )
+ from prowler.providers.huaweicloud.services.vpc.vpc_service import (
+ SecurityGroupRule,
+ SecurityGroups,
+ )
+
+ sg = SecurityGroups(
+ id="sg-1",
+ name="safe-sg",
+ region="la-south-2",
+ vpc_id="vpc-1",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ethertype="IPv4",
+ remote_ip_prefix="10.0.0.0/24",
+ port_range_min=22,
+ port_range_max=22,
+ ),
+ ],
+ )
+ vpc_client.security_groups = {"sg-1": sg}
+ vpc_client.audited_account = "123456789012"
+
+ check = vpc_security_group_open_ingress()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert "does not allow open ingress" in result[0].status_extended
+
+ def test_open_ingress_ssh_fails(self):
+ vpc_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_ingress.vpc_security_group_open_ingress.vpc_client",
+ new=vpc_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_ingress.vpc_security_group_open_ingress import (
+ vpc_security_group_open_ingress,
+ )
+ from prowler.providers.huaweicloud.services.vpc.vpc_service import (
+ SecurityGroupRule,
+ SecurityGroups,
+ )
+
+ sg = SecurityGroups(
+ id="sg-1",
+ name="open-sg",
+ region="la-south-2",
+ vpc_id="vpc-1",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ethertype="IPv4",
+ remote_ip_prefix="0.0.0.0/0",
+ port_range_min=22,
+ port_range_max=22,
+ ),
+ ],
+ )
+ vpc_client.security_groups = {"sg-1": sg}
+ vpc_client.audited_account = "123456789012"
+
+ check = vpc_security_group_open_ingress()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "22" in result[0].status_extended
+
+ def test_open_ingress_rdp_fails(self):
+ vpc_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_ingress.vpc_security_group_open_ingress.vpc_client",
+ new=vpc_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_ingress.vpc_security_group_open_ingress import (
+ vpc_security_group_open_ingress,
+ )
+ from prowler.providers.huaweicloud.services.vpc.vpc_service import (
+ SecurityGroupRule,
+ SecurityGroups,
+ )
+
+ sg = SecurityGroups(
+ id="sg-1",
+ name="open-sg",
+ region="la-south-2",
+ vpc_id="vpc-1",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ethertype="IPv4",
+ remote_ip_prefix="::/0",
+ port_range_min=3389,
+ port_range_max=3389,
+ ),
+ ],
+ )
+ vpc_client.security_groups = {"sg-1": sg}
+ vpc_client.audited_account = "123456789012"
+
+ check = vpc_security_group_open_ingress()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "3389" in result[0].status_extended
+
+ def test_open_ingress_all_ports_fails(self):
+ """port_range_min and port_range_max both None means 'all ports' in Huawei."""
+ vpc_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_ingress.vpc_security_group_open_ingress.vpc_client",
+ new=vpc_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_ingress.vpc_security_group_open_ingress import (
+ vpc_security_group_open_ingress,
+ )
+ from prowler.providers.huaweicloud.services.vpc.vpc_service import (
+ SecurityGroupRule,
+ SecurityGroups,
+ )
+
+ sg = SecurityGroups(
+ id="sg-1",
+ name="open-sg",
+ region="la-south-2",
+ vpc_id="vpc-1",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="",
+ ethertype="IPv4",
+ remote_ip_prefix="0.0.0.0/0",
+ port_range_min=None,
+ port_range_max=None,
+ ),
+ ],
+ )
+ vpc_client.security_groups = {"sg-1": sg}
+ vpc_client.audited_account = "123456789012"
+
+ check = vpc_security_group_open_ingress()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ for port in ("22", "3389", "3306"):
+ assert port in result[0].status_extended
+
+ def test_open_ingress_wide_range_fails(self):
+ """port range 1-65535 should behave like all-ports."""
+ vpc_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_ingress.vpc_security_group_open_ingress.vpc_client",
+ new=vpc_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_ingress.vpc_security_group_open_ingress import (
+ vpc_security_group_open_ingress,
+ )
+ from prowler.providers.huaweicloud.services.vpc.vpc_service import (
+ SecurityGroupRule,
+ SecurityGroups,
+ )
+
+ sg = SecurityGroups(
+ id="sg-1",
+ name="open-sg",
+ region="la-south-2",
+ vpc_id="vpc-1",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ethertype="IPv4",
+ remote_ip_prefix="0.0.0.0/0",
+ port_range_min=1,
+ port_range_max=65535,
+ ),
+ ],
+ )
+ vpc_client.security_groups = {"sg-1": sg}
+ vpc_client.audited_account = "123456789012"
+
+ check = vpc_security_group_open_ingress()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "22" in result[0].status_extended
+
+ def test_empty_source_fields_treated_as_open(self):
+ """Empty remote_ip_prefix + empty remote_group_id = any source."""
+ vpc_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_ingress.vpc_security_group_open_ingress.vpc_client",
+ new=vpc_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_ingress.vpc_security_group_open_ingress import (
+ vpc_security_group_open_ingress,
+ )
+ from prowler.providers.huaweicloud.services.vpc.vpc_service import (
+ SecurityGroupRule,
+ SecurityGroups,
+ )
+
+ sg = SecurityGroups(
+ id="sg-1",
+ name="open-sg",
+ region="eu-west-101",
+ vpc_id="vpc-1",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="",
+ ethertype="IPv4",
+ remote_ip_prefix="",
+ remote_group_id="",
+ port_range_min=22,
+ port_range_max=22,
+ ),
+ ],
+ )
+ vpc_client.security_groups = {"sg-1": sg}
+ vpc_client.audited_account = "123456789012"
+
+ check = vpc_security_group_open_ingress()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "22" in result[0].status_extended
+
+ def test_remote_group_reference_not_flagged(self):
+ """Empty remote_ip_prefix but a remote_group_id is NOT open."""
+ vpc_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_ingress.vpc_security_group_open_ingress.vpc_client",
+ new=vpc_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_ingress.vpc_security_group_open_ingress import (
+ vpc_security_group_open_ingress,
+ )
+ from prowler.providers.huaweicloud.services.vpc.vpc_service import (
+ SecurityGroupRule,
+ SecurityGroups,
+ )
+
+ sg = SecurityGroups(
+ id="sg-1",
+ name="app-sg",
+ region="la-south-2",
+ vpc_id="vpc-1",
+ rules=[
+ SecurityGroupRule(
+ id="rule-1",
+ direction="ingress",
+ protocol="tcp",
+ ethertype="IPv4",
+ remote_ip_prefix="",
+ remote_group_id="sg-2",
+ port_range_min=22,
+ port_range_max=22,
+ ),
+ ],
+ )
+ vpc_client.security_groups = {"sg-1": sg}
+ vpc_client.audited_account = "123456789012"
+
+ check = vpc_security_group_open_ingress()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+
+ def test_no_security_groups(self):
+ vpc_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_ingress.vpc_security_group_open_ingress.vpc_client",
+ new=vpc_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_ingress.vpc_security_group_open_ingress import (
+ vpc_security_group_open_ingress,
+ )
+
+ vpc_client.security_groups = {}
+ vpc_client.audited_account = "123456789012"
+
+ check = vpc_security_group_open_ingress()
+ result = check.execute()
+
+ assert len(result) == 0
diff --git a/tests/providers/huaweicloud/services/waf/huaweicloud_waf_service_test.py b/tests/providers/huaweicloud/services/waf/huaweicloud_waf_service_test.py
new file mode 100644
index 0000000000..5f39afe91a
--- /dev/null
+++ b/tests/providers/huaweicloud/services/waf/huaweicloud_waf_service_test.py
@@ -0,0 +1,62 @@
+from types import SimpleNamespace
+from unittest import mock
+
+from prowler.providers.huaweicloud.services.waf.waf_service import WAF, WAFInstance
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+REGION = "la-south-2"
+
+
+def _provider_with_client(service_client):
+ """Return a mocked provider whose regional client is the given mock."""
+ provider = set_mocked_huaweicloud_provider(region=REGION)
+ provider.session.client = mock.MagicMock(return_value=service_client)
+ provider.generate_regional_clients = mock.MagicMock(
+ return_value={REGION: service_client}
+ )
+ return provider
+
+
+class TestWAFService:
+ def test_list_instances_parses_instances(self):
+ instances = [
+ SimpleNamespace(id="waf-1", instancename="waf-primary", status=1),
+ # Fallback to instance_name when instancename is missing/empty.
+ SimpleNamespace(id="waf-2", instance_name="waf-fallback", status=0),
+ ]
+ service_client = mock.MagicMock(region=REGION)
+ service_client.list_instance.return_value = SimpleNamespace(items=instances)
+
+ waf = WAF(_provider_with_client(service_client))
+
+ assert len(waf.instances) == 2
+ by_id = {inst.id: inst for inst in waf.instances}
+
+ primary = by_id["waf-1"]
+ assert isinstance(primary, WAFInstance)
+ assert primary.name == "waf-primary"
+ assert primary.status == 1
+ assert primary.region == REGION
+
+ fallback = by_id["waf-2"]
+ assert fallback.name == "waf-fallback"
+ assert fallback.status == 0
+
+ def test_list_instances_empty(self):
+ service_client = mock.MagicMock(region=REGION)
+ service_client.list_instance.return_value = SimpleNamespace(items=[])
+
+ waf = WAF(_provider_with_client(service_client))
+
+ assert waf.instances == []
+
+ def test_list_instances_handles_sdk_error(self):
+ service_client = mock.MagicMock(region=REGION)
+ service_client.list_instance.side_effect = Exception("boom")
+
+ waf = WAF(_provider_with_client(service_client))
+
+ # Errors are logged and swallowed; no partial/garbage resources.
+ assert waf.instances == []
diff --git a/tests/providers/huaweicloud/services/waf/waf_enabled/waf_enabled_test.py b/tests/providers/huaweicloud/services/waf/waf_enabled/waf_enabled_test.py
new file mode 100644
index 0000000000..9c016cab2d
--- /dev/null
+++ b/tests/providers/huaweicloud/services/waf/waf_enabled/waf_enabled_test.py
@@ -0,0 +1,107 @@
+from unittest import mock
+
+from tests.providers.huaweicloud.huaweicloud_fixtures import (
+ set_mocked_huaweicloud_provider,
+)
+
+
+class TestWafEnabled:
+ def test_waf_running_passes(self):
+ waf_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.waf.waf_enabled.waf_enabled.waf_client",
+ new=waf_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.waf.waf_enabled.waf_enabled import (
+ waf_enabled,
+ )
+ from prowler.providers.huaweicloud.services.waf.waf_service import (
+ WAFInstance,
+ )
+
+ instance = WAFInstance(
+ id="waf-1",
+ name="my-waf",
+ status=1,
+ region="la-south-2",
+ )
+ waf_client.instances = [instance]
+ waf_client.audited_account = "123456789012"
+
+ check = waf_enabled()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert "running" in result[0].status_extended
+
+ def test_waf_not_running_fails(self):
+ waf_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.waf.waf_enabled.waf_enabled.waf_client",
+ new=waf_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.waf.waf_enabled.waf_enabled import (
+ waf_enabled,
+ )
+ from prowler.providers.huaweicloud.services.waf.waf_service import (
+ WAFInstance,
+ )
+
+ instance = WAFInstance(
+ id="waf-1",
+ name="my-waf",
+ status=0,
+ region="la-south-2",
+ )
+ waf_client.instances = [instance]
+ waf_client.audited_account = "123456789012"
+
+ check = waf_enabled()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "not running" in result[0].status_extended
+
+ def test_no_waf_instances_fails(self):
+ waf_client = mock.MagicMock()
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_huaweicloud_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.huaweicloud.services.waf.waf_enabled.waf_enabled.waf_client",
+ new=waf_client,
+ ),
+ ):
+ from prowler.providers.huaweicloud.services.waf.waf_enabled.waf_enabled import (
+ waf_enabled,
+ )
+
+ waf_client.instances = []
+ waf_client.audited_account = "123456789012"
+ waf_client.region = "la-south-2"
+
+ check = waf_enabled()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "not enabled" in result[0].status_extended
diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md
index 40cb61691f..c3dba27145 100644
--- a/ui/CHANGELOG.md
+++ b/ui/CHANGELOG.md
@@ -4,6 +4,35 @@ All notable changes to the **Prowler UI** are documented in this file.
+## [1.36.0] (Prowler v5.36.0)
+
+### 🚀 Added
+
+- Finding Groups and grouped selections can be sent to Jira in Cloud with deep links, filter chip display, and Jira feedback toasts [(#12001)](https://github.com/prowler-cloud/prowler/pull/12001)
+- In Prowler Cloud, the Attack Paths query selector now lists only queries that returned data for the selected scan, hiding empty ones [(#12010)](https://github.com/prowler-cloud/prowler/pull/12010)
+- Overview banner linking to the AI agents documentation, shown next to the Lighthouse AI banner in Cloud and full width on self-hosted deployments [(#12074)](https://github.com/prowler-cloud/prowler/pull/12074)
+
+### 🐞 Fixed
+
+- Findings Severity Over Time chart Y-axis labels no longer overflow for large findings counts [(#11545)](https://github.com/prowler-cloud/prowler/pull/11545)
+- UI Sentry alerts now suppress non-actionable warnings and expected API/control-flow noise while preserving actionable runtime failures [(#11665)](https://github.com/prowler-cloud/prowler/pull/11665)
+- OCI provider E2E tests no longer require or submit a region when adding or updating credentials [(#11741)](https://github.com/prowler-cloud/prowler/pull/11741)
+- Billing navigation is hidden when Cloud billing is disabled, including Enterprise deployments [(#12047)](https://github.com/prowler-cloud/prowler/pull/12047)
+- AWS Organizations setup modal now shows the "Enter a valid Organizational Unit or Root ID" hint in the error color, clarifying why the deployment button is disabled [(#12063)](https://github.com/prowler-cloud/prowler/pull/12063)
+- Sidebar logo top spacing in the main app sidebar [(#12066)](https://github.com/prowler-cloud/prowler/pull/12066)
+- Contextual Cloud upgrade modal content remains stable throughout the closing animation [(#12067)](https://github.com/prowler-cloud/prowler/pull/12067)
+- Tenant switches now refresh session user permissions for the selected tenant [(#12087)](https://github.com/prowler-cloud/prowler/pull/12087)
+
+### 🔐 Security
+
+- Removed the unused `npm` CLI from the UI container image, eliminating the bundled `node-tar` `CVE-2026-59873` (and future bundled-npm CVEs); the image builds with `pnpm` via `corepack` and does not use `npm` [(#12065)](https://github.com/prowler-cloud/prowler/pull/12065)
+- Bumped `vitest` and `@vitest/browser`, `@vitest/browser-playwright`, `@vitest/coverage-v8` from `4.1.8` to `4.1.10`, resolving the critical `@vitest/browser` Browser Mode file-access permission bypass (`GHSA-p63j-vcc4-9vmv`) flagged by `pnpm audit`; dev dependencies only, no runtime impact [(#12077)](https://github.com/prowler-cloud/prowler/pull/12077)
+- Kubernetes credential forms now reject kubeconfig files using legacy `auth-provider.config.cmd-path` command authentication [(#12091)](https://github.com/prowler-cloud/prowler/pull/12091)
+- Next.js from 16.2.9 to 16.2.11, patching 4 high- and 5 medium-severity vulnerabilities [(#12093)](https://github.com/prowler-cloud/prowler/pull/12093)
+- next-auth from 5.0.0-beta.30 to 5.0.0-beta.32, patching 2 critical Auth.js advisories (GHSA-8fpg-xm3f-6cx3 fail-open auth checks, GHSA-7rqj-j65f-68wh email homoglyph bypass) [(#12108)](https://github.com/prowler-cloud/prowler/pull/12108)
+
+---
+
## [1.35.0] (Prowler v5.35.0)
### 🔄 Changed
diff --git a/ui/Dockerfile b/ui/Dockerfile
index 6ab9752972..41484a3fbe 100644
--- a/ui/Dockerfile
+++ b/ui/Dockerfile
@@ -4,7 +4,9 @@ FROM node:24.13.0-alpine@sha256:cd6fb7efa6490f039f3471a189214d5f548c11df1ff9e5b1
LABEL maintainer="https://github.com/prowler-cloud"
# Patch Alpine OpenSSL runtime packages before all stages inherit the base image.
-RUN apk upgrade --no-cache libcrypto3 libssl3 && corepack enable
+# The build uses pnpm via corepack, so npm is unused — remove it (and npx) to drop
+# the bundled-npm CVE surface (node-tar CVE-2026-59873) from every stage, incl. prod.
+RUN apk upgrade --no-cache libcrypto3 libssl3 && corepack enable && rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
# Install dependencies only when needed
FROM base AS deps
@@ -79,9 +81,10 @@ ENV HOSTNAME="0.0.0.0"
# Helm/K8s):
# - required: UI_API_BASE_URL, AUTH_URL, AUTH_SECRET (missing ⇒ fail fast at boot)
# - optional: UI_API_DOCS_URL
+# - optional: UI_CLOUD_ENABLED ("true" only in Prowler Cloud deployments)
# - gated integrations (load only when *_ENABLED="true"; the value is then
-# required or boot fails). Legacy names (NEXT_PUBLIC_*, POSTHOG_KEY/HOST)
-# still activate without the flag:
+# required or boot fails). Their legacy names (NEXT_PUBLIC_SENTRY_*,
+# NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID, POSTHOG_KEY/HOST) still work:
# UI_SENTRY_ENABLED + UI_SENTRY_DSN (+ optional UI_SENTRY_ENVIRONMENT)
# UI_GOOGLE_TAG_MANAGER_ENABLED + UI_GOOGLE_TAG_MANAGER_ID
# UI_POSTHOG_ENABLED + UI_POSTHOG_KEY + UI_POSTHOG_HOST (no consumer yet)
diff --git a/ui/actions/attack-paths/queries.adapter.test.ts b/ui/actions/attack-paths/queries.adapter.test.ts
index 77298991f3..18e89d9a4d 100644
--- a/ui/actions/attack-paths/queries.adapter.test.ts
+++ b/ui/actions/attack-paths/queries.adapter.test.ts
@@ -1,12 +1,26 @@
-import { describe, expect, it } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
import { DOCS_URLS } from "@/lib/external-urls";
+import { isCloud } from "@/lib/shared/env";
import {
ATTACK_PATH_QUERY_IDS,
type AttackPathQuery,
+ type AttackPathQueryParameter,
+ type AttackPathQueryResultSummary,
} from "@/types/attack-paths";
-import { buildAttackPathQueries } from "./queries.adapter";
+import {
+ adaptAttackPathQueriesResponse,
+ buildAttackPathQueries,
+} from "./queries.adapter";
+
+vi.mock("@/lib/shared/env", () => ({ isCloud: vi.fn() }));
+
+// Empty-query filtering only applies in Prowler Cloud; default the flag on for
+// the filtering suite and cover the self-hosted (flag off) case explicitly.
+beforeEach(() => {
+ vi.mocked(isCloud).mockReturnValue(true);
+});
const presetQuery: AttackPathQuery = {
type: "attack-paths-scans",
@@ -21,6 +35,95 @@ const presetQuery: AttackPathQuery = {
},
};
+const makeQuery = (
+ id: string,
+ overrides: {
+ result_summary?: AttackPathQueryResultSummary | null;
+ parameters?: AttackPathQueryParameter[];
+ } = {},
+): AttackPathQuery => ({
+ type: "attack-paths-scans",
+ id,
+ attributes: {
+ name: id,
+ short_description: "",
+ description: "",
+ provider: "aws",
+ attribution: null,
+ parameters: overrides.parameters ?? [],
+ result_summary: overrides.result_summary,
+ },
+});
+
+describe("adaptAttackPathQueriesResponse filtering", () => {
+ it("keeps only queries with data, plus everything without a definite empty verdict", () => {
+ const response = {
+ data: [
+ makeQuery("has-data", {
+ result_summary: { status: "ok", has_data: true },
+ }),
+ makeQuery("empty", {
+ result_summary: { status: "ok", has_data: false },
+ }),
+ makeQuery("errored", {
+ result_summary: { status: "error", has_data: null },
+ }),
+ makeQuery("parameterized", {
+ parameters: [
+ {
+ name: "ip",
+ label: "IP",
+ data_type: "string",
+ } as AttackPathQueryParameter,
+ ],
+ result_summary: null,
+ }),
+ makeQuery("no-summary"), // scan predating the precompute step
+ ],
+ };
+
+ const ids = adaptAttackPathQueriesResponse(response).data.map((q) => q.id);
+
+ // the only one hidden is the parameterless query known to be empty
+ expect(ids).toEqual(["has-data", "errored", "parameterized", "no-summary"]);
+ expect(ids).not.toContain("empty");
+ });
+
+ it("returns an empty list for a missing response", () => {
+ expect(adaptAttackPathQueriesResponse(undefined).data).toEqual([]);
+ });
+
+ it("updates the pagination count to the filtered length", () => {
+ const response = {
+ data: [
+ makeQuery("a", { result_summary: { status: "ok", has_data: true } }),
+ makeQuery("b", { result_summary: { status: "ok", has_data: false } }),
+ ],
+ };
+
+ const { metadata } = adaptAttackPathQueriesResponse(response);
+ expect(metadata?.pagination.count).toBe(1);
+ });
+
+ it("shows every query when not running in Cloud (feature flag off)", () => {
+ vi.mocked(isCloud).mockReturnValue(false);
+ const response = {
+ data: [
+ makeQuery("has-data", {
+ result_summary: { status: "ok", has_data: true },
+ }),
+ makeQuery("empty", {
+ result_summary: { status: "ok", has_data: false },
+ }),
+ ],
+ };
+
+ const ids = adaptAttackPathQueriesResponse(response).data.map((q) => q.id);
+ // self-hosted keeps current behaviour: no filtering, even the empty one
+ expect(ids).toEqual(["has-data", "empty"]);
+ });
+});
+
describe("buildAttackPathQueries", () => {
it("prepends a custom query that links to the Prowler documentation", () => {
// When
diff --git a/ui/actions/attack-paths/queries.adapter.ts b/ui/actions/attack-paths/queries.adapter.ts
index b4635ed333..38ce566740 100644
--- a/ui/actions/attack-paths/queries.adapter.ts
+++ b/ui/actions/attack-paths/queries.adapter.ts
@@ -1,4 +1,5 @@
import { DOCS_URLS } from "@/lib/external-urls";
+import { isCloud } from "@/lib/shared/env";
import { MetaDataProps } from "@/types";
import {
ATTACK_PATH_QUERY_IDS,
@@ -19,6 +20,27 @@ import {
* - Maintains separation of concerns between API layer and business logic
*/
+/**
+ * Decide whether a query should appear in the selector.
+ *
+ * Empty-query filtering is a Prowler Cloud feature: outside Cloud
+ * (`isCloud()` is false) the queries API does not precompute result summaries,
+ * so every query is shown, matching current self-hosted behaviour.
+ *
+ * In Cloud, hide only queries the scan precomputed and found empty
+ * (`has_data === false`). Everything else stays visible: parameterized queries
+ * (no summary, run live on demand), queries that errored during precompute
+ * (`has_data` null), and scans that predate the precompute step (no summary at
+ * all). A missing/null summary is therefore never treated as "empty".
+ */
+function shouldShowQuery(query: AttackPathQuery): boolean {
+ if (!isCloud()) {
+ return true;
+ }
+ const summary = query.attributes.result_summary;
+ return !summary || summary.has_data !== false;
+}
+
/**
* Adapt attack path queries response with enriched data
*
@@ -36,7 +58,7 @@ export function adaptAttackPathQueriesResponse(
}
// Enrich query data with computed properties
- const enrichedData = response.data.map((query) => ({
+ const enrichedData = response.data.filter(shouldShowQuery).map((query) => ({
...query,
// Can add computed properties here, e.g.:
// parameterCount: query.attributes.parameters.length,
diff --git a/ui/actions/finding-groups/finding-groups.adapter.test.ts b/ui/actions/finding-groups/finding-groups.adapter.test.ts
index e4dcb66804..6d93467fd2 100644
--- a/ui/actions/finding-groups/finding-groups.adapter.test.ts
+++ b/ui/actions/finding-groups/finding-groups.adapter.test.ts
@@ -235,7 +235,7 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => {
it("should attach adapter-produced triage DTOs to finding-level resource rows", () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
const input = {
data: [
{
@@ -291,7 +291,7 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => {
it("should leave triage editing disabled until a real capability is provided", () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
const input = {
data: [
{
diff --git a/ui/actions/findings/findings-filters.ts b/ui/actions/findings/findings-filters.ts
index 268d71e7cd..caa56a6744 100644
--- a/ui/actions/findings/findings-filters.ts
+++ b/ui/actions/findings/findings-filters.ts
@@ -3,6 +3,8 @@ import { FILTER_FIELD, FilterParam } from "@/types/filters";
/** Findings-only filter fields not shared with other views. */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const FINDINGS_EXTRA_FIELD = {
+ CHECK_ID: "check_id",
+ CHECK_ID_IN: "check_id__in",
DELTA_IN: "delta__in",
SCAN_EXACT: "scan",
SCAN_ID: "scan_id",
diff --git a/ui/actions/findings/findings-triage.options.ts b/ui/actions/findings/findings-triage.options.ts
index 1d0ad6314a..1f1578ec5c 100644
--- a/ui/actions/findings/findings-triage.options.ts
+++ b/ui/actions/findings/findings-triage.options.ts
@@ -1,3 +1,4 @@
+import { isCloud } from "@/lib/shared/env";
import {
FINDING_TRIAGE_DISABLED_REASON,
type FindingTriageDisabledReason,
@@ -9,7 +10,7 @@ interface FindingTriageAdapterOptions {
}
export function getFindingTriageAdapterOptions(): FindingTriageAdapterOptions {
- const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
+ const isCloudEnvironment = isCloud();
return {
canEdit: isCloudEnvironment,
diff --git a/ui/actions/findings/findings.test.ts b/ui/actions/findings/findings.test.ts
index 3da945ebfc..c9dc09d249 100644
--- a/ui/actions/findings/findings.test.ts
+++ b/ui/actions/findings/findings.test.ts
@@ -58,7 +58,7 @@ describe("findings actions triage projection", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
handleApiResponseMock.mockResolvedValue(findingsResponse);
@@ -86,7 +86,7 @@ describe("findings actions triage projection", () => {
it("should attach domain triage DTOs to latest findings responses", async () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
const result = await getLatestFindings({ page: 1, pageSize: 10 });
diff --git a/ui/actions/integrations/jira-dispatch.test.ts b/ui/actions/integrations/jira-dispatch.test.ts
index fea64a611a..1566fb5e1d 100644
--- a/ui/actions/integrations/jira-dispatch.test.ts
+++ b/ui/actions/integrations/jira-dispatch.test.ts
@@ -1,129 +1,170 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
-const { pollTaskUntilSettledMock } = vi.hoisted(() => ({
+const { fetchMock, pollTaskUntilSettledMock } = vi.hoisted(() => ({
+ fetchMock: vi.fn(),
pollTaskUntilSettledMock: vi.fn(),
}));
-vi.mock("@/actions/task/poll", () => ({
- pollTaskUntilSettled: pollTaskUntilSettledMock,
-}));
-
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
}));
-vi.mock("@/lib/auth-headers", () => ({
- getAuthHeaders: vi.fn(),
-}));
-
vi.mock("@/lib/server-actions-helper", () => ({
- handleApiError: vi.fn(),
+ handleApiError: () => ({ error: "An error occurred" }),
}));
-import { pollJiraDispatchTask } from "./jira-dispatch";
+vi.mock("@/actions/task/poll", () => ({
+ pollTaskUntilSettled: pollTaskUntilSettledMock,
+}));
-describe("pollJiraDispatchTask", () => {
+vi.mock("@/lib/auth-headers", () => ({
+ getAuthHeaders: vi.fn().mockResolvedValue({ Authorization: "Bearer token" }),
+}));
+
+import { pollJiraDispatchTask, sendJiraDispatch } from "./jira-dispatch";
+
+describe("sendJiraDispatch", () => {
beforeEach(() => {
vi.clearAllMocks();
+ vi.stubGlobal("fetch", fetchMock);
+ fetchMock.mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ data: {
+ id: "task-1",
+ type: "tasks",
+ attributes: { result: null },
+ },
+ }),
+ { status: 202 },
+ ),
+ );
});
- it("should return the backend error when a completed task has failed Jira dispatches", async () => {
+ it("should send grouped dispatch mode with multiple finding IDs", async () => {
+ // Given / When
+ await sendJiraDispatch({
+ integrationId: "jira-1",
+ targetIds: ["finding-1", "finding-2"],
+ filter: "finding_id",
+ projectKey: "SEC",
+ issueType: "Task",
+ dispatchMode: "grouped",
+ });
+
+ // Then
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ expect(url).toBe(
+ "https://api.example.com/api/v1/integrations/jira-1/jira/dispatches?filter%5Bfinding_id__in%5D=finding-1%2Cfinding-2",
+ );
+ expect(JSON.parse(init.body as string)).toMatchObject({
+ data: {
+ attributes: {
+ dispatch_mode: "grouped",
+ issue_type: "Task",
+ project_key: "SEC",
+ },
+ },
+ });
+ });
+
+ it("should send grouped dispatch mode with a finding group check ID", async () => {
+ // Given / When
+ await sendJiraDispatch({
+ integrationId: "jira-1",
+ targetIds: ["s3_bucket_public_access"],
+ filter: "check_id",
+ projectKey: "SEC",
+ issueType: "Task",
+ dispatchMode: "grouped",
+ });
+
+ // Then
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ expect(url).toBe(
+ "https://api.example.com/api/v1/integrations/jira-1/jira/dispatches?filter%5Bcheck_id%5D=s3_bucket_public_access",
+ );
+ expect(JSON.parse(init.body as string)).toMatchObject({
+ data: { attributes: { dispatch_mode: "grouped" } },
+ });
+ });
+
+ it("should preserve partial success when Jira dispatch has created and failed issues", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "completed",
result: {
- created_count: 0,
+ created_count: 2,
failed_count: 1,
- error: "Jira project requires custom fields: Team is required",
+ failed_finding_ids: ["finding-3"],
+ error: "Jira rejected one Finding.",
},
});
// When
- const result = await pollJiraDispatchTask("task-123");
+ const result = await pollJiraDispatchTask("task-1");
// Then
expect(result).toEqual({
- success: false,
- error: "Jira project requires custom fields: Team is required",
+ success: true,
+ message: "2 Jira issues were created or updated successfully.",
+ warning:
+ "Jira rejected one Finding. Jira dispatch completed with 1 failed and 2 created/updated issues.",
+ failedFindingIds: ["finding-3"],
});
});
- it("should return a fallback error when a completed task has failures without an error", async () => {
+ it("should include updated issues in partial failure summaries", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "completed",
- result: {
- created_count: 0,
- failed_count: 1,
- },
+ result: { created_count: 0, updated_count: 2, failed_count: 1 },
});
// When
- const result = await pollJiraDispatchTask("task-123");
+ const result = await pollJiraDispatchTask("task-1");
// Then
expect(result).toEqual({
- success: false,
- error: "Failed to create Jira issue.",
+ success: true,
+ message: "2 Jira issues were created or updated successfully.",
+ warning:
+ "Jira dispatch completed with 1 failed and 2 created/updated issues.",
});
});
- it("should return a plural fallback error when a completed task has multiple failures without an error", async () => {
+ it("should fail completed task polling when grouped dispatch reports failed groups", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "completed",
- result: {
- created_count: 0,
- failed_count: 3,
- },
+ result: { created_count: 1, failed_groups: [{ check_id: "check-a" }] },
});
// When
- const result = await pollJiraDispatchTask("task-123");
+ const result = await pollJiraDispatchTask("task-1");
// Then
expect(result).toEqual({
- success: false,
- error: "Failed to create 3 Jira issues.",
+ success: true,
+ message: "Finding successfully sent to Jira!",
+ warning:
+ "Jira dispatch completed with 1 failed and 1 created/updated issue.",
});
});
- it("should surface task failure result errors", async () => {
- // Given
- pollTaskUntilSettledMock.mockResolvedValue({
- ok: true,
- state: "failed",
- result: {
- error: "Jira credentials are invalid.",
- },
- });
-
- // When
- const result = await pollJiraDispatchTask("task-123");
-
- // Then
- expect(result).toEqual({
- success: false,
- error: "Jira credentials are invalid.",
- });
- });
-
- it("should return success when a completed task has no failures", async () => {
+ it("should succeed completed task polling when grouped dispatch reports no failed groups", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "completed",
- result: {
- created_count: 1,
- failed_count: 0,
- },
+ result: { created_count: 1, failed_groups: [] },
});
// When
- const result = await pollJiraDispatchTask("task-123");
+ const result = await pollJiraDispatchTask("task-1");
// Then
expect(result).toEqual({
@@ -132,24 +173,68 @@ describe("pollJiraDispatchTask", () => {
});
});
- it("should return a fallback error when no Jira issue was created", async () => {
+ it("should succeed completed task polling with grouped created and updated issue result shape", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "completed",
result: {
- created_count: 0,
+ created_count: 1,
+ updated_count: 1,
failed_count: 0,
+ created_issues: [{ key: "SEC-1" }],
+ updated_issues: [{ key: "SEC-2" }],
+ failed_groups: [],
},
});
// When
- const result = await pollJiraDispatchTask("task-123");
+ const result = await pollJiraDispatchTask("task-1");
+
+ // Then
+ expect(pollTaskUntilSettledMock).toHaveBeenCalledWith("task-1", {
+ maxAttempts: 5,
+ delayMs: 2000,
+ });
+ expect(result).toEqual({
+ success: true,
+ message: "2 Jira issues were created or updated successfully.",
+ });
+ });
+
+ it("should fail completed task polling when Jira dispatch completed as a no-op", async () => {
+ // Given
+ pollTaskUntilSettledMock.mockResolvedValue({
+ ok: true,
+ state: "completed",
+ result: { created_count: 0, updated_count: 0, failed_count: 0 },
+ });
+
+ // When
+ const result = await pollJiraDispatchTask("task-1");
// Then
expect(result).toEqual({
success: false,
- error: "Failed to create Jira issue.",
+ error: "Jira dispatch completed but did not create or update any issues.",
+ });
+ });
+
+ it("should fail completed task polling when Jira dispatch has no result payload", async () => {
+ // Given
+ pollTaskUntilSettledMock.mockResolvedValue({
+ ok: true,
+ state: "completed",
+ result: null,
+ });
+
+ // When
+ const result = await pollJiraDispatchTask("task-1");
+
+ // Then
+ expect(result).toEqual({
+ success: false,
+ error: "Jira dispatch completed but did not create or update any issues.",
});
});
});
diff --git a/ui/actions/integrations/jira-dispatch.ts b/ui/actions/integrations/jira-dispatch.ts
index 4ffbce85f0..d8584cfdb9 100644
--- a/ui/actions/integrations/jira-dispatch.ts
+++ b/ui/actions/integrations/jira-dispatch.ts
@@ -3,12 +3,26 @@
import { pollTaskUntilSettled } from "@/actions/task/poll";
import { apiBaseUrl } from "@/lib";
import { getAuthHeaders } from "@/lib/auth-headers";
+import { evaluateJiraDispatchTask } from "@/lib/jira-dispatch-result";
import { handleApiError } from "@/lib/server-actions-helper";
import type {
IntegrationProps,
+ JiraDispatchMode,
JiraDispatchRequest,
JiraDispatchResponse,
+ JiraDispatchTarget,
+ JiraDispatchTaskResult,
} from "@/types/integrations";
+import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
+
+interface JiraDispatchInput {
+ integrationId: string;
+ targetIds: string[];
+ filter: JiraDispatchTarget;
+ projectKey: string;
+ issueType: string;
+ dispatchMode?: JiraDispatchMode;
+}
export const getJiraIssueTypes = async (
integrationId: string,
@@ -88,14 +102,37 @@ export const sendFindingToJira = async (
): Promise<
| { success: true; taskId: string; message: string }
| { success: false; error: string }
+> => {
+ return sendJiraDispatch({
+ integrationId,
+ targetIds: [findingId],
+ filter: JIRA_DISPATCH_TARGET.FINDING_ID,
+ projectKey,
+ issueType,
+ });
+};
+
+export const sendJiraDispatch = async ({
+ integrationId,
+ targetIds,
+ filter,
+ projectKey,
+ issueType,
+ dispatchMode = JIRA_DISPATCH_MODE.INDIVIDUAL,
+}: JiraDispatchInput): Promise<
+ | { success: true; taskId: string; message: string }
+ | { success: false; error: string }
> => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(
`${apiBaseUrl}/integrations/${integrationId}/jira/dispatches`,
);
- // Single finding: use direct filter without array notation
- url.searchParams.append("filter[finding_id]", findingId);
+ if (targetIds.length === 1) {
+ url.searchParams.append(`filter[${filter}]`, targetIds[0]);
+ } else {
+ url.searchParams.append(`filter[${filter}__in]`, targetIds.join(","));
+ }
const payload: JiraDispatchRequest = {
data: {
@@ -103,6 +140,7 @@ export const sendFindingToJira = async (
attributes: {
project_key: projectKey,
issue_type: issueType,
+ dispatch_mode: dispatchMode,
},
},
};
@@ -146,38 +184,18 @@ export const sendFindingToJira = async (
export const pollJiraDispatchTask = async (
taskId: string,
): Promise<
- { success: true; message: string } | { success: false; error: string }
+ | { success: true; message: string; warning?: string }
+ | { success: false; error: string }
> => {
const res = await pollTaskUntilSettled(taskId, {
- maxAttempts: 30,
+ maxAttempts: 5,
delayMs: 2000,
});
if (!res.ok) {
return { success: false, error: res.error };
}
- const { state, result } = res;
- type JiraTaskResult = JiraDispatchResponse["data"]["attributes"]["result"];
- const jiraResult = result as JiraTaskResult | undefined;
-
- if (state === "completed") {
- const createdCount = jiraResult?.created_count ?? 0;
- const failedCount = jiraResult?.failed_count ?? 0;
- if (!jiraResult?.error && failedCount === 0 && createdCount > 0) {
- return { success: true, message: "Finding successfully sent to Jira!" };
- }
- return {
- success: false,
- error:
- jiraResult?.error ||
- (failedCount > 1
- ? `Failed to create ${failedCount} Jira issues.`
- : "Failed to create Jira issue."),
- };
- }
-
- if (state === "failed") {
- return { success: false, error: jiraResult?.error || "Task failed." };
- }
-
- return { success: false, error: `Unknown task state: ${state}` };
+ return evaluateJiraDispatchTask(
+ res.state,
+ res.result as JiraDispatchTaskResult | undefined,
+ );
};
diff --git a/ui/actions/overview/resources-inventory/resources-inventory.adapter.ts b/ui/actions/overview/resources-inventory/resources-inventory.adapter.ts
index 5e6f5fd1f2..508d9a46ad 100644
--- a/ui/actions/overview/resources-inventory/resources-inventory.adapter.ts
+++ b/ui/actions/overview/resources-inventory/resources-inventory.adapter.ts
@@ -1,5 +1,5 @@
-import { LucideIcon } from "lucide-react";
import {
+ LucideIcon,
Activity,
BarChart3,
Bot,
diff --git a/ui/actions/resources/resources.ts b/ui/actions/resources/resources.ts
index e220ceb616..3b3ff1ff73 100644
--- a/ui/actions/resources/resources.ts
+++ b/ui/actions/resources/resources.ts
@@ -13,6 +13,7 @@ import {
import { getAuthHeaders } from "@/lib/auth-headers";
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
import { handleApiResponse } from "@/lib/server-actions-helper";
+import { isCloud } from "@/lib/shared/env";
import { OrganizationResource } from "@/types/organizations";
export const getResources = async ({
@@ -287,7 +288,7 @@ export const getResourceDrawerData = async ({
pageSize?: number;
query?: string;
}) => {
- const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
+ const isCloudEnv = isCloud();
const [resourceData, findingsResponse, organizationsResponse] =
await Promise.all([
diff --git a/ui/actions/roles/roles.test.ts b/ui/actions/roles/roles.test.ts
index 6a0ca6331b..6b4dbf6901 100644
--- a/ui/actions/roles/roles.test.ts
+++ b/ui/actions/roles/roles.test.ts
@@ -77,7 +77,7 @@ describe("role actions", () => {
it("includes manage_alerts when creating a role in Prowler Cloud", async () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
await addRole(makeRoleFormData());
@@ -88,7 +88,7 @@ describe("role actions", () => {
it("omits manage_alerts when creating a role outside Prowler Cloud", async () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
await addRole(makeRoleFormData());
@@ -101,7 +101,7 @@ describe("role actions", () => {
it("includes manage_alerts when updating a role in Prowler Cloud", async () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
await updateRole(makeRoleFormData(), "role-1");
diff --git a/ui/actions/roles/roles.ts b/ui/actions/roles/roles.ts
index c778fe1a16..5b40441dfa 100644
--- a/ui/actions/roles/roles.ts
+++ b/ui/actions/roles/roles.ts
@@ -6,6 +6,7 @@ import { redirect } from "next/navigation";
import { apiBaseUrl } from "@/lib";
import { getAuthHeaders } from "@/lib/auth-headers";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
+import { isCloud } from "@/lib/shared/env";
export const getRoles = async ({
page = 1,
@@ -109,7 +110,7 @@ export const addRole = async (formData: FormData) => {
};
// Conditionally include Prowler Cloud permissions.
- if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
+ if (isCloud()) {
payload.data.attributes.manage_billing =
formData.get("manage_billing") === "true";
payload.data.attributes.manage_alerts =
@@ -166,7 +167,7 @@ export const updateRole = async (formData: FormData, roleId: string) => {
};
// Conditionally include Prowler Cloud permissions.
- if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
+ if (isCloud()) {
payload.data.attributes.manage_billing =
formData.get("manage_billing") === "true";
payload.data.attributes.manage_alerts =
diff --git a/ui/app/(auth)/(guest-only)/sign-up/page.tsx b/ui/app/(auth)/(guest-only)/sign-up/page.tsx
index 4854de012b..0415c6b0f3 100644
--- a/ui/app/(auth)/(guest-only)/sign-up/page.tsx
+++ b/ui/app/(auth)/(guest-only)/sign-up/page.tsx
@@ -1,6 +1,10 @@
import { AuthForm } from "@/components/auth/oss";
-import { getAuthUrl, isGithubOAuthEnabled } from "@/lib/helper";
-import { isGoogleOAuthEnabled } from "@/lib/helper";
+import {
+ getAuthUrl,
+ isGithubOAuthEnabled,
+ isGoogleOAuthEnabled,
+} from "@/lib/helper";
+import { isCloud } from "@/lib/shared/env";
import { SearchParamsProps } from "@/types";
const SignUp = async ({
@@ -13,7 +17,7 @@ const SignUp = async ({
typeof resolvedSearchParams?.invitation_token === "string"
? resolvedSearchParams.invitation_token
: null;
- const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
+ const isCloudEnv = isCloud();
const GOOGLE_AUTH_URL = getAuthUrl("google");
const GITHUB_AUTH_URL = getAuthUrl("github");
diff --git a/ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.test.tsx b/ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.test.tsx
deleted file mode 100644
index 255ac50c63..0000000000
--- a/ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.test.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-
-import { LIGHTHOUSE_OVERVIEW_BANNER_HREF } from "../_lib/lighthouse-banner";
-import { LighthouseOverviewBanner } from "./lighthouse-overview-banner";
-
-describe("LighthouseOverviewBanner", () => {
- it("renders Toni copy and opens a prompted chat when connected", () => {
- // Given / When
- render(
- ,
- );
-
- // Then
- const link = screen.getByRole("link", {
- name: /Find and remediate what actually matters\./,
- });
- expect(link).toHaveAttribute("href", LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT);
- expect(link).toHaveTextContent("Lighthouse AI");
- expect(link).toHaveTextContent("Find and remediate what actually matters.");
- });
-
- it("links to Lighthouse settings when no connected configuration exists", () => {
- // Given / When
- render(
- ,
- );
-
- // Then
- expect(
- screen.getByRole("link", {
- name: /Find and remediate what actually matters\./,
- }),
- ).toHaveAttribute("href", "/lighthouse/settings");
- });
-
- it("isolates its stacking so content never paints over the sticky navbar", () => {
- // Given / When: the banner's inner z-10 must stay scoped to the card —
- // without isolation it ties the sticky header's z-10 and wins by DOM order
- render(
- ,
- );
-
- // Then
- const card = screen
- .getByRole("link", { name: /Find and remediate what actually matters\./ })
- .querySelector("[data-slot='card']");
- expect(card).toHaveClass("isolate");
- });
-});
diff --git a/ui/app/(prowler)/_overview/_components/overview-banner.test.tsx b/ui/app/(prowler)/_overview/_components/overview-banner.test.tsx
new file mode 100644
index 0000000000..3912aa1f53
--- /dev/null
+++ b/ui/app/(prowler)/_overview/_components/overview-banner.test.tsx
@@ -0,0 +1,150 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { DOCS_URLS } from "@/lib/external-urls";
+
+import { LIGHTHOUSE_OVERVIEW_BANNER_HREF } from "../_lib/lighthouse-banner";
+import { OVERVIEW_BANNER_VARIANT } from "../_lib/overview-banner";
+
+import { OverviewBanner } from "./overview-banner";
+
+describe("OverviewBanner", () => {
+ it("renders Toni copy and opens a prompted chat when connected", () => {
+ // Given / When
+ render(
+ ,
+ );
+
+ // Then
+ const link = screen.getByRole("link", {
+ name: /Find and remediate what actually matters\./,
+ });
+ expect(link).toHaveAttribute("href", LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT);
+ expect(link).toHaveTextContent("Lighthouse AI");
+ expect(link).toHaveTextContent("Find and remediate what actually matters.");
+ });
+
+ it("links to Lighthouse settings when no connected configuration exists", () => {
+ // Given / When
+ render(
+ ,
+ );
+
+ // Then
+ const link = screen.getByRole("link", {
+ name: /Find and remediate what actually matters\./,
+ });
+ expect(link).toHaveAttribute("href", "/lighthouse/settings");
+ // In-app hrefs stay in the current tab
+ expect(link).not.toHaveAttribute("target");
+ });
+
+ it("opens the AI agents docs in a new tab", () => {
+ // Given / When
+ render(
+ ,
+ );
+
+ // Then
+ const link = screen.getByRole("link", {
+ name: /Connect all your agents to Prowler Cloud/,
+ });
+ expect(link).toHaveAttribute("href", DOCS_URLS.AI_AGENTS);
+ expect(link).toHaveAttribute("target", "_blank");
+ expect(link).toHaveAttribute("rel", "noopener noreferrer");
+ expect(link).toHaveTextContent(
+ "Turn your favorite agent into a Cloud Security Expert.",
+ );
+ });
+
+ it("uses the purple agents palette with the same animated layers", () => {
+ // Given / When
+ render(
+ ,
+ );
+
+ // Then
+ const link = screen.getByRole("link", {
+ name: /Connect all your agents to Prowler Cloud/,
+ });
+ const gradient = link.querySelector(".overview-banner-gradient");
+ expect(gradient).toHaveClass("overview-banner-gradient-agents");
+ expect(gradient?.querySelector(".animate-first")).toBeInTheDocument();
+ expect(gradient?.querySelector(".animate-second")).toBeInTheDocument();
+ expect(gradient?.querySelector(".animate-third")).toBeInTheDocument();
+ expect(
+ gradient?.querySelector(".overview-banner-gradient-primary-press"),
+ ).toBeInTheDocument();
+ });
+
+ it("keeps the Lighthouse banner on the default green palette", () => {
+ // Given / When
+ render(
+ ,
+ );
+
+ // Then
+ const link = screen.getByRole("link", {
+ name: /Find and remediate what actually matters\./,
+ });
+ const gradient = link.querySelector(".overview-banner-gradient");
+ expect(gradient).not.toHaveClass("overview-banner-gradient-agents");
+ });
+
+ it("scopes the blur filter id per instance so stacked banners keep their gradient", () => {
+ // Given / When: url(#id) resolves against the FIRST match in the document,
+ // so two banners sharing one id would both resolve to the same filter
+ const { container } = render(
+ <>
+
+
+ >,
+ );
+
+ // Then
+ const filterIds = Array.from(
+ container.querySelectorAll("filter"),
+ (filter) => filter.id,
+ );
+ expect(filterIds).toHaveLength(2);
+ expect(new Set(filterIds).size).toBe(2);
+ });
+
+ it("isolates its stacking so content never paints over the sticky navbar", () => {
+ // Given / When: the banner's inner z-10 must stay scoped to the card —
+ // without isolation it ties the sticky header's z-10 and wins by DOM order
+ render(
+ ,
+ );
+
+ // Then
+ const card = screen
+ .getByRole("link", { name: /Find and remediate what actually matters\./ })
+ .querySelector("[data-slot='card']");
+ expect(card).toHaveClass("isolate");
+ });
+});
diff --git a/ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.tsx b/ui/app/(prowler)/_overview/_components/overview-banner.tsx
similarity index 50%
rename from ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.tsx
rename to ui/app/(prowler)/_overview/_components/overview-banner.tsx
index 8bd8dea414..791685d35a 100644
--- a/ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.tsx
+++ b/ui/app/(prowler)/_overview/_components/overview-banner.tsx
@@ -1,29 +1,55 @@
"use client";
-import { ArrowRight } from "lucide-react";
+import { ArrowRight, Bot } from "lucide-react";
import Link from "next/link";
-import { useRef, useState } from "react";
+import { type ReactNode, useId, useRef, useState } from "react";
import { LighthouseIcon } from "@/components/icons/Icons";
import { Card, CardContent } from "@/components/shadcn";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { cn } from "@/lib/utils";
-import type { LighthouseOverviewBannerHref } from "../_lib/lighthouse-banner";
+import {
+ OVERVIEW_BANNER_VARIANT,
+ type OverviewBannerVariant,
+} from "../_lib/overview-banner";
-interface LighthouseOverviewBannerProps {
- href: LighthouseOverviewBannerHref;
+// Content lives here rather than at the call site so the icons stay inside the
+// client boundary — LighthouseIcon uses useId and cannot render on the server.
+const OVERVIEW_BANNER_CONTENT = {
+ lighthouse: {
+ icon: ,
+ title: "Lighthouse AI",
+ description: "Find and remediate what actually matters.",
+ },
+ agents: {
+ icon: ,
+ title: "Connect all your agents to Prowler Cloud",
+ description: "Turn your favorite agent into a Cloud Security Expert.",
+ },
+} as const satisfies Record<
+ OverviewBannerVariant,
+ { icon: ReactNode; title: string; description: string }
+>;
+
+interface OverviewBannerProps {
+ variant: OverviewBannerVariant;
+ href: string;
}
-export function LighthouseOverviewBanner({
- href,
-}: LighthouseOverviewBannerProps) {
+export function OverviewBanner({ variant, href }: OverviewBannerProps) {
+ const { icon, title, description } = OVERVIEW_BANNER_CONTENT[variant];
+ // Absolute hrefs leave the app, so they open in a new tab.
+ const isExternal = href.startsWith("http");
const interactiveRef = useRef(null);
const curXRef = useRef(0);
const curYRef = useRef(0);
const tgXRef = useRef(0);
const tgYRef = useRef(0);
const [isSafari, setIsSafari] = useState(false);
+ // Several banners render per page and url(#id) resolves against the FIRST
+ // matching id in the document, so the filter id must be per-instance.
+ const blurFilterId = `overview-banner-blur-${useId().replace(/[«»:]/g, "")}`;
useMountEffect(() => {
setIsSafari(/^((?!chrome|android).)*safari/i.test(navigator.userAgent));
@@ -61,19 +87,22 @@ export function LighthouseOverviewBanner({
return (
-
+
-
+
-
+
-
+
-
+
-
+ {icon}
- Lighthouse AI
+ {title}
- Find and remediate what actually matters.
+ {description}
diff --git a/ui/app/(prowler)/_overview/_lib/overview-banner.ts b/ui/app/(prowler)/_overview/_lib/overview-banner.ts
new file mode 100644
index 0000000000..9d75db6ec2
--- /dev/null
+++ b/ui/app/(prowler)/_overview/_lib/overview-banner.ts
@@ -0,0 +1,7 @@
+export const OVERVIEW_BANNER_VARIANT = {
+ LIGHTHOUSE: "lighthouse",
+ AGENTS: "agents",
+} as const;
+
+export type OverviewBannerVariant =
+ (typeof OVERVIEW_BANNER_VARIANT)[keyof typeof OVERVIEW_BANNER_VARIANT];
diff --git a/ui/app/(prowler)/_overview/attack-surface/attack-surface.ssr.tsx b/ui/app/(prowler)/_overview/attack-surface/attack-surface.ssr.tsx
index 7f5b0b0f5f..03b89db714 100644
--- a/ui/app/(prowler)/_overview/attack-surface/attack-surface.ssr.tsx
+++ b/ui/app/(prowler)/_overview/attack-surface/attack-surface.ssr.tsx
@@ -5,6 +5,7 @@ import {
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
+
import { AttackSurface } from "./_components/attack-surface";
export const AttackSurfaceSSR = async ({ searchParams }: SSRComponentProps) => {
diff --git a/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot.ssr.tsx b/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot.ssr.tsx
index 1f4d3625d4..940362dfe6 100644
--- a/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot.ssr.tsx
+++ b/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot.ssr.tsx
@@ -13,6 +13,7 @@ import {
filterProvidersByScope,
parseFilterIds,
} from "../../_lib/provider-scope";
+
import { RiskPlotClient } from "./risk-plot-client";
export async function RiskPlotSSR({
diff --git a/ui/app/(prowler)/_overview/graphs-tabs/risk-radar-view/risk-radar-view.ssr.tsx b/ui/app/(prowler)/_overview/graphs-tabs/risk-radar-view/risk-radar-view.ssr.tsx
index 932251e08a..355c03c397 100644
--- a/ui/app/(prowler)/_overview/graphs-tabs/risk-radar-view/risk-radar-view.ssr.tsx
+++ b/ui/app/(prowler)/_overview/graphs-tabs/risk-radar-view/risk-radar-view.ssr.tsx
@@ -7,6 +7,7 @@ import {
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../_lib/filter-params";
+
import { RiskRadarViewClient } from "./risk-radar-view-client";
export async function RiskRadarViewSSR({
diff --git a/ui/app/(prowler)/_overview/resources-inventory/resources-inventory.ssr.tsx b/ui/app/(prowler)/_overview/resources-inventory/resources-inventory.ssr.tsx
index a95f65f9d8..17a7df9f52 100644
--- a/ui/app/(prowler)/_overview/resources-inventory/resources-inventory.ssr.tsx
+++ b/ui/app/(prowler)/_overview/resources-inventory/resources-inventory.ssr.tsx
@@ -5,6 +5,7 @@ import {
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
+
import { ResourcesInventory } from "./_components/resources-inventory";
export const ResourcesInventorySSR = async ({
diff --git a/ui/app/(prowler)/_overview/risk-severity/risk-severity-chart.ssr.tsx b/ui/app/(prowler)/_overview/risk-severity/risk-severity-chart.ssr.tsx
index c825748169..d59b961c92 100644
--- a/ui/app/(prowler)/_overview/risk-severity/risk-severity-chart.ssr.tsx
+++ b/ui/app/(prowler)/_overview/risk-severity/risk-severity-chart.ssr.tsx
@@ -2,6 +2,7 @@ import { getFindingsBySeverity } from "@/actions/overview";
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
+
import { RiskSeverityChart } from "./_components/risk-severity-chart";
export const RiskSeverityChartSSR = async ({
diff --git a/ui/app/(prowler)/_overview/severity-over-time/_components/finding-severity-over-time.tsx b/ui/app/(prowler)/_overview/severity-over-time/_components/finding-severity-over-time.tsx
index b65b6a21f0..31cbc42cc4 100644
--- a/ui/app/(prowler)/_overview/severity-over-time/_components/finding-severity-over-time.tsx
+++ b/ui/app/(prowler)/_overview/severity-over-time/_components/finding-severity-over-time.tsx
@@ -15,6 +15,7 @@ import {
} from "@/types/severities";
import { DEFAULT_TIME_RANGE } from "../_constants/time-range.constants";
+
import { type TimeRange, TimeRangeSelector } from "./time-range-selector";
interface FindingSeverityOverTimeProps {
diff --git a/ui/app/(prowler)/_overview/severity-over-time/finding-severity-over-time.ssr.tsx b/ui/app/(prowler)/_overview/severity-over-time/finding-severity-over-time.ssr.tsx
index a2d7a36d5c..e1f9c6635e 100644
--- a/ui/app/(prowler)/_overview/severity-over-time/finding-severity-over-time.ssr.tsx
+++ b/ui/app/(prowler)/_overview/severity-over-time/finding-severity-over-time.ssr.tsx
@@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
+
import { FindingSeverityOverTime } from "./_components/finding-severity-over-time";
import { FindingSeverityOverTimeSkeleton } from "./_components/finding-severity-over-time.skeleton";
import { DEFAULT_TIME_RANGE } from "./_constants/time-range.constants";
diff --git a/ui/app/(prowler)/_overview/threat-score/threat-score.ssr.tsx b/ui/app/(prowler)/_overview/threat-score/threat-score.ssr.tsx
index 7f5364d147..244c50527e 100644
--- a/ui/app/(prowler)/_overview/threat-score/threat-score.ssr.tsx
+++ b/ui/app/(prowler)/_overview/threat-score/threat-score.ssr.tsx
@@ -2,6 +2,7 @@ import { getThreatScore } from "@/actions/overview";
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
+
import { ThreatScore } from "./_components/threat-score";
export const ThreatScoreSSR = async ({ searchParams }: SSRComponentProps) => {
diff --git a/ui/app/(prowler)/_overview/watchlist/compliance-watchlist.ssr.tsx b/ui/app/(prowler)/_overview/watchlist/compliance-watchlist.ssr.tsx
index d0aae42dad..e897a58ea6 100644
--- a/ui/app/(prowler)/_overview/watchlist/compliance-watchlist.ssr.tsx
+++ b/ui/app/(prowler)/_overview/watchlist/compliance-watchlist.ssr.tsx
@@ -5,6 +5,7 @@ import {
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
+
import { ComplianceWatchlist } from "./_components/compliance-watchlist";
export const ComplianceWatchlistSSR = async ({
diff --git a/ui/app/(prowler)/_overview/watchlist/service-watchlist.ssr.tsx b/ui/app/(prowler)/_overview/watchlist/service-watchlist.ssr.tsx
index 9ec08a6cb1..a84d9f1c56 100644
--- a/ui/app/(prowler)/_overview/watchlist/service-watchlist.ssr.tsx
+++ b/ui/app/(prowler)/_overview/watchlist/service-watchlist.ssr.tsx
@@ -2,6 +2,7 @@ import { getServicesOverview, ServiceOverview } from "@/actions/overview";
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
+
import { ServiceWatchlist } from "./_components/service-watchlist";
export const ServiceWatchlistSSR = async ({
diff --git a/ui/app/(prowler)/alerts/_actions/alerts.test.ts b/ui/app/(prowler)/alerts/_actions/alerts.test.ts
index ccd59f5d97..e452bbc8ff 100644
--- a/ui/app/(prowler)/alerts/_actions/alerts.test.ts
+++ b/ui/app/(prowler)/alerts/_actions/alerts.test.ts
@@ -28,6 +28,7 @@ vi.mock("@/lib/server-actions-helper", () => ({
}));
import { ALERT_AGGREGATE_OPS, ALERT_TRIGGER_KINDS } from "../_types";
+
import {
createAlert,
deleteAlert,
diff --git a/ui/app/(prowler)/alerts/_components/alerts-manager.tsx b/ui/app/(prowler)/alerts/_components/alerts-manager.tsx
index 92f11631ad..c9b499b251 100644
--- a/ui/app/(prowler)/alerts/_components/alerts-manager.tsx
+++ b/ui/app/(prowler)/alerts/_components/alerts-manager.tsx
@@ -15,12 +15,10 @@ import {
ALERT_TRIGGER_KINDS,
type AlertRule,
} from "@/app/(prowler)/alerts/_types";
-import { Button } from "@/components/shadcn";
-import { useToast } from "@/components/shadcn";
+import { Button, useToast } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { DOCS_URLS } from "@/lib/external-urls";
-import type { MetaDataProps } from "@/types";
-import type { ScanEntity } from "@/types";
+import type { MetaDataProps, ScanEntity } from "@/types";
import type { ProviderProps } from "@/types/providers";
import { toAlertPayload } from "../_lib/alert-adapter";
@@ -29,6 +27,7 @@ import type {
AlertFormSubmitResult,
AlertFormValues,
} from "../_types/alert-form";
+
import { AlertFormModal } from "./alert-form-modal";
import { AlertsEmptyState } from "./alerts-empty-state";
import { AlertsTable } from "./alerts-table";
diff --git a/ui/app/(prowler)/alerts/_components/seed-from-findings-button.tsx b/ui/app/(prowler)/alerts/_components/seed-from-findings-button.tsx
index 6b94549a9e..988f7e70f8 100644
--- a/ui/app/(prowler)/alerts/_components/seed-from-findings-button.tsx
+++ b/ui/app/(prowler)/alerts/_components/seed-from-findings-button.tsx
@@ -28,8 +28,9 @@ import {
Tooltip,
TooltipContent,
TooltipTrigger,
+ ToastAction,
+ useToast,
} from "@/components/shadcn";
-import { ToastAction, useToast } from "@/components/shadcn";
import { useCloudUpgradeStore } from "@/store";
import type { ScanEntity } from "@/types";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.test.tsx
index 20210a3126..f95aa99870 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.test.tsx
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.test.tsx
@@ -2,6 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ATTACK_PATHS_VIEW_STATES } from "../_lib/get-attack-paths-view-state";
+
import { AttackPathsStatusPanel } from "./attack-paths-status-panel";
describe("AttackPathsStatusPanel", () => {
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx
index 6e9c608f64..6f802e9d75 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx
@@ -31,9 +31,11 @@ import {
getNodeColor,
getPathEdges,
GRAPH_EDGE_HIGHLIGHT_COLOR,
+ isProwlerFindingNode,
resolveHiddenFindingIds,
} from "../../_lib";
-import { isFindingNode, layoutWithDagre } from "../../_lib/layout";
+import { layoutWithDagre } from "../../_lib/layout";
+
import { FindingNode } from "./nodes/finding-node";
import { InternetNode } from "./nodes/internet-node";
import { ResourceNode } from "./nodes/resource-node";
@@ -387,7 +389,7 @@ const GraphCanvas = ({
const findingToResources = new Map>();
nodes.forEach((n) => {
- if (isFindingNode(n.labels)) findingNodeIds.add(n.id);
+ if (isProwlerFindingNode(n.labels)) findingNodeIds.add(n.id);
});
const resourcesWithFindings = new Set();
@@ -459,7 +461,7 @@ const GraphCanvas = ({
hidden: hiddenFindingIds.has(node.id),
className: cn(
node.className,
- isFindingNode(node.data.graphNode.labels) ||
+ isProwlerFindingNode(node.data.graphNode.labels) ||
resourcesWithFindings.has(node.id)
? "cursor-pointer"
: "cursor-default",
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.tsx
index 03e17cfd3e..c16105140d 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.tsx
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.tsx
@@ -23,6 +23,7 @@ import {
GRAPH_NODE_COLORS,
} from "../../_lib/graph-colors";
import { resolveHiddenFindingIds } from "../../_lib/graph-utils";
+import { isProwlerFindingNode } from "../../_lib/node-types";
import { NODE_CATEGORY, resolveNodeVisual } from "../../_lib/node-visuals";
const LEGEND_PREVIEW = {
@@ -197,7 +198,7 @@ const edgeItems: LegendEdgeItem[] = [
];
const isFindingNode = (node: GraphNode): boolean =>
- node.labels.some((label) => label.toLowerCase().includes("finding"));
+ isProwlerFindingNode(node.labels);
const getGraphEdges = (
data: AttackPathGraphData,
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx
index 789a379551..78583c628a 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx
@@ -12,6 +12,7 @@ import type { GraphNode } from "@/types/attack-paths";
import { resolveNodeColors, resolveNodeVisual } from "../../../_lib";
import { FINDING_NODE_DIMENSIONS } from "../../../_lib/node-dimensions";
import { getNodeLabelDisplay } from "../../../_lib/node-label-lines";
+
import { HiddenHandles } from "./hidden-handles";
interface FindingNodeData {
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/internet-node.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/internet-node.tsx
index e2009f71c9..097e2903c5 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/internet-node.tsx
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/internet-node.tsx
@@ -5,6 +5,7 @@ import { type NodeProps } from "@xyflow/react";
import type { GraphNode } from "@/types/attack-paths";
import { resolveNodeColors } from "../../../_lib";
+
import { HiddenHandles } from "./hidden-handles";
interface InternetNodeData {
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx
index 9860dbea27..3120129091 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx
@@ -12,6 +12,7 @@ import type { GraphNode } from "@/types/attack-paths";
import { resolveNodeColors, resolveNodeVisual } from "../../../_lib";
import { RESOURCE_NODE_DIMENSIONS } from "../../../_lib/node-dimensions";
import { getNodeLabelDisplay } from "../../../_lib/node-label-lines";
+
import { HiddenHandles } from "./hidden-handles";
interface ResourceNodeData {
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.test.tsx
index 1183120a54..12c8d9638e 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.test.tsx
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.test.tsx
@@ -50,6 +50,15 @@ const resourceNode: GraphNode = {
},
};
+const guardDutyNode: GraphNode = {
+ id: "guard-duty-node-id",
+ labels: ["GuardDutyFinding"],
+ properties: {
+ id: "guard-duty-123",
+ title: "Port probe",
+ },
+};
+
describe("NodeDetailPanel", () => {
it("renders the view finding button only for finding nodes", () => {
const { rerender } = render( );
@@ -65,6 +74,17 @@ describe("NodeDetailPanel", () => {
).not.toBeInTheDocument();
});
+ it("does not render the view finding button for cloud-provider finding resources", () => {
+ // Given/When
+ render( );
+
+ // Then
+ expect(
+ screen.queryByRole("button", { name: /view finding/i }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByText("Node findings")).toBeInTheDocument();
+ });
+
it("calls onViewFinding with the node finding id", async () => {
const user = userEvent.setup();
const onViewFinding = vi.fn();
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.tsx
index 2dc42b2fc1..1e6f013aec 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.tsx
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.tsx
@@ -11,6 +11,8 @@ import {
import { Spinner } from "@/components/shadcn/spinner/spinner";
import type { GraphNode } from "@/types/attack-paths";
+import { isProwlerFindingNode } from "../../_lib";
+
import { NodeFindings } from "./node-findings";
import { NodeOverview } from "./node-overview";
import { NodeResources } from "./node-resources";
@@ -37,9 +39,7 @@ export const NodeDetailContent = ({
onViewFinding?: (findingId: string) => void;
viewFindingLoading?: boolean;
}) => {
- const isProwlerFinding = node?.labels.some((label) =>
- label.toLowerCase().includes("finding"),
- );
+ const isProwlerFinding = isProwlerFindingNode(node.labels);
return (
@@ -105,9 +105,7 @@ export const NodeDetailPanel = ({
}: NodeDetailPanelProps) => {
const isOpen = node !== null;
- const isProwlerFinding = node?.labels.some((label) =>
- label.toLowerCase().includes("finding"),
- );
+ const isProwlerFinding = node ? isProwlerFindingNode(node.labels) : false;
const findingId = node ? String(node.properties?.id || node.id) : "";
return (
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-overview.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-overview.tsx
index d2ee88bf5f..1961b43316 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-overview.tsx
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-overview.tsx
@@ -5,7 +5,7 @@ import { DateWithTime } from "@/components/shadcn/entities/date-with-time";
import { InfoField } from "@/components/shadcn/info-field/info-field";
import type { GraphNode, GraphNodePropertyValue } from "@/types/attack-paths";
-import { formatNodeLabels } from "../../_lib";
+import { formatNodeLabels, isProwlerFindingNode } from "../../_lib";
interface NodeOverviewProps {
node: GraphNode;
@@ -25,9 +25,7 @@ export const NodeOverview = ({ node }: NodeOverviewProps) => {
return String(value);
};
- const isFinding = node.labels.some((label) =>
- label.toLowerCase().includes("finding"),
- );
+ const isFinding = isProwlerFindingNode(node.labels);
return (
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.ts
index a10e0d474e..851ac027ff 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.ts
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.ts
@@ -22,6 +22,7 @@ import {
RESOURCE_NODE_DIMENSIONS,
} from "./node-dimensions";
import { getNodeLabelDisplay } from "./node-label-lines";
+import { isProwlerFindingNode } from "./node-types";
import { resolveNodeVisual } from "./node-visuals";
interface ExportGraphOptions {
@@ -67,8 +68,7 @@ const downloadDataUrl = (dataUrl: string, filename: string) => {
document.body.removeChild(link);
};
-const isFindingNode = (labels: string[]) =>
- labels.some((label) => label.toLowerCase().includes("finding"));
+const isFindingNode = isProwlerFindingNode;
const getGraphEdges = (graphData: AttackPathGraphData): GraphEdge[] => {
if (graphData.edges?.length) return graphData.edges;
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.ts
index 6c79d6c60a..49abf16ad3 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.ts
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.ts
@@ -1,3 +1,5 @@
+import { isProwlerFindingNode } from "./node-types";
+
/**
* Color constants for attack path graph visualization
* Colors chosen to work well in both light and dark themes
@@ -67,7 +69,7 @@ export const getNodeColor = (
labels: string[],
properties?: Record
,
): string => {
- const isFinding = labels.some((l) => l.toLowerCase().includes("finding"));
+ const isFinding = isProwlerFindingNode(labels);
if (isFinding && properties?.severity) {
const severity = String(properties.severity).toLowerCase();
if (severity === "critical") return GRAPH_NODE_COLORS.critical;
@@ -100,7 +102,7 @@ export const getNodeBorderColor = (
labels: string[],
properties?: Record,
): string => {
- const isFinding = labels.some((l) => l.toLowerCase().includes("finding"));
+ const isFinding = isProwlerFindingNode(labels);
if (isFinding && properties?.severity) {
const severity = String(properties.severity).toLowerCase();
if (severity === "critical") return GRAPH_NODE_BORDER_COLORS.critical;
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-utils.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-utils.ts
index e77126039f..c730f7a699 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-utils.ts
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-utils.ts
@@ -4,6 +4,8 @@
import type { AttackPathGraphData } from "@/types/attack-paths";
+import { isProwlerFindingNode } from "./node-types";
+
export const resolveHiddenFindingIds = ({
expandedResources,
findingNodeIds,
@@ -103,11 +105,11 @@ export const computeFilteredSubgraph = (
// Also include findings directly connected to the selected node
const nodeLabelMap = new Map(nodes.map((n) => [n.id, n.labels]));
edges.forEach((edge) => {
- const sourceIsFinding = (nodeLabelMap.get(edge.source) ?? []).some((l) =>
- l.toLowerCase().includes("finding"),
+ const sourceIsFinding = isProwlerFindingNode(
+ nodeLabelMap.get(edge.source) ?? [],
);
- const targetIsFinding = (nodeLabelMap.get(edge.target) ?? []).some((l) =>
- l.toLowerCase().includes("finding"),
+ const targetIsFinding = isProwlerFindingNode(
+ nodeLabelMap.get(edge.target) ?? [],
);
// Include findings connected to the selected node
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts
index 261a27cf8d..50fb64495e 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts
@@ -18,6 +18,7 @@ export {
resolveHiddenFindingIds,
} from "./graph-utils";
export { layoutWithDagre } from "./layout";
+export { isProwlerFindingLabel, isProwlerFindingNode } from "./node-types";
export {
NODE_CATEGORY,
type NodeCategory,
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.test.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.test.ts
index 7d8cb20c96..87a117771c 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.test.ts
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.test.ts
@@ -17,6 +17,18 @@ const resourceNode: GraphNode = {
properties: { name: "bucket-1" },
};
+const guardDutyNode: GraphNode = {
+ id: "guard-duty-1",
+ labels: ["GuardDutyFinding"],
+ properties: { title: "Port probe", severity: "high" },
+};
+
+const inspectorNode: GraphNode = {
+ id: "inspector-1",
+ labels: ["AWSInspectorFinding"],
+ properties: { title: "Package vulnerability", severity: "high" },
+};
+
const internetNode: GraphNode = {
id: "internet-1",
labels: ["Internet"],
@@ -54,6 +66,42 @@ describe("layoutWithDagre", () => {
});
});
+ it("treats cloud-provider finding resources as resource nodes", () => {
+ const { rfNodes } = layoutWithDagre(
+ [findingNode, guardDutyNode, inspectorNode],
+ [],
+ );
+
+ const byId = new Map(rfNodes.map((n) => [n.id, n]));
+
+ expect(byId.get("finding-1")?.type).toBe("finding");
+ expect(byId.get("guard-duty-1")).toMatchObject({
+ type: "resource",
+ width: 136,
+ height: 124,
+ });
+ expect(byId.get("inspector-1")?.type).toBe("resource");
+ });
+
+ it("does not animate edges that only touch cloud-provider finding resources", () => {
+ const { rfEdges } = layoutWithDagre(
+ [resourceNode, guardDutyNode],
+ [
+ {
+ id: "e1",
+ source: "guard-duty-1",
+ target: "resource-1",
+ type: "AFFECTS",
+ },
+ ],
+ );
+
+ expect(rfEdges[0]).toMatchObject({
+ animated: false,
+ className: "resource-edge",
+ });
+ });
+
it("is deterministic: same input produces equal output across runs", () => {
const nodes = [findingNode, resourceNode];
const edges: GraphEdge[] = [
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.ts
index cb67d11083..c7997ecd08 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.ts
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.ts
@@ -13,6 +13,7 @@ import {
INTERNET_NODE_DIMENSIONS,
RESOURCE_NODE_DIMENSIONS,
} from "./node-dimensions";
+import { isProwlerFindingNode } from "./node-types";
// Container relationships that get reversed for proper hierarchy
const CONTAINER_RELATIONS = new Set([
@@ -34,8 +35,7 @@ const NODE_TYPE = {
type NodeType = (typeof NODE_TYPE)[keyof typeof NODE_TYPE];
-export const isFindingNode = (labels: string[]): boolean =>
- labels.some((l) => l.toLowerCase().includes("finding"));
+const isFindingNode = isProwlerFindingNode;
const getNodeType = (labels: string[]): NodeType => {
if (isFindingNode(labels)) return NODE_TYPE.FINDING;
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-types.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-types.ts
new file mode 100644
index 0000000000..5de9eb6da5
--- /dev/null
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-types.ts
@@ -0,0 +1,8 @@
+const normalizeNodeLabel = (label: string): string =>
+ label.toLowerCase().replace(/[^a-z0-9]/g, "");
+
+export const isProwlerFindingLabel = (label: string): boolean =>
+ normalizeNodeLabel(label) === "prowlerfinding";
+
+export const isProwlerFindingNode = (labels: string[]): boolean =>
+ labels.some(isProwlerFindingLabel);
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.test.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.test.ts
index cb5f70f06e..48790f29e7 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.test.ts
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.test.ts
@@ -193,6 +193,28 @@ describe("resolveNodeVisual", () => {
});
});
+ it("should resolve cloud-provider finding resources as non-finding nodes", () => {
+ // Given
+ const guardDutyNode = buildNode(["GuardDutyFinding"], {
+ title: "Port probe",
+ severity: "high",
+ });
+ const inspectorNode = buildNode(["AWSInspectorFinding"], {
+ title: "Package vulnerability",
+ severity: "high",
+ });
+
+ // When
+ const guardDutyVisual = resolveNodeVisual(guardDutyNode);
+ const inspectorVisual = resolveNodeVisual(inspectorNode);
+
+ // Then
+ expect(guardDutyVisual.category).not.toBe(NODE_CATEGORY.FINDING);
+ expect(guardDutyVisual.description).toBe("Guard Duty Finding");
+ expect(inspectorVisual.category).not.toBe(NODE_CATEGORY.FINDING);
+ expect(inspectorVisual.description).toBe("Aws Inspector Finding");
+ });
+
it("should resolve finding icons from severity", () => {
// Given
const findingNodes = [
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts
index fca0e4ae67..e291855737 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts
@@ -49,6 +49,7 @@ import {
import type { GraphNode, GraphNodePropertyValue } from "@/types/attack-paths";
import { formatNodeLabel } from "./format";
+import { isProwlerFindingLabel } from "./node-types";
export const NODE_CATEGORY = {
FINDING: "finding",
@@ -396,8 +397,7 @@ const normalizeLabel = (label: string): string =>
const isKnownNodeLabel = (label: string): label is KnownNodeLabel =>
label in KNOWN_NODE_VISUALS;
-const isFindingLabel = (label: string): boolean =>
- normalizeLabel(label).includes("finding");
+const isFindingLabel = isProwlerFindingLabel;
const isInternetLabel = (label: string): boolean =>
normalizeLabel(label) === "internet";
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx
index 7f982e7857..f882d46efd 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx
@@ -35,8 +35,8 @@ vi.mock("@/actions/findings", async () => {
});
import { useGraphStore } from "./_hooks/use-graph-state";
-import { getPathEdges } from "./_lib";
-import { isFindingNode, layoutWithDagre } from "./_lib/layout";
+import { getPathEdges, isProwlerFindingNode } from "./_lib";
+import { layoutWithDagre } from "./_lib/layout";
import AttackPathsPage from "./attack-paths-page";
import { fixtures, type PageFixture } from "./attack-paths-page.fixtures";
import { AttackPathPageHarness } from "./attack-paths-page.harness";
@@ -268,7 +268,7 @@ describe("running a query", () => {
if (!fixture.queryResult) throw new Error("Expected graph fixture data");
const visibleNodes = fixture.queryResult.nodes.filter(
- (node) => !isFindingNode(node.labels),
+ (node) => !isProwlerFindingNode(node.labels),
);
const visibleNodeIds = new Set(visibleNodes.map((node) => node.id));
const visibleEdges = (fixture.queryResult.relationships ?? [])
@@ -477,7 +477,7 @@ describe("exploring the graph", () => {
const findingIds = new Set(
(fixture.queryResult?.nodes ?? [])
- .filter((node) => isFindingNode(node.labels))
+ .filter((node) => isProwlerFindingNode(node.labels))
.map((node) => node.id),
);
const visibleEdges = (fixture.queryResult?.relationships ?? [])
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts
index ad2d4bf72d..ebae771f3d 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts
@@ -7,6 +7,7 @@
import { vi } from "vitest";
import { userEvent } from "vitest/browser";
+import { isProwlerFindingNode } from "./_lib";
import type { PageFixture } from "./attack-paths-page.fixtures";
export class AttackPathPageHarness {
@@ -458,9 +459,7 @@ export class AttackPathPageHarness {
async clickFirstResourceNodeWithoutFindings(): Promise {
const findingIds = new Set(
(this.fixture.queryResult?.nodes ?? [])
- .filter((n) =>
- n.labels.some((l) => l.toLowerCase().includes("finding")),
- )
+ .filter((n) => isProwlerFindingNode(n.labels))
.map((n) => n.id),
);
const resourceWithFindingIds = new Set();
@@ -528,9 +527,7 @@ export class AttackPathPageHarness {
async expandAllFindings(): Promise {
const findingIds = new Set(
(this.fixture.queryResult?.nodes ?? [])
- .filter((n) =>
- n.labels.some((l) => l.toLowerCase().includes("finding")),
- )
+ .filter((n) => isProwlerFindingNode(n.labels))
.map((n) => n.id),
);
const resourceWithFindingIds = new Set();
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx
index cc87406aa8..d8d04e9def 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx
@@ -28,13 +28,13 @@ import {
import { StatusAlert } from "@/components/shared/status-alert";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { isCloud } from "@/lib/shared/env";
+import { attackPathsEmptyTour } from "@/lib/tours/attack-paths-empty.tour";
import {
attackPathsTour,
type AttackPathsTourTarget,
pickDemoQuery,
pickDemoScan,
} from "@/lib/tours/attack-paths.tour";
-import { attackPathsEmptyTour } from "@/lib/tours/attack-paths-empty.tour";
import { advanceActiveTour, useDriverTour } from "@/lib/tours/use-driver-tour";
import type {
AttackPathQuery,
@@ -60,7 +60,7 @@ import type { GraphHandle } from "./_components/graph/attack-path-graph";
import { useAttackPathScans } from "./_hooks/use-attack-path-scans";
import { useGraphState } from "./_hooks/use-graph-state";
import { useQueryBuilder } from "./_hooks/use-query-builder";
-import { exportGraphAsPNG } from "./_lib";
+import { exportGraphAsPNG, isProwlerFindingNode } from "./_lib";
import {
ATTACK_PATHS_VIEW_STATES,
getAttackPathsViewState,
@@ -325,9 +325,7 @@ export default function AttackPathsPage() {
};
const handleNodeClick = (node: GraphNode) => {
- const isFinding = node.labels.some((label) =>
- label.toLowerCase().includes("finding"),
- );
+ const isFinding = isProwlerFindingNode(node.labels);
if (isFinding) {
if (findingNavigationInFlightRef.current) {
@@ -347,9 +345,7 @@ export default function AttackPathsPage() {
if (edge.source !== node.id && edge.target !== node.id) return false;
const otherId = edge.source === node.id ? edge.target : edge.source;
const otherNode = sourceData.nodes?.find(({ id }) => id === otherId);
- return otherNode?.labels.some((label) =>
- label.toLowerCase().includes("finding"),
- );
+ return otherNode ? isProwlerFindingNode(otherNode.labels) : false;
});
if (hasFindings) {
diff --git a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx
index 617d943030..531cfbe79d 100644
--- a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx
+++ b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx
@@ -6,6 +6,7 @@ import { useCloudUpgradeStore } from "@/store";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import { COMPLIANCE_TAB } from "../_types";
+
import { CompliancePageTabs } from "./compliance-page-tabs";
import { getComplianceTab } from "./compliance-page-tabs.shared";
diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-detail.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-detail.tsx
index 7972379b1f..4901294ad4 100644
--- a/ui/app/(prowler)/compliance/_components/cross-provider-detail.tsx
+++ b/ui/app/(prowler)/compliance/_components/cross-provider-detail.tsx
@@ -29,6 +29,7 @@ import {
parseCrossProviderFilters,
} from "../_lib/cross-provider-frameworks";
import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types";
+
import { CrossProviderErrorAlert } from "./cross-provider-error-alert";
import type {
CrossProviderAccountOption,
diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-overview.test.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-overview.test.tsx
index b2fb3b66f4..1e5a71deba 100644
--- a/ui/app/(prowler)/compliance/_components/cross-provider-overview.test.tsx
+++ b/ui/app/(prowler)/compliance/_components/cross-provider-overview.test.tsx
@@ -11,6 +11,7 @@ import {
CROSS_PROVIDER_OVERVIEW_RESULT_STATUS,
CROSS_PROVIDER_OVERVIEW_TYPE,
} from "../_types";
+
import { CrossProviderOverview } from "./cross-provider-overview";
vi.mock("../_actions/cross-provider", () => ({
diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-overview.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-overview.tsx
index 17af2b38bf..d115ed4e0e 100644
--- a/ui/app/(prowler)/compliance/_components/cross-provider-overview.tsx
+++ b/ui/app/(prowler)/compliance/_components/cross-provider-overview.tsx
@@ -15,6 +15,7 @@ import {
} from "../_lib/cross-provider-frameworks";
import type { CrossProviderFrameworkSummary } from "../_types";
import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types";
+
import { CrossProviderErrorAlert } from "./cross-provider-error-alert";
import type {
CrossProviderAccountOption,
diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-requirement-content.test.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-requirement-content.test.tsx
index 98a9f11aab..5ec45475ce 100644
--- a/ui/app/(prowler)/compliance/_components/cross-provider-requirement-content.test.tsx
+++ b/ui/app/(prowler)/compliance/_components/cross-provider-requirement-content.test.tsx
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest";
import type { CheckProviderTypesMap, Requirement } from "@/types/compliance";
import type { CrossProviderRequirementExtras } from "../_types";
+
import { CrossProviderRequirementContent } from "./cross-provider-requirement-content";
const { clientAccordionContentMock } = vi.hoisted(() => ({
diff --git a/ui/app/(prowler)/compliance/_components/provider-coverage-card.test.tsx b/ui/app/(prowler)/compliance/_components/provider-coverage-card.test.tsx
index d1f73d264a..ac30a01f53 100644
--- a/ui/app/(prowler)/compliance/_components/provider-coverage-card.test.tsx
+++ b/ui/app/(prowler)/compliance/_components/provider-coverage-card.test.tsx
@@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { ProviderBreakdownEntry } from "../_types";
+
import { ProviderCoverageCard } from "./provider-coverage-card";
vi.mock("@/components/icons/providers-badge/provider-type-icon", () => ({
diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx
index 77cb1d8d0e..4a7f7d2f80 100644
--- a/ui/app/(prowler)/findings/page.tsx
+++ b/ui/app/(prowler)/findings/page.tsx
@@ -24,6 +24,7 @@ import {
extractSortAndKey,
hasDateOrScanFilter,
} from "@/lib";
+import { getFindingGroupFilterOptions } from "@/lib/finding-group-filter-options";
import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters";
import { isCloud } from "@/lib/shared/env";
import { ScanEntity, ScanProps } from "@/types";
@@ -68,6 +69,13 @@ export default async function Findings({
metadataInfoData?.data?.attributes?.resource_types || [];
const uniqueCategories = metadataInfoData?.data?.attributes?.categories || [];
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
+ const fetchFindingGroupFilterOptions = hasHistoricalData
+ ? getFindingGroups
+ : getLatestFindingGroups;
+ const checkOptions = await getFindingGroupFilterOptions({
+ fetchFindingGroups: fetchFindingGroupFilterOptions,
+ filters: resolvedFilters,
+ });
const completedScans = scansData?.data?.filter(
(scan: ScanProps) =>
@@ -110,6 +118,7 @@ export default async function Findings({
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
+ checkOptions={checkOptions}
trailingControls={
{
const page = parseInt(searchParams.page?.toString() || "1", 10);
const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10);
+ const expandedCheckIdParam = searchParams.expandedCheckId;
+ const expandedCheckId = Array.isArray(expandedCheckIdParam)
+ ? expandedCheckIdParam[0]
+ : expandedCheckIdParam;
const { encodedSort } = extractSortAndKey(searchParams);
const hasHistoricalData = hasDateOrScanFilter(filters);
@@ -178,6 +191,7 @@ const SSRDataTable = async ({
metadata={findingGroupsData?.meta}
resolvedFilters={filters}
hasHistoricalData={hasHistoricalData}
+ expandedCheckId={expandedCheckId}
/>
>
);
diff --git a/ui/app/(prowler)/invitations/(send-invite)/new/page.tsx b/ui/app/(prowler)/invitations/(send-invite)/new/page.tsx
index 520760092b..d0372c4f4b 100644
--- a/ui/app/(prowler)/invitations/(send-invite)/new/page.tsx
+++ b/ui/app/(prowler)/invitations/(send-invite)/new/page.tsx
@@ -1,4 +1,3 @@
-import React from "react";
import { Suspense } from "react";
import { getRoles } from "@/actions/roles";
diff --git a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-view.tsx b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-view.tsx
index 0060dbe44e..9b687383bc 100644
--- a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-view.tsx
+++ b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-view.tsx
@@ -29,6 +29,7 @@ import {
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { ProviderIcon } from "../config/provider-icon";
+
import { ChatComposerPanel } from "./composer";
import { ChatEmptyState } from "./empty-state";
import { useLighthouseChatStore } from "./lighthouse-chat-store-provider";
diff --git a/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.test.tsx b/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.test.tsx
index f903bd87e2..706ed7420d 100644
--- a/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.test.tsx
+++ b/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.test.tsx
@@ -170,11 +170,12 @@ describe("LighthousePanelChat", () => {
// When
render( );
- // Then: composer is live and the empty state lists recent chats
+ // Then: composer is live and the empty state lists recent chats.
+ // Sessions load on a later render than the composer, so await them.
expect(
await screen.findByRole("textbox", { name: "Message" }),
).toBeInTheDocument();
- expect(screen.getByText("Recent chats")).toBeInTheDocument();
+ expect(await screen.findByText("Recent chats")).toBeInTheDocument();
expect(
await screen.findByText("Counting critical findings"),
).toBeInTheDocument();
diff --git a/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.tsx b/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.tsx
index acecf64b32..371075a088 100644
--- a/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.tsx
+++ b/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.tsx
@@ -42,6 +42,7 @@ import {
LIGHTHOUSE_CHAT_SURFACE,
LighthouseV2ChatView,
} from "../chat/lighthouse-v2-chat-view";
+
import { LighthousePanelChatSkeleton } from "./lighthouse-panel-chat-skeleton";
const PANEL_CHAT_STATUS = {
diff --git a/ui/app/(prowler)/lighthouse/settings/(connect-llm)/connect/page.tsx b/ui/app/(prowler)/lighthouse/settings/(connect-llm)/connect/page.tsx
index acebbf9cb4..f0f7c29a57 100644
--- a/ui/app/(prowler)/lighthouse/settings/(connect-llm)/connect/page.tsx
+++ b/ui/app/(prowler)/lighthouse/settings/(connect-llm)/connect/page.tsx
@@ -6,6 +6,7 @@ import { Suspense } from "react";
import { ConnectLLMProvider } from "@/components/lighthouse-v1/connect-llm-provider";
import { SelectBedrockAuthMethod } from "@/components/lighthouse-v1/select-bedrock-auth-method";
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
+import { isCloud } from "@/lib/shared/env";
import type { LighthouseProvider } from "@/types/lighthouse-v1";
export const BEDROCK_AUTH_MODES = {
@@ -44,7 +45,7 @@ function ConnectContent() {
}
export default function ConnectLLMProviderPage() {
- if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
+ if (isCloud()) {
redirect(LIGHTHOUSE_ROUTE.SETTINGS);
}
diff --git a/ui/app/(prowler)/lighthouse/settings/(connect-llm)/layout.tsx b/ui/app/(prowler)/lighthouse/settings/(connect-llm)/layout.tsx
index 8c8db3f7c7..9b03f5b3a2 100644
--- a/ui/app/(prowler)/lighthouse/settings/(connect-llm)/layout.tsx
+++ b/ui/app/(prowler)/lighthouse/settings/(connect-llm)/layout.tsx
@@ -12,8 +12,7 @@ import {
} from "@/actions/lighthouse-v1/lighthouse";
import { DeleteLLMProviderForm } from "@/components/lighthouse-v1/forms/delete-llm-provider-form";
import { WorkflowConnectLLM } from "@/components/lighthouse-v1/workflow";
-import { Button } from "@/components/shadcn";
-import { NavigationHeader } from "@/components/shadcn";
+import { Button, NavigationHeader } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
import type { LighthouseProvider } from "@/types/lighthouse-v1";
diff --git a/ui/app/(prowler)/lighthouse/settings/(connect-llm)/select-model/page.tsx b/ui/app/(prowler)/lighthouse/settings/(connect-llm)/select-model/page.tsx
index 7b61e92c62..f3e72b3063 100644
--- a/ui/app/(prowler)/lighthouse/settings/(connect-llm)/select-model/page.tsx
+++ b/ui/app/(prowler)/lighthouse/settings/(connect-llm)/select-model/page.tsx
@@ -5,6 +5,7 @@ import { Suspense } from "react";
import { SelectModel } from "@/components/lighthouse-v1/select-model";
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
+import { isCloud } from "@/lib/shared/env";
import type { LighthouseProvider } from "@/types/lighthouse-v1";
function SelectModelContent() {
@@ -27,7 +28,7 @@ function SelectModelContent() {
}
export default function SelectModelPage() {
- if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
+ if (isCloud()) {
redirect(LIGHTHOUSE_ROUTE.SETTINGS);
}
diff --git a/ui/app/(prowler)/mutelist/_components/advanced-mutelist-form.tsx b/ui/app/(prowler)/mutelist/_components/advanced-mutelist-form.tsx
index 5d30308ee3..b7a6885b55 100644
--- a/ui/app/(prowler)/mutelist/_components/advanced-mutelist-form.tsx
+++ b/ui/app/(prowler)/mutelist/_components/advanced-mutelist-form.tsx
@@ -15,8 +15,8 @@ import {
FieldError,
Skeleton,
Textarea,
+ useToast,
} from "@/components/shadcn";
-import { useToast } from "@/components/shadcn";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
import { Modal } from "@/components/shadcn/modal";
import { fontMono } from "@/config/fonts";
diff --git a/ui/app/(prowler)/mutelist/_components/simple/mute-rule-enabled-toggle.tsx b/ui/app/(prowler)/mutelist/_components/simple/mute-rule-enabled-toggle.tsx
index 73af798640..40bcf5d3ff 100644
--- a/ui/app/(prowler)/mutelist/_components/simple/mute-rule-enabled-toggle.tsx
+++ b/ui/app/(prowler)/mutelist/_components/simple/mute-rule-enabled-toggle.tsx
@@ -4,8 +4,7 @@ import { useState } from "react";
import { toggleMuteRule } from "@/actions/mute-rules";
import { MuteRuleData } from "@/actions/mute-rules/types";
-import { Switch } from "@/components/shadcn";
-import { useToast } from "@/components/shadcn";
+import { Switch, useToast } from "@/components/shadcn";
interface MuteRuleEnabledToggleProps {
muteRule: MuteRuleData;
diff --git a/ui/app/(prowler)/mutelist/_components/simple/mute-rule-targets-modal.test.tsx b/ui/app/(prowler)/mutelist/_components/simple/mute-rule-targets-modal.test.tsx
index eb8db616e2..fd45dc2e91 100644
--- a/ui/app/(prowler)/mutelist/_components/simple/mute-rule-targets-modal.test.tsx
+++ b/ui/app/(prowler)/mutelist/_components/simple/mute-rule-targets-modal.test.tsx
@@ -3,6 +3,7 @@ import { type ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import { type MuteRuleTableData } from "./mute-rule-target-previews";
+import { MuteRuleTargetsModal } from "./mute-rule-targets-modal";
vi.mock("@/components/shadcn/modal", () => ({
Modal: ({
@@ -21,8 +22,6 @@ vi.mock("@/components/shadcn/modal", () => ({
) : null,
}));
-import { MuteRuleTargetsModal } from "./mute-rule-targets-modal";
-
const longMuteRule: MuteRuleTableData = {
type: "mute-rules",
id: "mute-rule-1",
diff --git a/ui/app/(prowler)/page.test.tsx b/ui/app/(prowler)/page.test.tsx
new file mode 100644
index 0000000000..78ddc4c550
--- /dev/null
+++ b/ui/app/(prowler)/page.test.tsx
@@ -0,0 +1,28 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { describe, expect, it } from "vitest";
+
+describe("Overview page", () => {
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
+ const filePath = path.join(currentDir, "page.tsx");
+ const source = readFileSync(filePath, "utf8");
+
+ it("renders the overview banners before the provider filters", () => {
+ // Given
+ const firstBannerPosition = source.indexOf("
+ {/* Agents banner shows everywhere; Lighthouse is Cloud-only, so on a
+ local server the agents banner is the only child and fills the row. */}
+
+ {lighthouseBannerHref ? (
+
+
+
+ ) : null}
+
+
+
+
+
- {lighthouseBannerHref ? (
-
-
-
- ) : null}
-
}>
diff --git a/ui/app/(prowler)/scans/config/_components/scan-configuration-editor.tsx b/ui/app/(prowler)/scans/config/_components/scan-configuration-editor.tsx
index e9086cd366..83cfe429bb 100644
--- a/ui/app/(prowler)/scans/config/_components/scan-configuration-editor.tsx
+++ b/ui/app/(prowler)/scans/config/_components/scan-configuration-editor.tsx
@@ -17,8 +17,8 @@ import {
FieldLabel,
Input,
Textarea,
+ useToast,
} from "@/components/shadcn";
-import { useToast } from "@/components/shadcn";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
import { Modal } from "@/components/shadcn/modal";
import { DOCS_URLS } from "@/lib/external-urls";
diff --git a/ui/app/(prowler)/scans/config/_components/scan-configurations-manager.tsx b/ui/app/(prowler)/scans/config/_components/scan-configurations-manager.tsx
index 2599b7291b..618ee1b8d0 100644
--- a/ui/app/(prowler)/scans/config/_components/scan-configurations-manager.tsx
+++ b/ui/app/(prowler)/scans/config/_components/scan-configurations-manager.tsx
@@ -10,8 +10,7 @@ import {
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
import { BatchFiltersLayout } from "@/components/filters/batch-filters-layout";
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
-import { Button, Card } from "@/components/shadcn";
-import { useToast } from "@/components/shadcn";
+import { Button, Card, useToast } from "@/components/shadcn";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
import { Modal } from "@/components/shadcn/modal";
import { DataTable } from "@/components/shadcn/table";
diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx
index f307920e52..217b94b4c6 100644
--- a/ui/app/(prowler)/scans/page.tsx
+++ b/ui/app/(prowler)/scans/page.tsx
@@ -11,6 +11,8 @@ import {
import { getSchedules, getSchedulesPage } from "@/actions/schedules";
import { auth } from "@/auth.config";
import { PageReady } from "@/components/onboarding";
+import { ScansPageShell } from "@/components/scans/scans-page-shell";
+import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-empty-state";
import {
appendPendingScheduleRowsToPage,
buildScheduledTabRows,
@@ -20,8 +22,6 @@ import {
getScanJobsUserFilters,
pickScheduleProviderFilters,
} from "@/components/scans/scans.utils";
-import { ScansPageShell } from "@/components/scans/scans-page-shell";
-import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-empty-state";
import { SkeletonTableScans } from "@/components/scans/table";
import { ScanJobsTable } from "@/components/scans/table/scan-jobs-table";
import { ContentLayout } from "@/components/shadcn/content-layout";
diff --git a/ui/auth.config.test.ts b/ui/auth.config.test.ts
new file mode 100644
index 0000000000..7125ce9845
--- /dev/null
+++ b/ui/auth.config.test.ts
@@ -0,0 +1,197 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { authConfig } from "./auth.config";
+import type { RolePermissionAttributes } from "./types/users";
+
+const { getUserByMeMock } = vi.hoisted(() => ({
+ getUserByMeMock: vi.fn(),
+}));
+
+vi.mock("next-auth", () => ({
+ default: vi.fn(() => ({
+ signIn: vi.fn(),
+ signOut: vi.fn(),
+ auth: vi.fn(),
+ handlers: {},
+ })),
+}));
+
+vi.mock("next-auth/providers/credentials", () => ({
+ default: vi.fn((config) => config),
+}));
+
+vi.mock("./actions/auth", () => ({
+ getToken: vi.fn(),
+ getUserByMe: getUserByMeMock,
+}));
+
+vi.mock("./lib", () => ({
+ apiBaseUrl: "https://api.example.com/api/v1",
+}));
+
+const RESTRICTED_PERMISSIONS: RolePermissionAttributes = {
+ manage_users: false,
+ manage_account: false,
+ manage_providers: false,
+ manage_scans: false,
+ manage_integrations: false,
+ manage_alerts: false,
+ unlimited_visibility: false,
+};
+
+const ELEVATED_PERMISSIONS: RolePermissionAttributes = {
+ ...RESTRICTED_PERMISSIONS,
+ manage_users: true,
+ manage_scans: true,
+};
+
+describe("authConfig JWT callback", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should load elevated tenant permissions after switching from a restricted tenant", async () => {
+ // Given
+ const accessToken =
+ "header.eyJzdWIiOiJ1c2VyLTEiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtMiJ9.signature";
+ getUserByMeMock.mockResolvedValue({
+ name: "Tenant User",
+ email: "tenant@example.com",
+ company: "Tenant Company",
+ dateJoined: "2026-01-01",
+ permissions: ELEVATED_PERMISSIONS,
+ });
+ const jwtCallback = authConfig.callbacks?.jwt;
+ if (!jwtCallback) throw new Error("JWT callback is not configured");
+
+ // When
+ const result = await jwtCallback({
+ token: {
+ accessToken: "restricted-access-token",
+ refreshToken: "restricted-refresh-token",
+ tenant_id: "tenant-1",
+ user: {
+ name: "Tenant User",
+ email: "tenant@example.com",
+ dateJoined: "2026-01-01",
+ permissions: RESTRICTED_PERMISSIONS,
+ },
+ },
+ user: {} as Parameters
[0]["user"],
+ trigger: "update",
+ session: {
+ accessToken,
+ refreshToken: "elevated-refresh-token",
+ },
+ });
+
+ // Then
+ expect(getUserByMeMock).toHaveBeenCalledWith(accessToken);
+ expect(result.user).toEqual({
+ name: "Tenant User",
+ email: "tenant@example.com",
+ companyName: "Tenant Company",
+ dateJoined: "2026-01-01",
+ permissions: ELEVATED_PERMISSIONS,
+ });
+ });
+
+ it("should load restricted tenant permissions after switching from an elevated tenant", async () => {
+ // Given
+ const accessToken =
+ "header.eyJzdWIiOiJ1c2VyLTEiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtMSJ9.signature";
+ getUserByMeMock.mockResolvedValue({
+ name: "Tenant User",
+ email: "tenant@example.com",
+ company: "Tenant Company",
+ dateJoined: "2026-01-01",
+ permissions: RESTRICTED_PERMISSIONS,
+ });
+ const jwtCallback = authConfig.callbacks?.jwt;
+ if (!jwtCallback) throw new Error("JWT callback is not configured");
+
+ // When
+ const result = await jwtCallback({
+ token: {
+ accessToken: "elevated-access-token",
+ refreshToken: "elevated-refresh-token",
+ tenant_id: "tenant-2",
+ user: {
+ name: "Tenant User",
+ email: "tenant@example.com",
+ dateJoined: "2026-01-01",
+ permissions: ELEVATED_PERMISSIONS,
+ },
+ },
+ user: {} as Parameters[0]["user"],
+ trigger: "update",
+ session: {
+ accessToken,
+ refreshToken: "restricted-refresh-token",
+ },
+ });
+
+ // Then
+ expect(getUserByMeMock).toHaveBeenCalledWith(accessToken);
+ expect(result.accessToken).toBe(accessToken);
+ expect(result.refreshToken).toBe("restricted-refresh-token");
+ expect(result.tenant_id).toBe("tenant-1");
+ expect(result.user).toMatchObject({
+ permissions: RESTRICTED_PERMISSIONS,
+ });
+ });
+
+ it("should report a tenant switch failure while preserving the current session", async () => {
+ // Given
+ vi.spyOn(console, "warn").mockImplementation(() => undefined);
+ getUserByMeMock.mockRejectedValue(new Error("Temporary API failure"));
+ const jwtCallback = authConfig.callbacks?.jwt;
+ if (!jwtCallback) throw new Error("JWT callback is not configured");
+ const sessionCallback = authConfig.callbacks?.session;
+ if (!sessionCallback) throw new Error("Session callback is not configured");
+ const currentToken = {
+ accessToken: "current-access-token",
+ refreshToken: "current-refresh-token",
+ tenant_id: "tenant-1",
+ user: {
+ name: "Tenant User",
+ email: "tenant@example.com",
+ dateJoined: "2026-01-01",
+ permissions: RESTRICTED_PERMISSIONS,
+ },
+ };
+
+ // When
+ const result = await jwtCallback({
+ token: currentToken,
+ user: {} as Parameters[0]["user"],
+ trigger: "update",
+ session: {
+ accessToken:
+ "header.eyJzdWIiOiJ1c2VyLTEiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtMiJ9.signature",
+ refreshToken: "switched-refresh-token",
+ },
+ });
+ if (!result) throw new Error("JWT callback cleared the current token");
+
+ const session = await sessionCallback({
+ session: {
+ expires: "2026-12-31T23:59:59.999Z",
+ user: { name: "Tenant User" },
+ },
+ token: result,
+ } as Parameters[0]);
+
+ // Then
+ expect(session).toMatchObject({
+ error: "TenantSwitchError",
+ accessToken: "current-access-token",
+ refreshToken: "current-refresh-token",
+ tenantId: "tenant-1",
+ user: {
+ permissions: RESTRICTED_PERMISSIONS,
+ },
+ });
+ expect(result.error).toBeUndefined();
+ });
+});
diff --git a/ui/auth.config.ts b/ui/auth.config.ts
index 27f7eb2767..eefc73b2a4 100644
--- a/ui/auth.config.ts
+++ b/ui/auth.config.ts
@@ -4,7 +4,6 @@ import NextAuth, {
type DefaultSession,
type NextAuthConfig,
type Session,
- User,
} from "next-auth";
import type { JWT } from "next-auth/jwt";
import Credentials from "next-auth/providers/credentials";
@@ -58,8 +57,29 @@ const DEFAULT_PERMISSIONS: RolePermissionAttributes = {
unlimited_visibility: false,
};
+const TENANT_SWITCH_ERROR = "TenantSwitchError";
+
type TokenUserInput = Partial & { company?: string };
+type JwtCallback = NonNullable["jwt"]>;
+type JwtCallbackParams = Parameters[0];
+
+interface JwtCallbackCredentials {
+ accessToken?: string;
+ refreshToken?: string;
+}
+
+type AuthJwtUser = JwtCallbackParams["user"] &
+ TokenUserInput &
+ JwtCallbackCredentials;
+
+interface AuthJwtCallbackParams
+ extends Omit {
+ session?: Partial;
+ token: AuthToken;
+ user: AuthJwtUser;
+}
+
const toTokenUser = (user?: TokenUserInput): TokenUser =>
({
name: user?.name ?? undefined,
@@ -308,37 +328,54 @@ export const authConfig = {
return true;
},
- jwt: async ({ token, account, user, trigger, session }) => {
- const authToken = token as AuthToken;
-
+ jwt: async ({
+ token: authToken,
+ account,
+ user,
+ trigger,
+ session,
+ }: AuthJwtCallbackParams): Promise => {
// Handle tenant switch: update tokens from client-side useSession().update()
if (trigger === "update" && session?.accessToken) {
- authToken.accessToken = session.accessToken;
- authToken.refreshToken = session.refreshToken;
- applyDecodedClaims(authToken, authToken.accessToken, "tenant switch");
- return authToken;
+ const newAccessToken = session.accessToken;
+
+ try {
+ const userMeResponse = await getUserByMe(newAccessToken);
+ const nextAuthToken: AuthToken = {
+ ...authToken,
+ accessToken: newAccessToken,
+ refreshToken: session.refreshToken,
+ user: tokenUserFromApi(userMeResponse),
+ error: undefined,
+ };
+
+ applyDecodedClaims(nextAuthToken, newAccessToken, "tenant switch");
+
+ return nextAuthToken;
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.warn("Error refreshing user after tenant switch:", error);
+ return {
+ ...authToken,
+ error: TENANT_SWITCH_ERROR,
+ };
+ }
}
applyDecodedClaims(authToken, authToken.accessToken);
- if (account && user) {
- const signedInUser = user as User &
- TokenUserInput & {
- accessToken: string;
- refreshToken: string;
- };
-
+ if (account && user?.accessToken && user.refreshToken) {
const nextAuthToken: AuthToken = {
...authToken,
- accessToken: signedInUser.accessToken,
- refreshToken: signedInUser.refreshToken,
- user: toTokenUser(signedInUser),
+ accessToken: user.accessToken,
+ refreshToken: user.refreshToken,
+ user: toTokenUser(user),
error: undefined,
};
applyDecodedClaims(
nextAuthToken,
- signedInUser.accessToken,
+ user.accessToken,
"access token on sign-in",
);
@@ -359,7 +396,7 @@ export const authConfig = {
const authToken = token as AuthToken;
const nextSession = { ...session } as ExtendedSession;
- if (authToken?.error) {
+ if (authToken.error && authToken.error !== TENANT_SWITCH_ERROR) {
nextSession.error = authToken.error;
nextSession.user = undefined;
nextSession.userId = undefined;
@@ -369,7 +406,8 @@ export const authConfig = {
return nextSession;
}
- nextSession.error = undefined;
+ nextSession.error = authToken.error;
+ authToken.error = undefined;
nextSession.userId = authToken.user_id ?? nextSession.userId;
nextSession.tenantId = authToken.tenant_id ?? nextSession.tenantId;
nextSession.accessToken =
diff --git a/ui/changelog.d/attack-paths-provider-finding-resources.fixed.md b/ui/changelog.d/attack-paths-provider-finding-resources.fixed.md
new file mode 100644
index 0000000000..b40c123789
--- /dev/null
+++ b/ui/changelog.d/attack-paths-provider-finding-resources.fixed.md
@@ -0,0 +1 @@
+Attack Paths now classify cloud-provider finding resources separately from Prowler findings
diff --git a/ui/changelog.d/drop-unused-deployment-mode-env.removed.md b/ui/changelog.d/drop-unused-deployment-mode-env.removed.md
new file mode 100644
index 0000000000..9261cd9523
--- /dev/null
+++ b/ui/changelog.d/drop-unused-deployment-mode-env.removed.md
@@ -0,0 +1 @@
+Removed the unused `NEXT_PUBLIC_PROWLER_DEPLOYMENT_MODE` variable and its `getDeploymentMode` helper; no build ever set it and nothing read the result, so Cloud versus self-hosted behavior is decided solely by the runtime `UI_CLOUD_ENABLED` flag
diff --git a/ui/changelog.d/enterprise-billing-navigation.fixed.md b/ui/changelog.d/enterprise-billing-navigation.fixed.md
deleted file mode 100644
index 0ea2b4d31e..0000000000
--- a/ui/changelog.d/enterprise-billing-navigation.fixed.md
+++ /dev/null
@@ -1 +0,0 @@
-Billing navigation is hidden when Cloud billing is disabled, including Enterprise deployments
diff --git a/ui/changelog.d/oci-regionless-provider-e2e.fixed.md b/ui/changelog.d/oci-regionless-provider-e2e.fixed.md
deleted file mode 100644
index 2e413a6fa8..0000000000
--- a/ui/changelog.d/oci-regionless-provider-e2e.fixed.md
+++ /dev/null
@@ -1 +0,0 @@
-OCI provider E2E tests no longer require or submit a region when adding or updating credentials
diff --git a/ui/changelog.d/ui-sentry-actionability.fixed.md b/ui/changelog.d/ui-sentry-actionability.fixed.md
deleted file mode 100644
index 7a4b8ce2ca..0000000000
--- a/ui/changelog.d/ui-sentry-actionability.fixed.md
+++ /dev/null
@@ -1 +0,0 @@
-UI Sentry alerts now suppress non-actionable warnings and expected API/control-flow noise while preserving actionable runtime failures
diff --git a/ui/components/auth/oss/sign-in-form.tsx b/ui/components/auth/oss/sign-in-form.tsx
index 8ebd8f3c0e..7c971913be 100644
--- a/ui/components/auth/oss/sign-in-form.tsx
+++ b/ui/components/auth/oss/sign-in-form.tsx
@@ -17,8 +17,8 @@ import {
Tooltip,
TooltipContent,
TooltipTrigger,
+ useToast,
} from "@/components/shadcn";
-import { useToast } from "@/components/shadcn";
import { CustomInput } from "@/components/shadcn/custom";
import { Form } from "@/components/shadcn/form";
import { getSafeCallbackPath } from "@/lib/auth-callback-url";
diff --git a/ui/components/auth/oss/sign-up-form.tsx b/ui/components/auth/oss/sign-up-form.tsx
index 2127151b73..68fe4e2c6a 100644
--- a/ui/components/auth/oss/sign-up-form.tsx
+++ b/ui/components/auth/oss/sign-up-form.tsx
@@ -15,8 +15,7 @@ import { AuthFooterLink } from "@/components/auth/oss/auth-footer-link";
import { AuthLayout } from "@/components/auth/oss/auth-layout";
import { PasswordRequirementsMessage } from "@/components/auth/oss/password-validator";
import { SocialButtons } from "@/components/auth/oss/social-buttons";
-import { Button, Checkbox } from "@/components/shadcn";
-import { useToast } from "@/components/shadcn";
+import { Button, Checkbox, useToast } from "@/components/shadcn";
import { CustomInput } from "@/components/shadcn/custom";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
import {
diff --git a/ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx b/ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx
index e3ab821a55..256431cdfb 100644
--- a/ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx
+++ b/ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx
@@ -2,8 +2,7 @@
import { useRef, useState } from "react";
-import { Button } from "@/components/shadcn";
-import { Accordion, AccordionItemProps } from "@/components/shadcn";
+import { Button, Accordion, AccordionItemProps } from "@/components/shadcn";
import { Card } from "@/components/shadcn/card/card";
export const ClientAccordionWrapper = ({
diff --git a/ui/components/compliance/compliance-card.tsx b/ui/components/compliance/compliance-card.tsx
index 6168784f2f..37c7892fda 100644
--- a/ui/components/compliance/compliance-card.tsx
+++ b/ui/components/compliance/compliance-card.tsx
@@ -19,6 +19,7 @@ import {
import { ScanEntity } from "@/types/scans";
import { getComplianceIcon } from "../icons";
+
import { ComplianceDownloadContainer } from "./compliance-download-container";
interface ComplianceCardProps {
diff --git a/ui/components/findings/findings-filters.tsx b/ui/components/findings/findings-filters.tsx
index 608ad97cd6..fc444bb32e 100644
--- a/ui/components/findings/findings-filters.tsx
+++ b/ui/components/findings/findings-filters.tsx
@@ -26,7 +26,9 @@ import { DATA_TABLE_FILTER_MODE } from "@/types/filters";
import { ProviderProps } from "@/types/providers";
import {
+ buildFindingGroupFilterOption,
buildFindingsFilterChips,
+ type FindingCheckFilterOption,
getFindingsFilterDisplayValue,
} from "./findings-filters.utils";
@@ -42,6 +44,7 @@ interface FindingsFiltersProps {
uniqueResourceTypes: string[];
uniqueCategories: string[];
uniqueGroups: string[];
+ checkOptions?: FindingCheckFilterOption[];
trailingControls?: ReactNode;
variant?: "default" | "alerts-edit";
}
@@ -71,6 +74,7 @@ const countVisibleFilterKeys = (filters: Record): number =>
const FILTER_CONTROL_COLUMN_CLASS =
"min-w-0 flex-none basis-full sm:basis-[calc((100%_-_0.75rem)/2)] lg:basis-[calc((100%_-_1.5rem)/3)] xl:basis-[calc((100%_-_2.25rem)/4)] 2xl:basis-[calc((100%_-_3rem)/5)]";
const FILTER_GRID_ITEM_CLASS = "min-w-0";
+const FINDING_GROUP_FILTER_KEYS = ["filter[check_id]", "filter[check_id__in]"];
export const FindingsFilterBatchControls = ({
providers,
@@ -85,6 +89,7 @@ export const FindingsFilterBatchControls = ({
uniqueResourceTypes,
uniqueCategories,
uniqueGroups,
+ checkOptions = [],
trailingControls,
appliedFilters,
pendingFilters,
@@ -102,6 +107,18 @@ export const FindingsFilterBatchControls = ({
}: FindingsFilterBatchControlsProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const isAlertsEdit = variant === "alerts-edit";
+ const checkTitles = Object.fromEntries(
+ checkOptions.map(({ checkId, checkTitle }) => [
+ checkId,
+ checkTitle || checkId,
+ ]),
+ );
+ const findingGroupFilterOption = buildFindingGroupFilterOption({
+ checkOptions,
+ selectedCheckIds: getFilterValue("filter[check_id]"),
+ selectedCheckIdsIn: getFilterValue("filter[check_id__in]"),
+ checkTitles,
+ });
const customFilters = [
...filterFindings
@@ -112,39 +129,41 @@ export const FindingsFilterBatchControls = ({
getFindingsFilterDisplayValue(`filter[${filter.key}]`, value, {
providers,
scans: scanDetails,
+ checkTitles,
}),
})),
+ ...(findingGroupFilterOption ? [findingGroupFilterOption] : []),
{
key: FILTER_FIELD.REGION,
labelCheckboxGroup: "Regions",
values: uniqueRegions,
- index: 3,
+ index: 4,
},
{
key: FILTER_FIELD.SERVICE,
labelCheckboxGroup: "Services",
values: uniqueServices,
- index: 4,
+ index: 5,
},
{
key: FILTER_FIELD.RESOURCE_TYPE,
labelCheckboxGroup: "Resource Type",
values: uniqueResourceTypes,
- index: 8,
+ index: 9,
},
{
key: FILTER_FIELD.CATEGORY,
labelCheckboxGroup: "Category",
values: uniqueCategories,
labelFormatter: getCategoryLabel,
- index: 5,
+ index: 6,
},
{
key: FILTER_FIELD.RESOURCE_GROUPS,
labelCheckboxGroup: "Resource Group",
values: uniqueGroups,
labelFormatter: getGroupLabel,
- index: 6,
+ index: 7,
},
...(isAlertsEdit
? []
@@ -164,7 +183,7 @@ export const FindingsFilterBatchControls = ({
scans: scanDetails,
},
),
- index: 7,
+ index: 8,
},
]),
];
@@ -177,6 +196,7 @@ export const FindingsFilterBatchControls = ({
providers,
providerGroups,
scans: scanDetails,
+ checkTitles,
},
);
const pendingFilterChips: FilterChip[] = buildFindingsFilterChips(
@@ -185,6 +205,7 @@ export const FindingsFilterBatchControls = ({
providers,
providerGroups,
scans: scanDetails,
+ checkTitles,
},
);
const appliedCount = countVisibleFilterKeys(appliedFilters);
@@ -347,6 +368,7 @@ export const FindingsFilters = (props: FindingsFiltersProps) => {
getFilterValue,
} = useFilterBatch({
defaultParams: { "filter[muted]": "false" },
+ exclusiveFilterGroups: [FINDING_GROUP_FILTER_KEYS],
});
return (
diff --git a/ui/components/findings/findings-filters.utils.test.ts b/ui/components/findings/findings-filters.utils.test.ts
index 69ce0605d2..5b0fe99cb2 100644
--- a/ui/components/findings/findings-filters.utils.test.ts
+++ b/ui/components/findings/findings-filters.utils.test.ts
@@ -5,6 +5,7 @@ import { ProviderProps } from "@/types/providers";
import { ScanEntity } from "@/types/scans";
import {
+ buildFindingGroupFilterOption,
buildFindingsFilterChips,
getFindingsFilterDisplayValue,
} from "./findings-filters.utils";
@@ -164,6 +165,30 @@ describe("getFindingsFilterDisplayValue", () => {
);
});
+ it("uses the finding group title for check_id filters when available", () => {
+ expect(
+ getFindingsFilterDisplayValue(
+ "filter[check_id]",
+ "teams_external_users_can_join",
+ {
+ checkTitles: {
+ teams_external_users_can_join:
+ "External Teams users can join meetings",
+ },
+ },
+ ),
+ ).toBe("External Teams users can join meetings");
+ });
+
+ it("keeps the check id when no finding group title is available", () => {
+ expect(
+ getFindingsFilterDisplayValue(
+ "filter[check_id]",
+ "teams_external_users_can_join",
+ ),
+ ).toBe("teams_external_users_can_join");
+ });
+
it("uses the provider display name regardless of account alias/uid", () => {
expect(
getFindingsFilterDisplayValue("filter[scan__in]", "scan-2", {
@@ -298,6 +323,45 @@ describe("buildFindingsFilterChips", () => {
expect(chipsPlural[0].displayValues).toEqual(["New", "Changed"]);
});
+ it("renders filter[check_id] as a first-class Finding Group chip", () => {
+ // Given - exact deep-link params from the grouped findings page.
+ const chips = buildFindingsFilterChips(
+ {
+ "filter[check_id]": ["teams_external_users_can_join"],
+ },
+ {
+ checkTitles: {
+ teams_external_users_can_join:
+ "External Teams users can join meetings",
+ },
+ },
+ );
+
+ expect(chips).toEqual([
+ {
+ key: "filter[check_id]",
+ label: "Finding Group",
+ value: "teams_external_users_can_join",
+ displayValue: "External Teams users can join meetings",
+ },
+ ]);
+ });
+
+ it("renders filter[check_id__in] with the Finding Group chip label", () => {
+ const chips = buildFindingsFilterChips({
+ "filter[check_id__in]": ["teams_external_users_can_join"],
+ });
+
+ expect(chips).toEqual([
+ {
+ key: "filter[check_id__in]",
+ label: "Finding Group",
+ value: "teams_external_users_can_join",
+ displayValue: "teams_external_users_can_join",
+ },
+ ]);
+ });
+
it("skips muted filters because the table toolbar owns that control", () => {
const chips = buildFindingsFilterChips({
"filter[muted]": ["include"],
@@ -324,3 +388,47 @@ describe("buildFindingsFilterChips", () => {
]);
});
});
+
+describe("buildFindingGroupFilterOption", () => {
+ it("builds a selectable Finding Group filter from fetched options and URL-backed values", () => {
+ // Given
+ const filter = buildFindingGroupFilterOption({
+ checkOptions: [
+ {
+ checkId: "teams_external_users_can_join",
+ checkTitle: "External Teams users can join meetings",
+ },
+ ],
+ selectedCheckIds: ["s3_bucket_public_access"],
+ selectedCheckIdsIn: ["teams_external_users_can_join"],
+ checkTitles: {
+ teams_external_users_can_join: "External Teams users can join meetings",
+ },
+ });
+
+ // Then
+ expect(filter).toMatchObject({
+ key: "check_id__in",
+ labelCheckboxGroup: "Finding Group",
+ values: ["teams_external_users_can_join", "s3_bucket_public_access"],
+ index: 3,
+ });
+ expect(filter?.labelFormatter?.("teams_external_users_can_join")).toBe(
+ "External Teams users can join meetings",
+ );
+ expect(filter?.labelFormatter?.("s3_bucket_public_access")).toBe(
+ "s3_bucket_public_access",
+ );
+ });
+
+ it("omits the Finding Group filter when there are no selectable or URL-backed values", () => {
+ expect(
+ buildFindingGroupFilterOption({
+ checkOptions: [],
+ selectedCheckIds: [],
+ selectedCheckIdsIn: [],
+ checkTitles: {},
+ }),
+ ).toBeNull();
+ });
+});
diff --git a/ui/components/findings/findings-filters.utils.ts b/ui/components/findings/findings-filters.utils.ts
index 4cb9eef394..09188e65e6 100644
--- a/ui/components/findings/findings-filters.utils.ts
+++ b/ui/components/findings/findings-filters.utils.ts
@@ -7,14 +7,21 @@ import {
} from "@/lib/helper-filters";
import { FINDING_STATUS_DISPLAY_NAMES } from "@/types";
import { ProviderGroup } from "@/types/components";
+import type { FilterOption } from "@/types/filters";
import { getProviderDisplayName, ProviderProps } from "@/types/providers";
import { ScanEntity } from "@/types/scans";
import { SEVERITY_DISPLAY_NAMES } from "@/types/severities";
+export interface FindingCheckFilterOption {
+ checkId: string;
+ checkTitle?: string;
+}
+
interface GetFindingsFilterDisplayValueOptions {
providers?: ProviderProps[];
scans?: Array<{ [scanId: string]: ScanEntity }>;
providerGroups?: ProviderGroup[];
+ checkTitles?: Record;
}
const FINDING_DELTA_DISPLAY_NAMES: Record = {
@@ -64,6 +71,12 @@ export function getFindingsFilterDisplayValue(
if (filterKey === "filter[scan__in]" || filterKey === "filter[scan]") {
return getScanDisplayValue(value, options.scans || []);
}
+ if (
+ filterKey === "filter[check_id]" ||
+ filterKey === "filter[check_id__in]"
+ ) {
+ return options.checkTitles?.[value] || value;
+ }
if (filterKey === "filter[severity__in]") {
return (
SEVERITY_DISPLAY_NAMES[
@@ -100,6 +113,43 @@ export function getFindingsFilterDisplayValue(
return formatLabel(value);
}
+function uniqueNonEmptyValues(values: string[]): string[] {
+ return Array.from(new Set(values.filter(Boolean)));
+}
+
+export function buildFindingGroupFilterOption({
+ checkOptions,
+ selectedCheckIds,
+ selectedCheckIdsIn,
+ checkTitles,
+}: {
+ checkOptions: FindingCheckFilterOption[];
+ selectedCheckIds: string[];
+ selectedCheckIdsIn: string[];
+ checkTitles: Record;
+}): FilterOption | null {
+ const values = uniqueNonEmptyValues([
+ ...checkOptions.map((option) => option.checkId),
+ ...selectedCheckIds,
+ ...selectedCheckIdsIn,
+ ]);
+
+ if (values.length === 0) {
+ return null;
+ }
+
+ return {
+ key: "check_id__in",
+ labelCheckboxGroup: "Finding Group",
+ values,
+ labelFormatter: (value: string) =>
+ getFindingsFilterDisplayValue("filter[check_id]", value, {
+ checkTitles,
+ }),
+ index: 3,
+ };
+}
+
/**
* Maps raw filter param keys (e.g. "filter[severity__in]") to human-readable labels.
* Used to render chips in the FilterSummaryStrip.
@@ -108,6 +158,8 @@ export function getFindingsFilterDisplayValue(
* label is missing.
*/
export const FILTER_KEY_LABELS: Record = {
+ "filter[check_id]": "Finding Group",
+ "filter[check_id__in]": "Finding Group",
"filter[provider_type__in]": "Provider",
"filter[provider_id__in]": "Account",
"filter[provider_groups__in]": "Provider Group",
@@ -134,6 +186,7 @@ interface BuildFindingsFilterChipsOptions {
providers?: ProviderProps[];
scans?: Array<{ [scanId: string]: ScanEntity }>;
providerGroups?: ProviderGroup[];
+ checkTitles?: Record;
includeMuted?: boolean;
}
diff --git a/ui/components/findings/floating-mute-button.test.tsx b/ui/components/findings/floating-selection-actions.test.tsx
similarity index 94%
rename from ui/components/findings/floating-mute-button.test.tsx
rename to ui/components/findings/floating-selection-actions.test.tsx
index b953b47b43..31439af85d 100644
--- a/ui/components/findings/floating-mute-button.test.tsx
+++ b/ui/components/findings/floating-selection-actions.test.tsx
@@ -22,7 +22,7 @@ vi.mock("next/navigation", () => ({
// Import after mocks
// ---------------------------------------------------------------------------
-import { FloatingMuteButton } from "./floating-mute-button";
+import { FloatingSelectionActions } from "./floating-selection-actions";
function deferredPromise() {
let resolve!: (value: T) => void;
@@ -39,7 +39,7 @@ function deferredPromise() {
// Fix 3: onBeforeOpen rejection resets isResolving
// ---------------------------------------------------------------------------
-describe("FloatingMuteButton — onBeforeOpen error handling", () => {
+describe("FloatingSelectionActions — onBeforeOpen error handling", () => {
beforeEach(() => {
vi.clearAllMocks();
});
@@ -50,7 +50,7 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
const user = userEvent.setup();
render(
- {
const user = userEvent.setup();
render(
- {
const user = userEvent.setup();
render(
- {
const user = userEvent.setup();
render(
- void;
- /** Async resolver that returns actual finding UUIDs before opening modal */
+ /** Async resolver that returns actual finding UUIDs before opening modal. */
onBeforeOpen?: () => Promise;
- /** When true, the toast warns that processing may take a few minutes */
+ /** When true, the toast warns that processing may take a few minutes. */
isBulkOperation?: boolean;
- /** Custom button label. Defaults to "Mute ({selectedCount})" */
+ /** Custom button label. Defaults to "{selectedCount} selected". */
label?: string;
+ /** Custom mute action label. Defaults to "Mute". */
+ muteLabel?: string;
}
-export function FloatingMuteButton({
+type FloatingSelectionActionsProps = FloatingSelectionActionsBaseProps &
+ (
+ | {
+ jiraPayload: JiraDispatchModalPayload;
+ jiraLabel: string;
+ }
+ | {
+ jiraPayload?: never;
+ jiraLabel?: never;
+ }
+ );
+
+export function FloatingSelectionActions({
selectedCount,
selectedFindingIds,
onComplete,
onBeforeOpen,
isBulkOperation = false,
label,
-}: FloatingMuteButtonProps) {
+ muteLabel = "Mute",
+ jiraPayload,
+ jiraLabel,
+}: FloatingSelectionActionsProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [resolvedIds, setResolvedIds] = useState([]);
const [isResolving, setIsResolving] = useState(false);
@@ -51,7 +74,7 @@ export function FloatingMuteButton({
}
};
- const handleClick = async () => {
+ const handleMuteClick = async () => {
if (onBeforeOpen) {
setResolvedIds([]);
setMutePreparationError(null);
@@ -103,20 +126,48 @@ export function FloatingMuteButton({
with the content. */}
{typeof document !== "undefined"
? createPortal(
-
-
- {isResolving ? (
-
+
+
+ {jiraPayload ? (
+
+ {isResolving ? (
+
+ ) : (
+
+ )}
+ {label ?? `${selectedCount} selected`}
+
+ }
+ >
+ }
+ label={muteLabel}
+ aria-label={muteLabel}
+ onSelect={() => void handleMuteClick()}
+ />
+
+
) : (
-
+
void handleMuteClick()}
+ disabled={isResolving}
+ size="lg"
+ >
+ {isResolving ? (
+
+ ) : (
+
+ )}
+ Mute ({selectedCount})
+
)}
- {label ?? `Mute (${selectedCount})`}
-
+
,
document.body,
)
diff --git a/ui/components/findings/jira-dispatch-action-item.test.tsx b/ui/components/findings/jira-dispatch-action-item.test.tsx
new file mode 100644
index 0000000000..b70d4af73a
--- /dev/null
+++ b/ui/components/findings/jira-dispatch-action-item.test.tsx
@@ -0,0 +1,105 @@
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import {
+ ActionDropdown,
+ ActionDropdownItem,
+} from "@/components/shadcn/dropdown";
+import { createJiraTargetSelection } from "@/lib/jira-dispatch-selection";
+import { useCloudUpgradeStore, useJiraDispatchStore } from "@/store";
+import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
+import {
+ JIRA_DISPATCH_TARGET,
+ type JiraDispatchTarget,
+} from "@/types/integrations";
+
+const { isGroupedJiraDispatchEnabledMock } = vi.hoisted(() => ({
+ isGroupedJiraDispatchEnabledMock: vi.fn(() => false),
+}));
+
+vi.mock("@/lib/deployment", async (importOriginal) => ({
+ ...(await importOriginal()),
+ isGroupedJiraDispatchEnabled: isGroupedJiraDispatchEnabledMock,
+}));
+
+import { JiraDispatchActionItem } from "./jira-dispatch-action-item";
+
+const renderAction = (targetIds: string[], targetType: JiraDispatchTarget) => {
+ const selection = createJiraTargetSelection(targetIds, targetType)!;
+
+ render(
+ Actions }>
+
+
+ ,
+ );
+};
+
+describe("JiraDispatchActionItem", () => {
+ beforeEach(() => {
+ isGroupedJiraDispatchEnabledMock.mockReturnValue(false);
+ useCloudUpgradeStore.getState().closeCloudUpgrade();
+ useJiraDispatchStore.getState().closeJiraDispatch();
+ });
+
+ it("opens Jira modal payload for one Finding", async () => {
+ // Given
+ const user = userEvent.setup();
+ renderAction(["finding-1"], JIRA_DISPATCH_TARGET.FINDING_ID);
+
+ // When
+ await user.click(screen.getByRole("button", { name: "Actions" }));
+ await user.click(screen.getByRole("menuitem", { name: "Send to Jira" }));
+
+ // Then
+ expect(useJiraDispatchStore.getState().activePayload).toMatchObject({
+ selection: { targetId: "finding-1" },
+ });
+ expect(useCloudUpgradeStore.getState().activeFeature).toBeNull();
+ });
+
+ it("shows Cloud tooltip and opens upgrade for grouped dispatch", async () => {
+ // Given
+ const user = userEvent.setup();
+ renderAction(["check-1"], JIRA_DISPATCH_TARGET.CHECK_ID);
+
+ // When
+ await user.click(screen.getByRole("button", { name: "Actions" }));
+ const jiraAction = screen.getByRole("menuitem", { name: "Send to Jira" });
+ expect(
+ within(jiraAction).queryByText("Available only in Prowler Cloud"),
+ ).not.toBeInTheDocument();
+ await user.hover(jiraAction);
+
+ // Then
+ expect(await screen.findByRole("tooltip")).toHaveTextContent(
+ "Available only in Prowler Cloud",
+ );
+
+ // When
+ await user.click(jiraAction);
+
+ // Then
+ expect(useCloudUpgradeStore.getState().activeFeature).toBe(
+ CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH,
+ );
+ expect(useJiraDispatchStore.getState().activePayload).toBeNull();
+ });
+
+ it("opens grouped Jira payload when feature is enabled", async () => {
+ // Given
+ isGroupedJiraDispatchEnabledMock.mockReturnValue(true);
+ const user = userEvent.setup();
+ renderAction(["check-1"], JIRA_DISPATCH_TARGET.CHECK_ID);
+
+ // When
+ await user.click(screen.getByRole("button", { name: "Actions" }));
+ await user.click(screen.getByRole("menuitem", { name: "Send to Jira" }));
+
+ // Then
+ expect(useJiraDispatchStore.getState().activePayload).toMatchObject({
+ selection: { targetId: "check-1" },
+ });
+ });
+});
diff --git a/ui/components/findings/jira-dispatch-action-item.tsx b/ui/components/findings/jira-dispatch-action-item.tsx
new file mode 100644
index 0000000000..025482a401
--- /dev/null
+++ b/ui/components/findings/jira-dispatch-action-item.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import { JiraIcon } from "@/components/icons/services/IconServices";
+import { ActionDropdownItem } from "@/components/shadcn/dropdown";
+import {
+ isGroupedJiraDispatchEnabled,
+ PROWLER_CLOUD_ONLY_TOOLTIP,
+} from "@/lib/deployment";
+import { getJiraDispatchActionState } from "@/lib/jira-dispatch-action";
+import { useCloudUpgradeStore, useJiraDispatchStore } from "@/store";
+import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
+import type { JiraDispatchModalPayload } from "@/types/jira-dispatch";
+
+interface JiraDispatchActionItemProps {
+ label: string;
+ payload: JiraDispatchModalPayload | null | undefined;
+}
+
+export const JiraDispatchActionItem = ({
+ label,
+ payload,
+}: JiraDispatchActionItemProps) => {
+ const openCloudUpgrade = useCloudUpgradeStore(
+ (state) => state.openCloudUpgrade,
+ );
+ const openJiraDispatch = useJiraDispatchStore(
+ (state) => state.openJiraDispatch,
+ );
+
+ if (!payload) return null;
+
+ const { requiresUpgrade } = getJiraDispatchActionState(
+ payload,
+ isGroupedJiraDispatchEnabled(),
+ );
+
+ const handleSelect = () => {
+ if (requiresUpgrade) {
+ openCloudUpgrade(CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH);
+ return;
+ }
+
+ openJiraDispatch(payload);
+ };
+
+ return (
+
}
+ label={label}
+ aria-label={label}
+ tooltip={requiresUpgrade ? PROWLER_CLOUD_ONLY_TOOLTIP : undefined}
+ onSelect={handleSelect}
+ />
+ );
+};
diff --git a/ui/components/findings/jira-dispatch-modal-host.test.tsx b/ui/components/findings/jira-dispatch-modal-host.test.tsx
new file mode 100644
index 0000000000..f1460e1ebe
--- /dev/null
+++ b/ui/components/findings/jira-dispatch-modal-host.test.tsx
@@ -0,0 +1,67 @@
+import { render } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { createJiraTargetSelection } from "@/lib/jira-dispatch-selection";
+import { useJiraDispatchStore } from "@/store";
+import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
+
+const { SendToJiraModalMock, isGroupedJiraDispatchEnabledMock } = vi.hoisted(
+ () => ({
+ SendToJiraModalMock: vi.fn(() => null),
+ isGroupedJiraDispatchEnabledMock: vi.fn(() => true),
+ }),
+);
+
+vi.mock("./send-to-jira-modal", () => ({
+ SendToJiraModal: SendToJiraModalMock,
+}));
+
+vi.mock("@/lib/deployment", async (importOriginal) => ({
+ ...(await importOriginal
()),
+ isGroupedJiraDispatchEnabled: isGroupedJiraDispatchEnabledMock,
+}));
+
+import { JiraDispatchModalHost } from "./jira-dispatch-modal-host";
+
+describe("JiraDispatchModalHost", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useJiraDispatchStore.getState().closeJiraDispatch();
+ });
+
+ it("renders one modal with derived grouped configuration", () => {
+ // Given
+ const selection = createJiraTargetSelection(
+ ["check-1"],
+ JIRA_DISPATCH_TARGET.CHECK_ID,
+ )!;
+ useJiraDispatchStore.getState().openJiraDispatch({
+ selection,
+ selectedResourceCount: 3,
+ findingTitle: "Check title",
+ });
+
+ // When
+ render( );
+
+ // Then
+ expect(SendToJiraModalMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ isOpen: true,
+ selection,
+ findingTitle: "Check title",
+ defaultDispatchMode: JIRA_DISPATCH_MODE.GROUPED,
+ canChooseGroupedDispatch: true,
+ }),
+ undefined,
+ );
+ });
+
+ it("does not render without an active payload", () => {
+ // Given / When
+ render( );
+
+ // Then
+ expect(SendToJiraModalMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/ui/components/findings/jira-dispatch-modal-host.tsx b/ui/components/findings/jira-dispatch-modal-host.tsx
new file mode 100644
index 0000000000..15d47c9fef
--- /dev/null
+++ b/ui/components/findings/jira-dispatch-modal-host.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import { isGroupedJiraDispatchEnabled } from "@/lib/deployment";
+import { getJiraDispatchActionState } from "@/lib/jira-dispatch-action";
+import { useJiraDispatchStore } from "@/store";
+
+import { SendToJiraModal } from "./send-to-jira-modal";
+
+export const JiraDispatchModalHost = () => {
+ const activePayload = useJiraDispatchStore((state) => state.activePayload);
+ const closeJiraDispatch = useJiraDispatchStore(
+ (state) => state.closeJiraDispatch,
+ );
+
+ if (!activePayload) return null;
+
+ const { defaultDispatchMode, canChooseGroupedDispatch } =
+ getJiraDispatchActionState(activePayload, isGroupedJiraDispatchEnabled());
+
+ return (
+ !open && closeJiraDispatch()}
+ selection={activePayload.selection}
+ findingTitle={activePayload.findingTitle}
+ defaultDispatchMode={defaultDispatchMode}
+ canChooseGroupedDispatch={canChooseGroupedDispatch}
+ isFindingGroupSelection={activePayload.isFindingGroupSelection}
+ selectedResourceCount={activePayload.selectedResourceCount}
+ description={activePayload.description}
+ />
+ );
+};
diff --git a/ui/components/findings/jira-dispatch-task-handler.test.tsx b/ui/components/findings/jira-dispatch-task-handler.test.tsx
new file mode 100644
index 0000000000..e77bc675df
--- /dev/null
+++ b/ui/components/findings/jira-dispatch-task-handler.test.tsx
@@ -0,0 +1,128 @@
+import { type ComponentProps } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { WatchedTask } from "@/store/task-watcher/store";
+import { JIRA_DISPATCH_MODE } from "@/types/integrations";
+
+import { jiraDispatchTaskHandler } from "./jira-dispatch-task-handler";
+
+interface ToastActionMockProps extends ComponentProps<"button"> {
+ altText: string;
+}
+
+const { sendJiraDispatchMock, toastMock, trackAndPollTaskMock } = vi.hoisted(
+ () => ({
+ sendJiraDispatchMock: vi.fn(),
+ toastMock: vi.fn(),
+ trackAndPollTaskMock: vi.fn(),
+ }),
+);
+
+vi.mock("@/actions/integrations/jira-dispatch", () => ({
+ sendJiraDispatch: sendJiraDispatchMock,
+}));
+
+vi.mock("@/components/shadcn/toast", () => ({
+ toast: toastMock,
+ ToastAction: ({
+ altText: _altText,
+ children,
+ ...props
+ }: ToastActionMockProps) => {children} ,
+}));
+
+vi.mock("@/store/task-watcher/store", () => ({
+ trackAndPollTask: trackAndPollTaskMock,
+}));
+
+const buildTask = (result: unknown): WatchedTask => ({
+ taskId: "task-1",
+ kind: "jira-dispatch",
+ status: "ready",
+ startedAt: Date.now(),
+ meta: {
+ integrationId: "jira-1",
+ projectKey: "SEC",
+ issueType: "Task",
+ dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
+ },
+ result,
+});
+
+describe("jiraDispatchTaskHandler", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ sendJiraDispatchMock.mockResolvedValue({
+ success: true,
+ taskId: "retry-task",
+ message: "Started",
+ });
+ });
+
+ it("shows the completed Jira result after a persisted task resumes", () => {
+ // Given
+ const task = buildTask({ created_count: 2, failed_count: 0 });
+
+ // When
+ jiraDispatchTaskHandler.onReady(task);
+
+ // Then
+ expect(toastMock).toHaveBeenCalledWith({
+ title: "Success!",
+ description: "2 Jira issues were created or updated successfully.",
+ });
+ });
+
+ it("retries only failed Findings from a resumed partial task", async () => {
+ // Given
+ const task = buildTask({
+ created_count: 1,
+ failed_count: 2,
+ failed_finding_ids: ["finding-2", "finding-3"],
+ error: "Two Jira issues failed.",
+ });
+ jiraDispatchTaskHandler.onReady(task);
+ const partialToast = toastMock.mock.calls.at(-1)?.[0];
+
+ // When
+ await partialToast.action.props.onClick();
+
+ // Then
+ expect(sendJiraDispatchMock).toHaveBeenCalledWith({
+ integrationId: "jira-1",
+ targetIds: ["finding-2", "finding-3"],
+ filter: "finding_id",
+ projectKey: "SEC",
+ issueType: "Task",
+ dispatchMode: "individual",
+ });
+ expect(trackAndPollTaskMock).toHaveBeenCalledWith({
+ taskId: "retry-task",
+ kind: "jira-dispatch",
+ meta: {
+ ...task.meta,
+ dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL,
+ },
+ notifyHandler: true,
+ });
+ });
+
+ it("surfaces task watcher errors without offering an unsafe retry", () => {
+ // Given
+ const task = {
+ ...buildTask(undefined),
+ status: "error",
+ error: "Tracking the task failed unexpectedly. Try again later.",
+ } as WatchedTask;
+
+ // When
+ jiraDispatchTaskHandler.onError(task);
+
+ // Then
+ expect(toastMock).toHaveBeenCalledWith({
+ variant: "destructive",
+ title: "Jira dispatch failed",
+ description: "Tracking the task failed unexpectedly. Try again later.",
+ });
+ });
+});
diff --git a/ui/components/findings/jira-dispatch-task-handler.tsx b/ui/components/findings/jira-dispatch-task-handler.tsx
new file mode 100644
index 0000000000..159d268507
--- /dev/null
+++ b/ui/components/findings/jira-dispatch-task-handler.tsx
@@ -0,0 +1,105 @@
+"use client";
+
+import { toast, ToastAction } from "@/components/shadcn/toast";
+import {
+ executeJiraDispatchBatches,
+ getJiraRetryBatch,
+} from "@/lib/jira-dispatch-execution";
+import { evaluateJiraDispatchTask } from "@/lib/jira-dispatch-result";
+import { parseJiraDispatchTaskMeta } from "@/lib/jira-dispatch-task";
+import type { TaskKindHandler, WatchedTask } from "@/store/task-watcher/store";
+import {
+ JIRA_DISPATCH_MODE,
+ type JiraDispatchTaskResult,
+} from "@/types/integrations";
+
+const retryFailedFindings = async (
+ task: WatchedTask,
+ failedFindingIds: string[],
+): Promise => {
+ const meta = parseJiraDispatchTaskMeta(task);
+ if (!meta) {
+ toast({
+ variant: "destructive",
+ title: "Jira retry failed",
+ description: "The original Jira dispatch configuration is unavailable.",
+ });
+ return;
+ }
+
+ const retryBatch = getJiraRetryBatch(failedFindingIds);
+ if (!retryBatch) return;
+
+ try {
+ toast({
+ title: "Retry started",
+ description: `Retrying ${failedFindingIds.length} failed Finding${failedFindingIds.length === 1 ? "" : "s"}.`,
+ });
+
+ const result = await executeJiraDispatchBatches(
+ [retryBatch],
+ {
+ integrationId: meta.integrationId,
+ projectKey: meta.projectKey,
+ issueType: meta.issueType,
+ dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL,
+ },
+ { notifyHandler: true },
+ );
+ if (result.startedTaskCount === 0 && result.errors.length > 0) {
+ toast({
+ variant: "destructive",
+ title: "Jira retry failed",
+ description: result.errors.join(" "),
+ });
+ }
+ } catch {
+ toast({
+ variant: "destructive",
+ title: "Jira retry failed",
+ description: "The retry could not be started. Try again later.",
+ });
+ }
+};
+
+const buildRetryAction = (task: WatchedTask, failedFindingIds?: string[]) =>
+ failedFindingIds?.length ? (
+ retryFailedFindings(task, failedFindingIds)}
+ >
+ Retry failed
+
+ ) : undefined;
+
+export const jiraDispatchTaskHandler: TaskKindHandler = {
+ onReady: (task) => {
+ const outcome = evaluateJiraDispatchTask(
+ "completed",
+ task.result as JiraDispatchTaskResult | undefined,
+ );
+
+ if (!outcome.success) {
+ toast({
+ variant: "destructive",
+ title: "Jira dispatch failed",
+ description: outcome.error,
+ action: buildRetryAction(task, outcome.failedFindingIds),
+ });
+ return;
+ }
+
+ toast({
+ title: outcome.warning ? "Jira dispatch partially completed" : "Success!",
+ description: outcome.warning ?? outcome.message,
+ action: buildRetryAction(task, outcome.failedFindingIds),
+ });
+ },
+ onError: (task) => {
+ toast({
+ variant: "destructive",
+ title: "Jira dispatch failed",
+ description: task.error || "The Jira dispatch task failed unexpectedly.",
+ });
+ },
+};
diff --git a/ui/components/findings/send-to-jira-modal-copy.test.ts b/ui/components/findings/send-to-jira-modal-copy.test.ts
new file mode 100644
index 0000000000..4e49e98402
--- /dev/null
+++ b/ui/components/findings/send-to-jira-modal-copy.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ buildJiraDispatchChoiceCopy,
+ JIRA_SELECTION_KIND,
+} from "./send-to-jira-modal-copy";
+
+describe("buildJiraDispatchChoiceCopy", () => {
+ it("uses Finding Group copy for selected Findings grouped Jira choice", () => {
+ expect(
+ buildJiraDispatchChoiceCopy({
+ selectedCount: 2,
+ isSelectedFindingGroupFlow: true,
+ }),
+ ).toEqual({
+ description:
+ "Create Jira issue(s) for 2 selected Findings from this Finding Group.",
+ groupedTitle:
+ "Create one Jira issue for all selected Findings in this Finding Group",
+ groupedHelp:
+ "Recommended. The issue will include every selected Finding from this Finding Group.",
+ individualHelp:
+ "Use this when each selected Finding should be tracked independently.",
+ });
+ });
+
+ it("preserves resource copy for resource-based grouped Jira choice", () => {
+ expect(
+ buildJiraDispatchChoiceCopy({
+ selectedCount: 2,
+ isSelectedFindingGroupFlow: false,
+ }),
+ ).toEqual({
+ description:
+ "Create Jira issue(s) for 2 selected affected failing resources.",
+ groupedTitle:
+ "Create one Jira issue for all selected affected failing resources",
+ groupedHelp:
+ "Recommended. The issue will include every selected resource from this finding group.",
+ individualHelp:
+ "Use this when each selected resource should be tracked independently.",
+ });
+ });
+
+ it("uses neutral Findings copy outside a single Finding Group", () => {
+ expect(
+ buildJiraDispatchChoiceCopy({
+ selectedCount: 2,
+ isSelectedFindingGroupFlow: false,
+ selectionKind: JIRA_SELECTION_KIND.FINDINGS,
+ }),
+ ).toEqual({
+ description: "Create Jira issue(s) for 2 selected Findings.",
+ groupedTitle: "Create one Jira issue for all selected Findings",
+ groupedHelp:
+ "Recommended. The issue will include every selected Finding.",
+ individualHelp:
+ "Use this when each selected Finding should be tracked independently.",
+ });
+ });
+});
diff --git a/ui/components/findings/send-to-jira-modal-copy.ts b/ui/components/findings/send-to-jira-modal-copy.ts
new file mode 100644
index 0000000000..f74bc472c7
--- /dev/null
+++ b/ui/components/findings/send-to-jira-modal-copy.ts
@@ -0,0 +1,59 @@
+export const JIRA_SELECTION_KIND = {
+ FINDINGS: "findings",
+ RESOURCES: "resources",
+} as const;
+
+type JiraSelectionKind =
+ (typeof JIRA_SELECTION_KIND)[keyof typeof JIRA_SELECTION_KIND];
+
+interface JiraDispatchChoiceCopyParams {
+ selectedCount: number;
+ isSelectedFindingGroupFlow: boolean;
+ selectionKind?: JiraSelectionKind;
+}
+
+interface JiraDispatchChoiceCopy {
+ description: string;
+ groupedTitle: string;
+ groupedHelp: string;
+ individualHelp: string;
+}
+
+export const buildJiraDispatchChoiceCopy = ({
+ selectedCount,
+ isSelectedFindingGroupFlow,
+ selectionKind = JIRA_SELECTION_KIND.RESOURCES,
+}: JiraDispatchChoiceCopyParams): JiraDispatchChoiceCopy => {
+ if (isSelectedFindingGroupFlow) {
+ return {
+ description: `Create Jira issue(s) for ${selectedCount} selected Findings from this Finding Group.`,
+ groupedTitle:
+ "Create one Jira issue for all selected Findings in this Finding Group",
+ groupedHelp:
+ "Recommended. The issue will include every selected Finding from this Finding Group.",
+ individualHelp:
+ "Use this when each selected Finding should be tracked independently.",
+ };
+ }
+
+ if (selectionKind === JIRA_SELECTION_KIND.FINDINGS) {
+ return {
+ description: `Create Jira issue(s) for ${selectedCount} selected Findings.`,
+ groupedTitle: "Create one Jira issue for all selected Findings",
+ groupedHelp:
+ "Recommended. The issue will include every selected Finding.",
+ individualHelp:
+ "Use this when each selected Finding should be tracked independently.",
+ };
+ }
+
+ return {
+ description: `Create Jira issue(s) for ${selectedCount} selected affected failing resources.`,
+ groupedTitle:
+ "Create one Jira issue for all selected affected failing resources",
+ groupedHelp:
+ "Recommended. The issue will include every selected resource from this finding group.",
+ individualHelp:
+ "Use this when each selected resource should be tracked independently.",
+ };
+};
diff --git a/ui/components/findings/send-to-jira-modal.test.tsx b/ui/components/findings/send-to-jira-modal.test.tsx
new file mode 100644
index 0000000000..7fc94db219
--- /dev/null
+++ b/ui/components/findings/send-to-jira-modal.test.tsx
@@ -0,0 +1,293 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { type ComponentProps } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { createJiraBatchSelection } from "@/lib/jira-dispatch-selection";
+import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
+
+import { SendToJiraModal } from "./send-to-jira-modal";
+
+interface ToastActionMockProps extends ComponentProps<"button"> {
+ altText: string;
+}
+
+const {
+ executeJiraDispatchBatchesMock,
+ getJiraIntegrationsMock,
+ getJiraIssueTypesMock,
+ toastMock,
+} = vi.hoisted(() => ({
+ executeJiraDispatchBatchesMock: vi.fn(),
+ getJiraIntegrationsMock: vi.fn(),
+ getJiraIssueTypesMock: vi.fn(),
+ toastMock: vi.fn(),
+}));
+
+vi.mock("@/actions/integrations/jira-dispatch", () => ({
+ getJiraIntegrations: getJiraIntegrationsMock,
+ getJiraIssueTypes: getJiraIssueTypesMock,
+}));
+
+vi.mock("@/lib/jira-dispatch-execution", () => ({
+ executeJiraDispatchBatches: executeJiraDispatchBatchesMock,
+}));
+
+vi.mock("@/components/shadcn/toast", () => ({
+ toast: toastMock,
+ ToastAction: ({
+ altText: _altText,
+ children,
+ ...props
+ }: ToastActionMockProps) => {children} ,
+}));
+
+vi.mock("@/components/shadcn/select/enhanced-multi-select", () => ({
+ EnhancedMultiSelect: ({
+ options,
+ onValueChange,
+ placeholder,
+ disabled,
+ }: {
+ options: { value: string; label: string }[];
+ onValueChange: (values: string[]) => void;
+ placeholder: string;
+ disabled?: boolean;
+ }) => (
+ onValueChange([options[0]?.value ?? ""])}
+ >
+ {placeholder}
+
+ ),
+}));
+
+const selection = createJiraBatchSelection([
+ {
+ targetIds: ["check-a"],
+ targetType: JIRA_DISPATCH_TARGET.CHECK_ID,
+ dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
+ },
+ {
+ targetIds: ["finding-1", "finding-2"],
+ targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
+ },
+])!;
+
+describe("SendToJiraModal", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ getJiraIntegrationsMock.mockResolvedValue({
+ success: true,
+ data: [
+ {
+ type: "integrations",
+ id: "jira-1",
+ attributes: {
+ inserted_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ enabled: true,
+ connected: true,
+ connection_last_checked_at: null,
+ integration_type: "jira",
+ configuration: {
+ domain: "example.atlassian.net",
+ projects: { SEC: "Security" },
+ issue_types: { SEC: ["Task"] },
+ },
+ },
+ links: { self: "/integrations/jira-1" },
+ },
+ ],
+ });
+ getJiraIssueTypesMock.mockResolvedValue({ success: true, issueTypes: [] });
+ executeJiraDispatchBatchesMock.mockResolvedValue({
+ startedTaskCount: 2,
+ successfulTaskCount: 2,
+ successfulIssueCount: 3,
+ successMessage: "3 Jira issues were created or updated successfully.",
+ warnings: [],
+ errors: [],
+ });
+ });
+
+ it("renders the dispatch choice and custom mixed-selection description", async () => {
+ // Given / When
+ render(
+ ,
+ );
+
+ // Then
+ expect(screen.getByText("Jira issue creation mode")).toBeInTheDocument();
+ expect(
+ screen.getByText("Create one Jira issue for all selected Findings"),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Create separate Jira issues")).toBeInTheDocument();
+ expect(
+ screen.getByText("Create Jira issues for 1 Group and 2 Findings."),
+ ).toBeInTheDocument();
+ await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
+ });
+
+ it("delegates mixed dispatch execution with the selected settings", async () => {
+ // Given
+ const user = userEvent.setup();
+ const onOpenChange = vi.fn();
+ render(
+ ,
+ );
+ await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
+ await user.click(
+ screen.getByRole("button", { name: "Select a Jira project" }),
+ );
+ await user.click(
+ screen.getByRole("button", { name: "Select an issue type" }),
+ );
+ await user.click(
+ screen.getByRole("radio", { name: "Create separate Jira issues" }),
+ );
+
+ // When
+ await user.click(screen.getByRole("button", { name: "Send to Jira" }));
+
+ // Then
+ await waitFor(() =>
+ expect(executeJiraDispatchBatchesMock).toHaveBeenCalledWith(
+ [
+ {
+ targetIds: ["check-a"],
+ targetType: "check_id",
+ dispatchMode: "grouped",
+ },
+ {
+ targetIds: ["finding-1", "finding-2"],
+ targetType: "finding_id",
+ },
+ ],
+ {
+ integrationId: "jira-1",
+ projectKey: "SEC",
+ issueType: "Task",
+ dispatchMode: "individual",
+ },
+ ),
+ );
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it("retries only the failed Jira dispatch batch", async () => {
+ // Given
+ const user = userEvent.setup();
+ const retryBatch = {
+ targetIds: ["finding-2"],
+ targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
+ dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL,
+ } as const;
+ executeJiraDispatchBatchesMock
+ .mockResolvedValueOnce({
+ startedTaskCount: 1,
+ successfulTaskCount: 1,
+ successfulIssueCount: 1,
+ successMessage: "1 Jira issue was created successfully.",
+ warnings: ["1 Jira issue failed."],
+ errors: [],
+ retryBatch,
+ })
+ .mockResolvedValueOnce({
+ startedTaskCount: 1,
+ successfulTaskCount: 1,
+ successfulIssueCount: 1,
+ successMessage: "1 Jira issue was created successfully.",
+ warnings: [],
+ errors: [],
+ });
+ const onOpenChange = vi.fn();
+ const { rerender } = render(
+ ,
+ );
+ await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
+ await user.click(
+ screen.getByRole("button", { name: "Select a Jira project" }),
+ );
+ await user.click(
+ screen.getByRole("button", { name: "Select an issue type" }),
+ );
+ await user.click(screen.getByRole("button", { name: "Send to Jira" }));
+ await waitFor(() =>
+ expect(executeJiraDispatchBatchesMock).toHaveBeenCalledTimes(1),
+ );
+ const partialToast = toastMock.mock.calls.find(
+ ([toast]) => toast.title === "Partial success",
+ )?.[0];
+ rerender(
+ ,
+ );
+ render(partialToast.action);
+
+ // When
+ await user.click(screen.getByRole("button", { name: "Retry failed" }));
+
+ // Then
+ await waitFor(() =>
+ expect(executeJiraDispatchBatchesMock).toHaveBeenNthCalledWith(
+ 2,
+ [retryBatch],
+ {
+ integrationId: "jira-1",
+ projectKey: "SEC",
+ issueType: "Task",
+ dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
+ },
+ ),
+ );
+ });
+
+ it("closes the modal before navigating to Jira configuration", async () => {
+ // Given
+ const user = userEvent.setup();
+ const onOpenChange = vi.fn();
+ getJiraIntegrationsMock.mockResolvedValueOnce({
+ success: true,
+ data: [],
+ });
+ render(
+ ,
+ );
+ const configureLink = await screen.findByRole("link", {
+ name: "Configure",
+ });
+
+ // When
+ await user.click(configureLink);
+
+ // Then
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+});
diff --git a/ui/components/findings/send-to-jira-modal.tsx b/ui/components/findings/send-to-jira-modal.tsx
index aa6774da45..4557781efa 100644
--- a/ui/components/findings/send-to-jira-modal.tsx
+++ b/ui/components/findings/send-to-jira-modal.tsx
@@ -2,49 +2,93 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Send } from "lucide-react";
-import { type Dispatch, type SetStateAction, useEffect, useState } from "react";
+import { type Dispatch, type SetStateAction, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
getJiraIntegrations,
getJiraIssueTypes,
- pollJiraDispatchTask,
- sendFindingToJira,
} from "@/actions/integrations/jira-dispatch";
-import { useToast } from "@/components/shadcn";
import { CustomBanner } from "@/components/shadcn/custom/custom-banner";
+import { CustomRadio } from "@/components/shadcn/custom/custom-radio";
import { Form, FormField, FormMessage } from "@/components/shadcn/form";
import { FormButtons } from "@/components/shadcn/form/form-buttons";
import { Modal } from "@/components/shadcn/modal";
+import { RadioGroup } from "@/components/shadcn/radio-group/radio-group";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
-import { IntegrationProps } from "@/types/integrations";
+import { toast, ToastAction } from "@/components/shadcn/toast";
+import { useMountEffect } from "@/hooks/use-mount-effect";
+import {
+ executeJiraDispatchBatches,
+ type JiraDispatchSettings,
+} from "@/lib/jira-dispatch-execution";
+import { getJiraSelectionBatches } from "@/lib/jira-dispatch-selection";
+import {
+ type IntegrationProps,
+ JIRA_DISPATCH_MODE,
+ JIRA_DISPATCH_TARGET,
+ type JiraDispatchMode,
+ type JiraDispatchTargetBatch,
+ type JiraSelection,
+} from "@/types/integrations";
-interface SendToJiraModalProps {
+import {
+ buildJiraDispatchChoiceCopy,
+ JIRA_SELECTION_KIND,
+} from "./send-to-jira-modal-copy";
+
+export interface SendToJiraModalProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
- findingId: string;
+ selection: JiraSelection;
findingTitle?: string;
+ defaultDispatchMode?: JiraDispatchMode;
+ canChooseGroupedDispatch?: boolean;
+ isFindingGroupSelection?: boolean;
+ selectedResourceCount?: number;
+ description?: string;
}
const sendToJiraSchema = z.object({
integration: z.string().min(1, "Please select a Jira integration"),
project: z.string().min(1, "Please select a project"),
issueType: z.string().min(1, "Please select an issue type"),
+ dispatchMode: z.enum([
+ JIRA_DISPATCH_MODE.GROUPED,
+ JIRA_DISPATCH_MODE.INDIVIDUAL,
+ ]),
});
type SendToJiraFormData = z.infer;
-export const SendToJiraModal = ({
- isOpen,
+const getConfiguredIssueTypes = (
+ integration: IntegrationProps | undefined,
+ projectKey: string,
+) => {
+ const configuredIssueTypes = integration?.attributes.configuration
+ .issue_types as Record | undefined;
+
+ return configuredIssueTypes &&
+ typeof configuredIssueTypes === "object" &&
+ !Array.isArray(configuredIssueTypes)
+ ? (configuredIssueTypes[projectKey] ?? [])
+ : [];
+};
+
+const SendToJiraModalContent = ({
onOpenChange,
- findingId,
+ selection,
findingTitle,
-}: SendToJiraModalProps) => {
- const { toast } = useToast();
+ defaultDispatchMode = JIRA_DISPATCH_MODE.INDIVIDUAL,
+ canChooseGroupedDispatch = false,
+ isFindingGroupSelection = false,
+ selectedResourceCount,
+ description,
+}: Omit) => {
const [integrations, setIntegrations] = useState([]);
- const [isFetchingIntegrations, setIsFetchingIntegrations] = useState(false);
+ const [isFetchingIntegrations, setIsFetchingIntegrations] = useState(true);
const [fetchedIssueTypes, setFetchedIssueTypes] = useState<
Record
>({});
@@ -56,191 +100,214 @@ export const SendToJiraModal = ({
integration: "",
project: "",
issueType: "",
+ dispatchMode: defaultDispatchMode,
},
});
- const selectedIntegration = form.watch("integration");
+ const jiraTargetBatches = getJiraSelectionBatches(selection);
+ const findingTargetCount = jiraTargetBatches
+ .filter((batch) => batch.targetType === JIRA_DISPATCH_TARGET.FINDING_ID)
+ .reduce((count, batch) => count + batch.targetIds.length, 0);
+ const jiraSelectedResourceCount = selectedResourceCount ?? findingTargetCount;
+ const shouldShowDispatchChoice =
+ (canChooseGroupedDispatch || findingTargetCount > 1) &&
+ (findingTargetCount > 1 || jiraSelectedResourceCount > 1);
+ const checkIdBatches = jiraTargetBatches.filter(
+ (batch) => batch.targetType === JIRA_DISPATCH_TARGET.CHECK_ID,
+ );
+ const hasOnlySingleFindingGroupBatch =
+ jiraTargetBatches.length === 1 &&
+ checkIdBatches.length === 1 &&
+ checkIdBatches[0].targetIds.length === 1;
+ const isSelectedFindingGroupFlow =
+ shouldShowDispatchChoice &&
+ (isFindingGroupSelection || hasOnlySingleFindingGroupBatch);
+ const jiraDispatchChoiceCopy = buildJiraDispatchChoiceCopy({
+ selectedCount:
+ findingTargetCount > 1 ? findingTargetCount : jiraSelectedResourceCount,
+ isSelectedFindingGroupFlow,
+ selectionKind:
+ findingTargetCount > 1
+ ? JIRA_SELECTION_KIND.FINDINGS
+ : JIRA_SELECTION_KIND.RESOURCES,
+ });
+ const selectedIntegration = form.watch("integration");
+ const selectedProject = form.watch("project");
+ const selectedIntegrationData = integrations.find(
+ (integration) => integration.id === selectedIntegration,
+ );
+ const projects =
+ selectedIntegrationData?.attributes.configuration.projects ?? {};
+ const projectEntries = Object.entries(projects);
+ const configuredIssueTypes = getConfiguredIssueTypes(
+ selectedIntegrationData,
+ selectedProject,
+ );
+ const issueTypesForProject =
+ configuredIssueTypes.length > 0
+ ? configuredIssueTypes
+ : (fetchedIssueTypes[`${selectedIntegration}:${selectedProject}`] ?? []);
const hasConnectedIntegration = integrations.some(
- (i) => i.attributes.connected === true,
+ (integration) => integration.attributes.connected === true,
);
- const setOpenForFormButtons: Dispatch> = (value) => {
- const next = typeof value === "function" ? value(isOpen) : value;
- onOpenChange(next);
- };
-
- // Fetch Jira integrations when modal opens
- useEffect(() => {
- if (isOpen) {
- const fetchJiraIntegrations = async () => {
- setIsFetchingIntegrations(true);
-
- try {
- const result = await getJiraIntegrations();
- if (!result.success) {
- throw new Error(
- result.error || "Unable to fetch Jira integrations",
- );
- }
- setIntegrations(result.data);
- // Auto-select if only one integration
- if (result.data.length === 1) {
- form.setValue("integration", result.data[0].id);
- }
- } catch (error) {
- const message =
- error instanceof Error && error.message
- ? error.message
- : "Failed to load Jira integrations";
- toast({
- variant: "destructive",
- title: "Failed to load integrations",
- description: message,
- });
- } finally {
- setIsFetchingIntegrations(false);
- }
- };
-
- fetchJiraIntegrations();
- } else {
- // Reset form and fetched data when modal closes
- form.reset();
- setFetchedIssueTypes({});
- }
- }, [isOpen, form, toast]);
-
- const handleSubmit = async (data: SendToJiraFormData) => {
- // Close modal immediately; continue processing in background
- onOpenChange(false);
+ useMountEffect(() => {
+ let active = true;
void (async () => {
try {
- // Send the finding to Jira
- const result = await sendFindingToJira(
- data.integration,
- findingId,
- data.project,
- data.issueType,
- );
-
+ const result = await getJiraIntegrations();
+ if (!active) return;
if (!result.success) {
- throw new Error(result.error || "Failed to send to Jira");
+ throw new Error(result.error || "Unable to fetch Jira integrations");
}
- // Poll for task completion and notify once
- const taskResult = await pollJiraDispatchTask(result.taskId);
-
- if (!taskResult.success) {
- throw new Error(taskResult.error || "Failed to create Jira issue");
+ setIntegrations(result.data);
+ if (result.data.length === 1) {
+ form.setValue("integration", result.data[0].id);
}
-
- toast({
- title: "Success!",
- description:
- taskResult.message || "Finding sent to Jira successfully",
- });
} catch (error) {
+ if (!active) return;
const message =
error instanceof Error && error.message
? error.message
- : "Failed to send finding to Jira";
+ : "Failed to load Jira integrations";
toast({
variant: "destructive",
- title: "Error",
+ title: "Failed to load integrations",
description: message,
});
+ } finally {
+ if (active) setIsFetchingIntegrations(false);
}
})();
- };
-
- const selectedProject = form.watch("project");
-
- const selectedIntegrationData = integrations.find(
- (i) => i.id === selectedIntegration,
- );
-
- const projects: Record =
- selectedIntegrationData?.attributes.configuration.projects ??
- ({} as Record);
-
- const projectEntries = Object.entries(projects);
-
- // Get issue types from config (new dict format), falling back to fetched data
- const configIssueTypes = selectedIntegrationData?.attributes.configuration
- .issue_types as Record | undefined;
- const issueTypesFromConfig =
- configIssueTypes &&
- typeof configIssueTypes === "object" &&
- !Array.isArray(configIssueTypes)
- ? (configIssueTypes[selectedProject] ?? [])
- : [];
- const issueTypesForProject =
- issueTypesFromConfig.length > 0
- ? issueTypesFromConfig
- : (fetchedIssueTypes[selectedProject] ?? []);
-
- // Fetch issue types from API when project is selected but no types are available
- useEffect(() => {
- let ignore = false;
-
- if (
- selectedIntegration &&
- selectedProject &&
- issueTypesFromConfig.length === 0 &&
- !fetchedIssueTypes[selectedProject]
- ) {
- const fetchIssueTypes = async () => {
- setIsFetchingIssueTypes(true);
- try {
- const result = await getJiraIssueTypes(
- selectedIntegration,
- selectedProject,
- );
- if (ignore) return;
- if (result.success) {
- setFetchedIssueTypes((prev) => ({
- ...prev,
- [selectedProject]: result.issueTypes,
- }));
- } else {
- toast({
- variant: "destructive",
- title: "Failed to load issue types",
- description:
- result.error || "Unable to fetch issue types for this project",
- });
- }
- } finally {
- if (!ignore) setIsFetchingIssueTypes(false);
- }
- };
-
- fetchIssueTypes();
- }
return () => {
- ignore = true;
+ active = false;
};
- }, [
- selectedIntegration,
- selectedProject,
- issueTypesFromConfig.length,
- fetchedIssueTypes,
- toast,
- ]);
+ });
+
+ const setOpenForFormButtons: Dispatch> = (value) => {
+ const nextOpen = typeof value === "function" ? value(true) : value;
+ onOpenChange(nextOpen);
+ };
+
+ const loadIssueTypes = async (integrationId: string, projectKey: string) => {
+ const integration = integrations.find((item) => item.id === integrationId);
+ if (
+ !integrationId ||
+ !projectKey ||
+ getConfiguredIssueTypes(integration, projectKey).length > 0 ||
+ fetchedIssueTypes[`${integrationId}:${projectKey}`]
+ ) {
+ return;
+ }
+
+ setIsFetchingIssueTypes(true);
+ try {
+ const result = await getJiraIssueTypes(integrationId, projectKey);
+ if (result.success) {
+ setFetchedIssueTypes((current) => ({
+ ...current,
+ [`${integrationId}:${projectKey}`]: result.issueTypes,
+ }));
+ return;
+ }
+
+ toast({
+ variant: "destructive",
+ title: "Failed to load issue types",
+ description:
+ result.error || "Unable to fetch issue types for this project",
+ });
+ } catch {
+ toast({
+ variant: "destructive",
+ title: "Failed to load issue types",
+ description: "Unable to fetch issue types for this project",
+ });
+ } finally {
+ setIsFetchingIssueTypes(false);
+ }
+ };
+
+ async function processBatches(
+ batches: JiraDispatchTargetBatch[],
+ settings: JiraDispatchSettings,
+ ) {
+ const result = await executeJiraDispatchBatches(batches, settings);
+ const retryBatches = result.retryBatch ? [result.retryBatch] : [];
+ const retryAction =
+ retryBatches.length > 0 ? (
+ {
+ toast({
+ title: "Retry started",
+ description: "Retrying only the Jira dispatches that failed.",
+ });
+ await processBatches(retryBatches, settings);
+ }}
+ >
+ Retry failed
+
+ ) : undefined;
+
+ if (result.errors.length > 0 || result.warnings.length > 0) {
+ if (result.successfulTaskCount > 0) {
+ toast({
+ title: "Partial success",
+ description: `${result.successMessage || "Some Jira issues were created successfully."} Some Jira dispatches failed: ${[
+ ...result.warnings,
+ ...result.errors,
+ ].join(" ")}`,
+ ...(retryAction ? { action: retryAction } : {}),
+ });
+ return;
+ }
+
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: [...result.warnings, ...result.errors].join(" "),
+ ...(retryAction ? { action: retryAction } : {}),
+ });
+ return;
+ }
+
+ toast({
+ title: "Success!",
+ description: result.successMessage || "Finding sent to Jira successfully",
+ });
+ }
+
+ const handleSubmit = async (data: SendToJiraFormData) => {
+ onOpenChange(false);
+
+ void processBatches(jiraTargetBatches, {
+ integrationId: data.integration,
+ projectKey: data.project,
+ issueType: data.issueType,
+ dispatchMode: data.dispatchMode,
+ }).catch(() => {
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description:
+ "The Jira dispatch could not be processed. Check Jira before retrying.",
+ });
+ });
+ };
const issueTypeOptions = issueTypesForProject.map((type) => ({
value: type,
label: type,
}));
-
const integrationOptions = integrations.map((integration) => ({
value: integration.id,
label: integration.attributes.configuration.domain || integration.id,
}));
-
const projectOptions = projectEntries.map(([key, name]) => ({
value: key,
label: `${key} - ${name}`,
@@ -248,13 +315,17 @@ export const SendToJiraModal = ({
return (
@@ -310,7 +377,6 @@ export const SendToJiraModal = ({
/>
)}
- {/* Project Selection */}
{!isFetchingIntegrations &&
selectedIntegration &&
projectEntries.length > 0 && (
@@ -329,19 +395,19 @@ export const SendToJiraModal = ({
id="jira-project-select"
options={projectOptions}
onValueChange={(values) => {
- const selectedValue = values.at(-1) ?? "";
- field.onChange(selectedValue);
- // Reset issue type when project changes
+ const projectKey = values.at(-1) ?? "";
+ field.onChange(projectKey);
form.setValue("issueType", "");
+ void loadIssueTypes(selectedIntegration, projectKey);
}}
defaultValue={field.value ? [field.value] : []}
placeholder="Select a Jira project"
- searchable={true}
+ searchable
emptyIndicator="No projects found."
- hideSelectAll={true}
+ hideSelectAll
maxCount={1}
- closeOnSelect={true}
- resetOnDefaultValueChange={true}
+ closeOnSelect
+ resetOnDefaultValueChange
/>
@@ -349,7 +415,6 @@ export const SendToJiraModal = ({
/>
)}
- {/* Issue Type Selection */}
{selectedProject && (
{
- const selectedValue = values.at(-1) ?? "";
- field.onChange(selectedValue);
- }}
+ onValueChange={(values) =>
+ field.onChange(values.at(-1) ?? "")
+ }
defaultValue={field.value ? [field.value] : []}
placeholder={
isFetchingIssueTypes
? "Loading issue types..."
: "Select an issue type"
}
- searchable={true}
+ searchable
emptyIndicator="No issue types found."
disabled={isFetchingIssueTypes}
- hideSelectAll={true}
+ hideSelectAll
maxCount={1}
- closeOnSelect={true}
- resetOnDefaultValueChange={true}
+ closeOnSelect
+ resetOnDefaultValueChange
/>
@@ -389,7 +453,52 @@ export const SendToJiraModal = ({
/>
)}
- {/* No integrations or none connected message */}
+ {shouldShowDispatchChoice && (
+
(
+
+
+ Jira issue creation mode
+
+
+
+
+
+ {jiraDispatchChoiceCopy.groupedTitle}
+
+
+ {jiraDispatchChoiceCopy.groupedHelp}
+
+
+
+
+
+
+ Create separate Jira issues
+
+
+ {jiraDispatchChoiceCopy.individualHelp}
+
+
+
+
+
+
+ )}
+ />
+ )}
+
{!isFetchingIntegrations &&
(integrations.length === 0 || !hasConnectedIntegration) ? (
onOpenChange(false)}
/>
) : (
);
};
+
+export const SendToJiraModal = ({ isOpen, ...props }: SendToJiraModalProps) => {
+ if (!isOpen) return null;
+
+ return ;
+};
diff --git a/ui/components/findings/table/column-finding-resources.test.tsx b/ui/components/findings/table/column-finding-resources.test.tsx
index 877d83c186..ce33c2a867 100644
--- a/ui/components/findings/table/column-finding-resources.test.tsx
+++ b/ui/components/findings/table/column-finding-resources.test.tsx
@@ -5,7 +5,11 @@ import type {
InputHTMLAttributes,
ReactNode,
} from "react";
-import { describe, expect, it, vi } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { isGroupedJiraDispatchEnabledMock } = vi.hoisted(() => ({
+ isGroupedJiraDispatchEnabledMock: vi.fn(() => true),
+}));
// CustomLink pulls the "@/lib" barrel (and next-auth with it) into the unit env.
vi.mock("@/components/shadcn/custom/custom-link", () => ({
@@ -14,8 +18,7 @@ vi.mock("@/components/shadcn/custom/custom-link", () => ({
),
}));
-vi.mock("@/components/shadcn", async (importOriginal) => ({
- ...(await importOriginal>()),
+vi.mock("@/components/shadcn", () => ({
Button: ({ children, ...props }: ButtonHTMLAttributes) => (
{children}
),
@@ -41,22 +44,6 @@ vi.mock("@/components/findings/mute-findings-modal", () => ({
MuteFindingsModal: () => null,
}));
-vi.mock("@/components/findings/send-to-jira-modal", () => ({
- SendToJiraModal: ({
- findingId,
- isOpen,
- }: {
- findingId: string;
- isOpen: boolean;
- }) => (
-
- ),
-}));
-
vi.mock("@/components/icons/services/IconServices", () => ({
JiraIcon: () => null,
}));
@@ -69,12 +56,14 @@ vi.mock("@/components/shadcn/dropdown", () => ({
label,
onSelect,
disabled,
+ disabledTooltip,
}: {
label: string;
onSelect?: () => void;
disabled?: boolean;
+ disabledTooltip?: string;
}) => (
-
+
{label}
),
@@ -175,6 +164,11 @@ vi.mock("@/lib/date-utils", () => ({
getFailingForLabel: () => "2d",
}));
+vi.mock("@/lib/deployment", () => ({
+ isGroupedJiraDispatchEnabled: isGroupedJiraDispatchEnabledMock,
+ PROWLER_CLOUD_ONLY_TOOLTIP: "Available only in Prowler Cloud",
+}));
+
const notificationIndicatorMock = vi.fn((_props: unknown) => null);
vi.mock("./notification-indicator", () => ({
@@ -184,6 +178,7 @@ vi.mock("./notification-indicator", () => ({
},
}));
+import { useJiraDispatchStore } from "@/store/jira-dispatch/store";
import type { FindingResourceRow } from "@/types";
import {
FINDING_TRIAGE_DISABLED_REASON,
@@ -284,6 +279,12 @@ function renderResourceActionsCell({
}
describe("column-finding-resources", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ isGroupedJiraDispatchEnabledMock.mockReturnValue(true);
+ useJiraDispatchStore.getState().closeJiraDispatch();
+ });
+
it("should render actions as the last visible column after Triage without Notes", () => {
// Given
const columns = getColumnFindingResources({
@@ -296,6 +297,7 @@ describe("column-finding-resources", () => {
// Then
expect(columnIds.slice(-2)).toEqual(["triage", "actions"]);
+ expect(columnIds).not.toContain("status");
expect(columnIds).not.toContain("notes");
expect(
(columns.at(-1) as { id?: string; size?: number } | undefined)?.size,
@@ -473,7 +475,7 @@ describe("column-finding-resources", () => {
expect(screen.getByText(CLOUD_ONLY_TOOLTIP_COPY)).toBeInTheDocument();
});
- it("should open Send to Jira modal with finding UUID directly", async () => {
+ it("should open Jira dispatch with the finding UUID directly", async () => {
// Given
const user = userEvent.setup();
@@ -506,16 +508,15 @@ describe("column-finding-resources", () => {
);
// When
- await user.click(screen.getByRole("button", { name: "Send to Jira" }));
+ await user.click(
+ screen.getByRole("button", { name: "Send 1 Finding to Jira" }),
+ );
// Then
- expect(screen.getByTestId("jira-modal")).toHaveAttribute(
- "data-finding-id",
- "real-finding-uuid",
- );
- expect(screen.getByTestId("jira-modal")).toHaveAttribute(
- "data-open",
- "true",
- );
+ expect(useJiraDispatchStore.getState().activePayload?.selection).toEqual({
+ kind: "single",
+ targetId: "real-finding-uuid",
+ targetType: "finding_id",
+ });
});
});
diff --git a/ui/components/findings/table/column-finding-resources.tsx b/ui/components/findings/table/column-finding-resources.tsx
index 255abea929..cddf57f429 100644
--- a/ui/components/findings/table/column-finding-resources.tsx
+++ b/ui/components/findings/table/column-finding-resources.tsx
@@ -4,9 +4,8 @@ import { ColumnDef, Row, RowSelectionState } from "@tanstack/react-table";
import { CornerDownRight, VolumeOff, VolumeX } from "lucide-react";
import { useContext, useState } from "react";
+import { JiraDispatchActionItem } from "@/components/findings/jira-dispatch-action-item";
import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
-import { SendToJiraModal } from "@/components/findings/send-to-jira-modal";
-import { JiraIcon } from "@/components/icons/services/IconServices";
import { Checkbox } from "@/components/shadcn";
import {
ActionDropdown,
@@ -18,16 +17,15 @@ import { InfoField } from "@/components/shadcn/info-field/info-field";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { SeverityBadge } from "@/components/shadcn/table";
import { DataTableColumnHeader } from "@/components/shadcn/table/data-table-column-header";
-import {
- type FindingStatus,
- StatusFindingBadge,
-} from "@/components/shadcn/table/status-finding-badge";
import { getFailingForLabel } from "@/lib/date-utils";
+import { buildJiraActionLabel } from "@/lib/jira-dispatch-action";
+import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
import { FindingResourceRow } from "@/types";
import type {
FindingTriageLoadedNote,
FindingTriageSummary,
} from "@/types/findings-triage";
+import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
import { canMuteFindingResource } from "./finding-resource-selection";
import {
@@ -57,7 +55,6 @@ const ResourceRowActions = ({
const resource = row.original;
const canMute = canMuteFindingResource(resource);
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
- const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
const [resolvedIds, setResolvedIds] = useState([]);
const [isResolving, setIsResolving] = useState(false);
@@ -83,6 +80,14 @@ const ResourceRowActions = ({
if (ids.length > 1) return `Mute ${ids.length}`;
return "Mute";
};
+ const displayIds = getDisplayIds();
+ const jiraPayload = createJiraDispatchPayload({
+ targetIds: displayIds,
+ targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
+ findingTitle: findingTitle || resource.checkId,
+ selectedResourceCount: displayIds.length,
+ isFindingGroupSelection: true,
+ });
const handleMuteClick = async () => {
const displayIds = getDisplayIds();
@@ -123,12 +128,6 @@ const ResourceRowActions = ({
onComplete={handleMuteComplete}
/>
)}
-
e.stopPropagation()}
@@ -159,10 +158,11 @@ const ResourceRowActions = ({
disabled={!canMute || isResolving}
onSelect={handleMuteClick}
/>
-
}
- label="Send to Jira"
- onSelect={() => setIsJiraModalOpen(true)}
+
@@ -243,24 +243,14 @@ export function getColumnFindingResources({
enableSorting: false,
enableHiding: false,
},
- // Status
- {
- id: "status",
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => {
- return (
-
- );
- },
- enableSorting: false,
- },
- // Resource — name + uid
+ // Affected failing resource — name + uid
{
id: "resource",
header: ({ column }) => (
-
+
),
cell: ({ row }) => (
diff --git a/ui/components/findings/table/data-table-row-actions.test.tsx b/ui/components/findings/table/data-table-row-actions.test.tsx
index 5f63d640b9..d4ac28b42c 100644
--- a/ui/components/findings/table/data-table-row-actions.test.tsx
+++ b/ui/components/findings/table/data-table-row-actions.test.tsx
@@ -2,8 +2,21 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
+import { useJiraDispatchStore } from "@/store/jira-dispatch/store";
+import {
+ FINDING_TRIAGE_DISABLED_REASON,
+ FINDING_TRIAGE_STATUS,
+ type FindingTriageSummary,
+} from "@/types/findings-triage";
+
+import {
+ DataTableRowActions,
+ type FindingRowData,
+} from "./data-table-row-actions";
+import { FindingsSelectionContext } from "./findings-selection-context";
+
const { MuteFindingsModalMock } = vi.hoisted(() => ({
- MuteFindingsModalMock: vi.fn(() => null),
+ MuteFindingsModalMock: vi.fn((_props: unknown) => null),
}));
vi.mock("next/navigation", () => ({
@@ -14,14 +27,15 @@ vi.mock("@/components/findings/mute-findings-modal", () => ({
MuteFindingsModal: MuteFindingsModalMock,
}));
-vi.mock("@/components/findings/send-to-jira-modal", () => ({
- SendToJiraModal: () => null,
-}));
-
vi.mock("@/components/icons/services/IconServices", () => ({
JiraIcon: () => null,
}));
+vi.mock("@/lib/deployment", () => ({
+ isGroupedJiraDispatchEnabled: () => true,
+ PROWLER_CLOUD_ONLY_TOOLTIP: "Available only in Prowler Cloud",
+}));
+
vi.mock("@/components/shadcn/dropdown", () => ({
ActionDropdown: ({ children }: { children: React.ReactNode }) => (
{children}
@@ -74,18 +88,6 @@ vi.mock("./finding-note-modal", () => ({
) : null,
}));
-import {
- FINDING_TRIAGE_DISABLED_REASON,
- FINDING_TRIAGE_STATUS,
- type FindingTriageSummary,
-} from "@/types/findings-triage";
-
-import {
- DataTableRowActions,
- type FindingRowData,
-} from "./data-table-row-actions";
-import { FindingsSelectionContext } from "./findings-selection-context";
-
function deferredPromise
() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
@@ -134,6 +136,7 @@ function makeFindingRow(overrides?: Partial) {
describe("DataTableRowActions", () => {
beforeEach(() => {
vi.clearAllMocks();
+ useJiraDispatchStore.getState().closeJiraDispatch();
});
it("opens the mute modal immediately in preparing state for finding groups", async () => {
@@ -176,41 +179,18 @@ describe("DataTableRowActions", () => {
);
// Then
- const preparingCall = (
- MuteFindingsModalMock.mock.calls as unknown as Array<
- [
- {
- isOpen: boolean;
- isPreparing?: boolean;
- findingIds: string[];
- },
- ]
- >
- ).at(-1);
-
- expect(preparingCall?.[0]).toMatchObject({
+ expect(MuteFindingsModalMock.mock.calls.at(-1)?.[0]).toMatchObject({
isOpen: true,
isPreparing: true,
findingIds: [],
});
- // And when the resolver finishes
+ // When
deferred.resolve(["finding-1", "finding-2"]);
+ // Then
await waitFor(() => {
- const resolvedCall = (
- MuteFindingsModalMock.mock.calls as unknown as Array<
- [
- {
- isOpen: boolean;
- isPreparing?: boolean;
- findingIds: string[];
- },
- ]
- >
- ).at(-1);
-
- expect(resolvedCall?.[0]).toMatchObject({
+ expect(MuteFindingsModalMock.mock.calls.at(-1)?.[0]).toMatchObject({
isOpen: true,
isPreparing: false,
findingIds: ["finding-1", "finding-2"],
@@ -219,6 +199,7 @@ describe("DataTableRowActions", () => {
});
it("disables the mute action for groups without impacted resources", () => {
+ // Given / When
render(
{
,
);
+ // Then
expect(
screen.getByRole("button", { name: "Mute Finding Group" }),
).toBeDisabled();
});
+ it("opens Jira from the row action for a finding group", async () => {
+ // Given
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+
+ // When
+ await user.click(
+ screen.getByRole("button", { name: "Send 1 Finding Group to Jira" }),
+ );
+
+ // Then
+ expect(useJiraDispatchStore.getState().activePayload).toEqual({
+ selection: {
+ kind: "single",
+ targetId: "s3_bucket_public_access",
+ targetType: "check_id",
+ },
+ findingTitle: "S3 bucket public access",
+ selectedResourceCount: 2,
+ });
+ });
+
it("shows Add Triage Note for editable findings without a note", () => {
// Given / When
render(
diff --git a/ui/components/findings/table/data-table-row-actions.tsx b/ui/components/findings/table/data-table-row-actions.tsx
index f0d6f10e6f..123c12d0b7 100644
--- a/ui/components/findings/table/data-table-row-actions.tsx
+++ b/ui/components/findings/table/data-table-row-actions.tsx
@@ -5,20 +5,22 @@ import { VolumeOff, VolumeX } from "lucide-react";
import { useRouter } from "next/navigation";
import { useContext, useState } from "react";
+import { JiraDispatchActionItem } from "@/components/findings/jira-dispatch-action-item";
import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
-import { SendToJiraModal } from "@/components/findings/send-to-jira-modal";
-import { JiraIcon } from "@/components/icons/services/IconServices";
import {
ActionDropdown,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { isFindingGroupMuted } from "@/lib/findings-groups";
+import { buildJiraActionLabel } from "@/lib/jira-dispatch-action";
+import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
import { getOptionalText } from "@/lib/utils";
import type {
FindingTriageLoadedNote,
FindingTriageSummary,
} from "@/types/findings-triage";
+import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
import type { ProviderType } from "@/types/providers";
import { canMuteFindingGroup } from "./finding-group-selection";
@@ -109,7 +111,6 @@ export function DataTableRowActions({
}: DataTableRowActionsProps) {
const router = useRouter();
const finding = row.original;
- const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
const [isPreparingMuteModal, setIsPreparingMuteModal] = useState(false);
const [mutePreparationError, setMutePreparationError] = useState<
@@ -150,22 +151,34 @@ export function DataTableRowActions({
const isCurrentSelected = selectedFindingIds.includes(muteKey);
const hasMultipleSelected = selectedFindingIds.length > 1;
- const getDisplayIds = (): string[] => {
- if (isCurrentSelected && hasMultipleSelected) {
- return selectedFindingIds;
- }
- return [muteKey];
- };
+ const actionTargetIds =
+ isCurrentSelected && hasMultipleSelected ? selectedFindingIds : [muteKey];
const getMuteLabel = () => {
if (isMuted) return "Muted";
- const ids = getDisplayIds();
- if (ids.length > 1) {
- return `Mute ${ids.length} ${isGroup ? "Finding Groups" : "Findings"}`;
+ if (actionTargetIds.length > 1) {
+ return `Mute ${actionTargetIds.length} ${isGroup ? "Finding Groups" : "Findings"}`;
}
return isGroup ? "Mute Finding Group" : "Mute Finding";
};
+ const jiraTargetType = isGroup
+ ? JIRA_DISPATCH_TARGET.CHECK_ID
+ : JIRA_DISPATCH_TARGET.FINDING_ID;
+ const selectedJiraResourceCount = isGroup
+ ? (finding.resourcesFail ?? 0)
+ : undefined;
+ const jiraPayload = createJiraDispatchPayload({
+ targetIds: actionTargetIds,
+ targetType: jiraTargetType,
+ findingTitle,
+ selectedResourceCount: selectedJiraResourceCount,
+ });
+ const jiraLabel = buildJiraActionLabel({
+ findingGroupCount: isGroup ? actionTargetIds.length : 0,
+ findingCount: isGroup ? 0 : actionTargetIds.length,
+ });
+
const handleMuteModalOpenChange = (
nextOpen: boolean | ((previousOpen: boolean) => boolean),
) => {
@@ -181,8 +194,6 @@ export function DataTableRowActions({
};
const handleMuteClick = async () => {
- const displayIds = getDisplayIds();
-
if (resolveMuteIds) {
setResolvedIds([]);
setMutePreparationError(null);
@@ -190,7 +201,7 @@ export function DataTableRowActions({
setIsMuteModalOpen(true);
setIsResolving(true);
try {
- const ids = await resolveMuteIds(displayIds);
+ const ids = await resolveMuteIds(actionTargetIds);
setResolvedIds(ids);
setMutePreparationError(
ids.length === 0
@@ -207,7 +218,7 @@ export function DataTableRowActions({
}
} else {
// Regular findings — IDs are already valid finding UUIDs
- setResolvedIds(displayIds);
+ setResolvedIds(actionTargetIds);
setIsMuteModalOpen(true);
}
};
@@ -219,7 +230,7 @@ export function DataTableRowActions({
clearSelection();
setResolvedIds([]);
if (onMuteComplete) {
- onMuteComplete(getDisplayIds());
+ onMuteComplete(actionTargetIds);
return;
}
@@ -228,15 +239,6 @@ export function DataTableRowActions({
return (
<>
- {!isGroup && (
-
- )}
-
({
disabled={!canMute || isResolving}
onSelect={handleMuteClick}
/>
- {!isGroup && (
- }
- label="Send to Jira"
- onSelect={() => setIsJiraModalOpen(true)}
- />
- )}
+
>
diff --git a/ui/components/findings/table/findings-group-drill-down.test.ts b/ui/components/findings/table/findings-group-drill-down.test.ts
deleted file mode 100644
index 816e894e0a..0000000000
--- a/ui/components/findings/table/findings-group-drill-down.test.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { readFileSync } from "node:fs";
-import path from "node:path";
-import { fileURLToPath } from "node:url";
-
-import { describe, expect, it } from "vitest";
-
-describe("findings group drill down", () => {
- const currentDir = path.dirname(fileURLToPath(import.meta.url));
- const filePath = path.join(currentDir, "findings-group-drill-down.tsx");
- const source = readFileSync(filePath, "utf8");
-
- it("uses the shared finding-group resource state hook", () => {
- expect(source).toContain("useFindingGroupResourceState");
- expect(source).not.toContain("useInfiniteResources");
- });
-});
diff --git a/ui/components/findings/table/findings-group-drill-down.tsx b/ui/components/findings/table/findings-group-drill-down.tsx
index 0b96a9657e..045db68e7f 100644
--- a/ui/components/findings/table/findings-group-drill-down.tsx
+++ b/ui/components/findings/table/findings-group-drill-down.tsx
@@ -20,8 +20,9 @@ import {
TableHead,
TableHeader,
TableRow,
+ SeverityBadge,
+ StatusFindingBadge,
} from "@/components/shadcn/table";
-import { SeverityBadge, StatusFindingBadge } from "@/components/shadcn/table";
import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state";
import { cn, hasHistoricalFindingFilter } from "@/lib";
import {
@@ -29,9 +30,13 @@ import {
getFindingGroupImpactedCounts,
isFindingGroupMuted,
} from "@/lib/findings-groups";
+import { buildJiraActionLabel } from "@/lib/jira-dispatch-action";
+import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
import { FindingGroupRow } from "@/types";
+import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
+
+import { FloatingSelectionActions } from "../floating-selection-actions";
-import { FloatingMuteButton } from "../floating-mute-button";
import { getColumnFindingResources } from "./column-finding-resources";
import { FindingsSelectionContext } from "./findings-selection-context";
import { ImpactedResourcesCell } from "./impacted-resources-cell";
@@ -111,6 +116,13 @@ export function FindingsGroupDrillDown({
const impactedCounts = getFindingGroupImpactedCounts(group);
const rows = table.getRowModel().rows;
+ const jiraPayload = createJiraDispatchPayload({
+ targetIds: selectedFindingIds,
+ targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
+ findingTitle: group.checkTitle,
+ selectedResourceCount: selectedFindingIds.length,
+ isFindingGroupSelection: true,
+ });
return (
@@ -229,15 +241,22 @@ export function FindingsGroupDrillDown({
- {selectedFindingIds.length > 0 && (
- 0 && jiraPayload && (
+ {
return resolveSelectedFindingIds(selectedFindingIds);
}}
onComplete={handleMuteComplete}
- isBulkOperation
+ isBulkOperation={selectedFindingIds.length > 1}
+ jiraPayload={jiraPayload}
+ jiraLabel={buildJiraActionLabel({
+ findingCount: selectedFindingIds.length,
+ })}
/>
)}
diff --git a/ui/components/findings/table/findings-group-table.test.tsx b/ui/components/findings/table/findings-group-table.test.tsx
index 3d31ac0fb2..9eeecb2921 100644
--- a/ui/components/findings/table/findings-group-table.test.tsx
+++ b/ui/components/findings/table/findings-group-table.test.tsx
@@ -1,13 +1,35 @@
import { render, screen } from "@testing-library/react";
-import type { ReactNode } from "react";
-import { describe, expect, it, vi } from "vitest";
+import userEvent from "@testing-library/user-event";
+import { Fragment, type ReactNode } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { resolveFindingIdsByVisibleGroupResources } from "@/actions/findings/findings-by-resource";
+import type { JiraDispatchModalPayload } from "@/types/jira-dispatch";
import { FindingsGroupTable } from "./findings-group-table";
+const {
+ FloatingSelectionActionsMock,
+ setOnDrillDownMock,
+ triggerOnDrillDownMock,
+} = vi.hoisted(() => {
+ let onDrillDown: ((checkId: string, group: unknown) => void) | undefined;
+
+ return {
+ FloatingSelectionActionsMock: vi.fn((_props: unknown) => null),
+ setOnDrillDownMock: vi.fn(
+ (handler: ((checkId: string, group: unknown) => void) | undefined) => {
+ onDrillDown = handler;
+ },
+ ),
+ triggerOnDrillDownMock: vi.fn((checkId: string, group: unknown) => {
+ onDrillDown?.(checkId, group);
+ }),
+ };
+});
+
vi.mock("next/navigation", () => ({
- useRouter: () => ({
- refresh: vi.fn(),
- }),
+ useRouter: () => ({ refresh: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
usePathname: () => "/findings",
}));
@@ -17,6 +39,8 @@ vi.mock("@/components/shadcn/table", () => ({
data,
toolbarRightContent,
getRowAttributes,
+ onRowSelectionChange,
+ renderAfterRow,
}: {
data?: Array<{ checkId?: string }>;
toolbarRightContent?: ReactNode;
@@ -24,20 +48,49 @@ vi.mock("@/components/shadcn/table", () => ({
index: number;
original: { checkId?: string };
}) => Record;
+ onRowSelectionChange?: (
+ updater: (previous: Record) => Record,
+ ) => void;
+ renderAfterRow?: (row: {
+ index: number;
+ original: { checkId?: string };
+ }) => ReactNode;
}) => (
{toolbarRightContent}
-
10 Total Entries
{(data ?? []).map((original, index) => (
-
- {original.checkId}
-
+
+
+ {original.checkId}
+
+
+ onRowSelectionChange?.((previous) => ({
+ ...previous,
+ [index]: true,
+ }))
+ }
+ >
+ Select {original.checkId}
+
+
+ triggerOnDrillDownMock(original.checkId ?? "", original)
+ }
+ >
+ Expand {original.checkId}
+
+
+
+ {renderAfterRow?.({ index, original })}
+
))}
@@ -64,109 +117,242 @@ vi.mock("@/actions/findings/findings-by-resource", () => ({
}));
vi.mock("./column-finding-groups", () => ({
- getColumnFindingGroups: () => [],
+ getColumnFindingGroups: ({
+ onDrillDown,
+ }: {
+ onDrillDown?: (checkId: string, group: unknown) => void;
+ }) => {
+ setOnDrillDownMock(onDrillDown);
+ return [];
+ },
}));
vi.mock("./inline-resource-container", () => ({
- InlineResourceContainer: () => null,
+ InlineResourceContainer: ({
+ onResourceSelectionChange,
+ }: {
+ onResourceSelectionChange?: (selectedResourceIds: string[]) => void;
+ }) => (
+
+
+ onResourceSelectionChange?.(["finding-1"])}
+ >
+ Select finding-1
+
+
+ onResourceSelectionChange?.(["finding-1", "finding-2"])
+ }
+ >
+ Select findings 1 and 2
+
+
+
+ ),
}));
-vi.mock("../floating-mute-button", () => ({
- FloatingMuteButton: () => null,
+vi.mock("../floating-selection-actions", () => ({
+ FloatingSelectionActions: FloatingSelectionActionsMock,
}));
+function makeGroup(checkId: string, resourcesFail = 2) {
+ return {
+ checkId,
+ checkTitle: `Title ${checkId}`,
+ resourcesFail,
+ resourcesTotal: Math.max(resourcesFail, 1),
+ mutedCount: 0,
+ } as unknown as Parameters
[0]["data"][number];
+}
+
+function getLastFloatingActionsProps(): {
+ jiraPayload: JiraDispatchModalPayload;
+ onBeforeOpen: () => Promise;
+} {
+ const props = FloatingSelectionActionsMock.mock.calls.at(-1)?.[0];
+ expect(props).toBeDefined();
+ return props as unknown as {
+ jiraPayload: JiraDispatchModalPayload;
+ onBeforeOpen: () => Promise;
+ };
+}
+
describe("FindingsGroupTable", () => {
- describe("toolbar", () => {
- it("should render the muted findings checkbox inside the table toolbar", () => {
- // Given
- render(
- ,
- );
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
- // When
- const toolbar = screen.getByTestId("table-toolbar-right");
+ it("renders the muted findings filter in the table toolbar", () => {
+ // Given / When
+ render(
+ ,
+ );
- // Then
- expect(
- screen.getByRole("checkbox", { name: "Include muted findings" }),
- ).toBeInTheDocument();
- expect(toolbar).toHaveTextContent("Include muted findings");
+ // Then
+ expect(
+ screen.getByRole("checkbox", { name: "Include muted findings" }),
+ ).toBeInTheDocument();
+ });
+
+ it("mounts the tour only when finding groups exist", () => {
+ // Given / When
+ const { rerender } = render(
+ ,
+ );
+
+ // Then
+ expect(screen.queryByTestId("onboarding-trigger")).not.toBeInTheDocument();
+ expect(screen.getByTestId("page-ready")).toBeInTheDocument();
+
+ // When
+ rerender(
+ ,
+ );
+
+ // Then
+ expect(screen.getByTestId("onboarding-trigger")).toBeInTheDocument();
+ });
+
+ it("anchors the finding-group tour to the first row only", () => {
+ // Given / When
+ render(
+ ,
+ );
+
+ // Then
+ expect(screen.getByTestId("row-0")).toHaveAttribute(
+ "data-tour-id",
+ "explore-findings-group",
+ );
+ expect(screen.getByTestId("row-1")).not.toHaveAttribute("data-tour-id");
+ });
+
+ it("opens a drillable group from the expanded deep link", () => {
+ // Given / When
+ render(
+ ,
+ );
+
+ // Then
+ expect(
+ screen.getByRole("button", { name: "Select finding-1" }),
+ ).toBeInTheDocument();
+ });
+
+ it("builds separate Jira batches for selected groups and child findings", async () => {
+ // Given
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ // When
+ await user.click(screen.getByRole("button", { name: "Select check-a" }));
+ await user.click(screen.getByRole("button", { name: "Expand check-b" }));
+ await user.click(screen.getByRole("button", { name: "Select finding-1" }));
+
+ // Then
+ expect(getLastFloatingActionsProps().jiraPayload.selection).toEqual({
+ kind: "batches",
+ batches: [
+ {
+ targetIds: ["check-a"],
+ targetType: "check_id",
+ dispatchMode: "grouped",
+ },
+ {
+ targetIds: ["finding-1"],
+ targetType: "finding_id",
+ dispatchMode: "individual",
+ },
+ ],
});
});
- describe("explore-findings tour gating", () => {
- it("does not mount the tour trigger when there are no finding groups", () => {
- // Given an empty table (e.g. a scan is still running)
- render(
- ,
- );
+ it("keeps resource-only Jira selections scoped to the expanded group", async () => {
+ // Given
+ const user = userEvent.setup();
+ render(
+ ,
+ );
- // Then the tour never starts — there is no first-row anchor for the
- // "Open a finding group" step to resolve, which would otherwise throw.
- expect(
- screen.queryByTestId("onboarding-trigger"),
- ).not.toBeInTheDocument();
- // PageReady still signals the navbar that the route's data has loaded.
- expect(screen.getByTestId("page-ready")).toBeInTheDocument();
- });
+ // When
+ await user.click(screen.getByRole("button", { name: "Expand check-a" }));
+ await user.click(
+ screen.getByRole("button", { name: "Select findings 1 and 2" }),
+ );
- it("mounts the tour trigger once at least one finding group exists", () => {
- // Given a populated table
- const data = [{ checkId: "check-a" }] as unknown as Parameters<
- typeof FindingsGroupTable
- >[0]["data"];
-
- render(
- ,
- );
-
- // Then the explore-findings tour is allowed to start.
- expect(screen.getByTestId("onboarding-trigger")).toBeInTheDocument();
+ // Then
+ expect(getLastFloatingActionsProps().jiraPayload).toMatchObject({
+ selection: {
+ kind: "target-list",
+ targetIds: ["finding-1", "finding-2"],
+ targetType: "finding_id",
+ },
+ findingTitle: "Title check-a",
+ isFindingGroupSelection: true,
+ selectedResourceCount: 2,
});
});
- describe("onboarding anchor", () => {
- it("anchors the finding-group tour step to the first row only", () => {
- // Given two finding groups (the tour must point at the first, even if there is one)
- const data = [
- { checkId: "check-a" },
- { checkId: "check-b" },
- ] as unknown as Parameters[0]["data"];
+ it("resolves group selections through the visible-resource query before muting", async () => {
+ // Given
+ vi.mocked(resolveFindingIdsByVisibleGroupResources).mockResolvedValue([
+ "finding-a",
+ "finding-b",
+ ]);
+ const user = userEvent.setup();
+ render(
+ ,
+ );
- render(
- ,
- );
+ // When
+ await user.click(screen.getByRole("button", { name: "Select check-a" }));
+ const resolvedIds = await getLastFloatingActionsProps().onBeforeOpen();
- // Then driver.js resolves `[data-tour-id="explore-findings-group"]` to the first row.
- expect(screen.getByTestId("row-0")).toHaveAttribute(
- "data-tour-id",
- "explore-findings-group",
- );
- expect(screen.getByTestId("row-1")).not.toHaveAttribute("data-tour-id");
+ // Then
+ expect(resolvedIds).toEqual(["finding-a", "finding-b"]);
+ expect(resolveFindingIdsByVisibleGroupResources).toHaveBeenCalledWith({
+ checkId: "check-a",
+ filters: { "filter[severity]": "high" },
+ hasDateOrScanFilter: false,
+ resourceSearch: undefined,
});
});
});
diff --git a/ui/components/findings/table/findings-group-table.tsx b/ui/components/findings/table/findings-group-table.tsx
index 10eeff0b62..8ce23a56fb 100644
--- a/ui/components/findings/table/findings-group-table.tsx
+++ b/ui/components/findings/table/findings-group-table.tsx
@@ -9,11 +9,18 @@ import { CustomCheckboxMutedFindings } from "@/components/filters/custom-checkbo
import { OnboardingTrigger, PageReady } from "@/components/onboarding";
import { DataTable } from "@/components/shadcn/table";
import { canDrillDownFindingGroup } from "@/lib/findings-groups";
+import { buildJiraActionLabel } from "@/lib/jira-dispatch-action";
+import {
+ createJiraBatchSelection,
+ createJiraTargetSelection,
+} from "@/lib/jira-dispatch-selection";
import { getFlowById } from "@/lib/onboarding";
import { createExploreFindingsTourStepHandlers } from "@/lib/tours/explore-findings.tour";
import { FindingGroupRow, MetaDataProps } from "@/types";
+import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
+
+import { FloatingSelectionActions } from "../floating-selection-actions";
-import { FloatingMuteButton } from "../floating-mute-button";
import { getColumnFindingGroups } from "./column-finding-groups";
import { canMuteFindingGroup } from "./finding-group-selection";
import { FindingsSelectionContext } from "./findings-selection-context";
@@ -23,18 +30,42 @@ import {
} from "./inline-resource-container";
const exploreFindingsFlow = getFlowById("explore-findings")!;
+const EMPTY_FINDING_GROUPS: FindingGroupRow[] = [];
-function buildMuteLabel(groupCount: number, resourceCount: number): string {
- const parts: string[] = [];
- if (groupCount > 0) {
- parts.push(`${groupCount} ${groupCount === 1 ? "Group" : "Groups"}`);
- }
- if (resourceCount > 0) {
- parts.push(
- `${resourceCount} ${resourceCount === 1 ? "Resource" : "Resources"}`,
- );
- }
- return `Mute ${parts.join(" and ")}`;
+function buildSelectionSummary(
+ groupCount: number,
+ findingCount: number,
+): string {
+ return `${buildSelectionEntityLabel(groupCount, findingCount)} selected`;
+}
+
+function buildMuteActionLabel(
+ groupCount: number,
+ findingCount: number,
+): string {
+ return `Mute ${buildSelectionEntityLabel(groupCount, findingCount)}`;
+}
+
+function buildSelectionEntityLabel(
+ groupCount: number,
+ findingCount: number,
+): string {
+ const parts = [
+ buildEntityCountLabel(groupCount, "Group", "Groups"),
+ buildEntityCountLabel(findingCount, "Finding", "Findings"),
+ ].filter(Boolean);
+
+ return parts.join(" and ");
+}
+
+function buildEntityCountLabel(
+ count: number,
+ singular: string,
+ plural: string,
+): string | null {
+ if (count === 0) return null;
+
+ return `${count} ${count === 1 ? singular : plural}`;
}
interface FindingsGroupTableProps {
@@ -42,6 +73,7 @@ interface FindingsGroupTableProps {
metadata?: MetaDataProps;
resolvedFilters: Record;
hasHistoricalData: boolean;
+ expandedCheckId?: string;
}
export function FindingsGroupTable({
@@ -49,22 +81,67 @@ export function FindingsGroupTable({
metadata,
resolvedFilters,
hasHistoricalData,
+ expandedCheckId: requestedExpandedCheckId,
}: FindingsGroupTableProps) {
+ const safeData = data ?? EMPTY_FINDING_GROUPS;
+ const requestedGroup = requestedExpandedCheckId
+ ? safeData.find((group) => group.checkId === requestedExpandedCheckId)
+ : undefined;
+ const initialExpandedCheckId =
+ requestedGroup && canDrillDownFindingGroup(requestedGroup)
+ ? requestedGroup.checkId
+ : null;
+
+ return (
+
+ );
+}
+
+interface FindingsGroupTableContentProps {
+ data: FindingGroupRow[];
+ metadata?: MetaDataProps;
+ resolvedFilters: Record;
+ hasHistoricalData: boolean;
+ initialExpandedCheckId: string | null;
+}
+
+const FindingsGroupTableContent = ({
+ data,
+ metadata,
+ resolvedFilters,
+ hasHistoricalData,
+ initialExpandedCheckId,
+}: FindingsGroupTableContentProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const [rowSelection, setRowSelection] = useState({});
- const [expandedCheckId, setExpandedCheckId] = useState(null);
- const [expandedGroup, setExpandedGroup] = useState(
- null,
- );
+ const [selectedExpandedCheckId, setSelectedExpandedCheckId] = useState<
+ string | null
+ >(initialExpandedCheckId);
// Separate input (keystroke) from committed search (Enter) to avoid remounting InlineResourceContainer.
const [resourceSearchInput, setResourceSearchInput] = useState("");
const [resourceSearch, setResourceSearch] = useState("");
const [resourceSelection, setResourceSelection] = useState([]);
const inlineRef = useRef(null);
- const safeData = data ?? [];
- const hasResourceSelection = resourceSelection.length > 0;
+ const safeData = data ?? EMPTY_FINDING_GROUPS;
+ const expandedGroupCandidate = selectedExpandedCheckId
+ ? safeData.find((group) => group.checkId === selectedExpandedCheckId)
+ : undefined;
+ const expandedGroup =
+ expandedGroupCandidate && canDrillDownFindingGroup(expandedGroupCandidate)
+ ? expandedGroupCandidate
+ : null;
+ const expandedCheckId = expandedGroup?.checkId ?? null;
+ const activeResourceSelection = expandedCheckId ? resourceSelection : [];
+ const hasResourceSelection = activeResourceSelection.length > 0;
const filters = resolvedFilters;
// Exclude expanded group from group-level mutes when it has resource selections.
@@ -81,6 +158,70 @@ export function FindingsGroupTable({
.map((idx) => safeData[parseInt(idx)])
.filter(Boolean);
+ const selectedGroupTitle =
+ selectedFindings.length === 1 ? selectedFindings[0]?.checkTitle : undefined;
+ const hasMixedJiraSelection =
+ selectedCheckIds.length > 0 && hasResourceSelection;
+ const jiraGroupSelectionTakesPrecedence = selectedCheckIds.length > 0;
+ const jiraTargetIds = jiraGroupSelectionTakesPrecedence
+ ? selectedCheckIds
+ : activeResourceSelection;
+ const jiraTargetType = jiraGroupSelectionTakesPrecedence
+ ? JIRA_DISPATCH_TARGET.CHECK_ID
+ : JIRA_DISPATCH_TARGET.FINDING_ID;
+ const singleSelectedGroup =
+ selectedCheckIds.length === 1
+ ? selectedFindings.find(
+ (finding) => finding.checkId === selectedCheckIds[0],
+ )
+ : undefined;
+ const selectedJiraResourceCount = jiraGroupSelectionTakesPrecedence
+ ? singleSelectedGroup
+ ? singleSelectedGroup.resourcesFail
+ : selectedCheckIds.length
+ : activeResourceSelection.length;
+ const jiraTitle = hasMixedJiraSelection
+ ? undefined
+ : jiraGroupSelectionTakesPrecedence
+ ? selectedGroupTitle
+ : expandedGroup?.checkTitle;
+ const jiraSelection = hasMixedJiraSelection
+ ? createJiraBatchSelection([
+ {
+ targetIds: selectedCheckIds,
+ targetType: JIRA_DISPATCH_TARGET.CHECK_ID,
+ dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
+ },
+ {
+ targetIds: activeResourceSelection,
+ targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
+ ...(activeResourceSelection.length > 1
+ ? {}
+ : { dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL }),
+ },
+ ])
+ : createJiraTargetSelection(jiraTargetIds, jiraTargetType);
+ const jiraDescription = hasMixedJiraSelection
+ ? `Create Jira issues for ${buildSelectionEntityLabel(
+ selectedCheckIds.length,
+ activeResourceSelection.length,
+ )}.`
+ : undefined;
+ const jiraPayload = jiraSelection
+ ? {
+ selection: jiraSelection,
+ findingTitle: jiraTitle,
+ selectedResourceCount: selectedJiraResourceCount,
+ isFindingGroupSelection:
+ !jiraGroupSelectionTakesPrecedence && Boolean(expandedGroup),
+ description: jiraDescription,
+ }
+ : undefined;
+ const sendToJiraLabel = buildJiraActionLabel({
+ findingGroupCount: selectedCheckIds.length,
+ findingCount: activeResourceSelection.length,
+ });
+
const selectableRowCount = safeData.filter((g) =>
canMuteFindingGroup({
resourcesFail: g.resourcesFail,
@@ -145,16 +286,14 @@ export function FindingsGroupTable({
handleCollapse();
return;
}
- setExpandedCheckId(checkId);
- setExpandedGroup(group);
+ setSelectedExpandedCheckId(checkId);
setResourceSearchInput("");
setResourceSearch("");
setResourceSelection([]);
};
const handleCollapse = () => {
- setExpandedCheckId(null);
- setExpandedGroup(null);
+ setSelectedExpandedCheckId(null);
setResourceSearchInput("");
setResourceSearch("");
setResourceSelection([]);
@@ -254,29 +393,39 @@ export function FindingsGroupTable({
/>
- {(selectedCheckIds.length > 0 || hasResourceSelection) && (
- 0 || hasResourceSelection) && jiraPayload && (
+ {
const [groupIds, resourceIds] = await Promise.all([
selectedCheckIds.length > 0
? resolveGroupMuteIds(selectedCheckIds)
: Promise.resolve([]),
- Promise.resolve(hasResourceSelection ? resourceSelection : []),
+ Promise.resolve(
+ hasResourceSelection ? activeResourceSelection : [],
+ ),
]);
return [...groupIds, ...resourceIds];
}}
onComplete={handleMuteComplete}
isBulkOperation={
- selectedCheckIds.length > 0 || resourceSelection.length > 1
+ selectedCheckIds.length > 0 || activeResourceSelection.length > 1
}
+ jiraPayload={jiraPayload}
+ jiraLabel={sendToJiraLabel}
/>
)}
);
-}
+};
diff --git a/ui/components/findings/table/inline-resource-container.tsx b/ui/components/findings/table/inline-resource-container.tsx
index 91c4f3b225..32ce06cdc1 100644
--- a/ui/components/findings/table/inline-resource-container.tsx
+++ b/ui/components/findings/table/inline-resource-container.tsx
@@ -91,11 +91,7 @@ function ResourceSkeletonRow({
- {/* Status */}
-
-
-
- {/* Resource: name + uid */}
+ {/* Affected failing resource: name + uid */}
diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx
index afedde6c55..858852e6d5 100644
--- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx
+++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx
@@ -665,7 +665,9 @@ describe("ResourceDetailDrawerContent — triage drawer actions", () => {
within(row as HTMLElement).getByRole("button", { name: "Mute" }),
).toBeInTheDocument();
expect(
- within(row as HTMLElement).getByRole("button", { name: "Send to Jira" }),
+ within(row as HTMLElement).getByRole("button", {
+ name: "Send 1 Finding to Jira",
+ }),
).toBeInTheDocument();
});
@@ -702,7 +704,7 @@ describe("ResourceDetailDrawerContent — triage drawer actions", () => {
const row = screen.getByText("EC2 Check").closest("tr");
expect(row).not.toBeNull();
const actionsCell = within(row as HTMLElement)
- .getByRole("button", { name: "Send to Jira" })
+ .getByRole("button", { name: "Send 1 Finding to Jira" })
.closest("td");
// Then
diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx
index 1d2716985f..fb296e50b2 100644
--- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx
+++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx
@@ -21,11 +21,10 @@ import {
type ResourceDrawerFinding,
updateFindingTriage,
} from "@/actions/findings";
+import { JiraDispatchActionItem } from "@/components/findings/jira-dispatch-action-item";
import { MarkdownContainer } from "@/components/findings/markdown-container";
import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
-import { SendToJiraModal } from "@/components/findings/send-to-jira-modal";
import { getComplianceIcon } from "@/components/icons";
-import { JiraIcon } from "@/components/icons/services/IconServices";
import {
Badge,
Button,
@@ -71,15 +70,17 @@ import {
type QueryEditorLanguage,
} from "@/components/shared/query-code-editor";
import { ResourceMetadataPanel } from "@/components/shared/resource-metadata-panel";
-import { getFailingForLabel } from "@/lib/date-utils";
-import { formatDuration } from "@/lib/date-utils";
+import { getFailingForLabel, formatDuration } from "@/lib/date-utils";
import { shouldRefreshAfterTriageUpdate } from "@/lib/finding-triage";
+import { buildJiraActionLabel } from "@/lib/jira-dispatch-action";
+import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
import { buildFindingAnalysisPrompt } from "@/lib/lighthouse/prompts";
import { getRegionFlag } from "@/lib/region-flags";
import { getRecommendationLinkLabel } from "@/lib/vulnerability-references";
import type { ComplianceOverviewData } from "@/types/compliance";
import type { FindingResourceRow } from "@/types/findings-table";
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
+import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
import { Muted } from "../../muted";
import { DeltaIndicator } from "../delta-indicator";
@@ -89,6 +90,7 @@ import {
FindingTriageStatusCell,
} from "../finding-triage-cells";
import { DeltaValues, NotificationIndicator } from "../notification-indicator";
+
import { ResourceDetailSkeleton } from "./resource-detail-skeleton";
import type { CheckMeta } from "./use-resource-detail-drawer";
@@ -361,7 +363,6 @@ export function ResourceDetailDrawerContent({
}: ResourceDetailDrawerContentProps) {
const searchParams = useSearchParams();
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
- const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
const [resolvingFramework, setResolvingFramework] = useState
(
null,
);
@@ -411,6 +412,11 @@ export function ResourceDetailDrawerContent({
// During carousel navigation we only trust row-backed data until the next
// finding payload is fully ready, otherwise stale details flash briefly.
const f = isNavigating ? null : currentFinding;
+ const jiraPayload = createJiraDispatchPayload({
+ targetIds: f ? [f.id] : [],
+ targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
+ findingTitle: checkMeta.checkTitle,
+ });
const isCheckMetaFresh =
!currentResource?.checkId || currentResource.checkId === checkMeta.checkId;
const showCheckMetaContent = !isNavigating || isCheckMetaFresh;
@@ -545,15 +551,6 @@ export function ResourceDetailDrawerContent({
}}
/>
)}
- {f && (
-
- )}
-
{/* Header: keep row-backed badges visible; only hide stale check metadata */}
@@ -867,10 +864,9 @@ export function ResourceDetailDrawerContent({
disabled={f.isMuted}
onSelect={() => setIsMuteModalOpen(true)}
/>
-
}
- label="Send to Jira"
- onSelect={() => setIsJiraModalOpen(true)}
+
{externalResourceTarget && (
Promise;
}) {
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
- const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
const isMuted = finding.isMuted || isOptimisticallyMuted;
+ const jiraPayload = createJiraDispatchPayload({
+ targetIds: [finding.id],
+ targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
+ findingTitle: finding.checkTitle,
+ });
const findingUrl = `/findings?filter%5Bcheck_id__in%5D=${encodeURIComponent(finding.checkId)}&filter%5Bmuted%5D=include`;
@@ -1585,12 +1585,6 @@ function OtherFindingRow({
}}
/>
)}
-
window.open(findingUrl, "_blank", "noopener,noreferrer")}
@@ -1659,10 +1653,9 @@ function OtherFindingRow({
disabled={isMuted}
onSelect={() => setIsMuteModalOpen(true)}
/>
- }
- label="Send to Jira"
- onSelect={() => setIsJiraModalOpen(true)}
+
diff --git a/ui/components/graphs/line-chart.test.tsx b/ui/components/graphs/line-chart.test.tsx
new file mode 100644
index 0000000000..3febcd46f3
--- /dev/null
+++ b/ui/components/graphs/line-chart.test.tsx
@@ -0,0 +1,42 @@
+import { describe, expect, it } from "vitest";
+
+import { formatYAxisTick } from "./line-chart.utils";
+
+describe("formatYAxisTick", () => {
+ describe("when findings counts are large", () => {
+ it("should compact six-digit values so Y-axis labels do not overflow", () => {
+ // Given
+ const tickValue = 150000;
+
+ // When
+ const formattedValue = formatYAxisTick(tickValue);
+
+ // Then
+ expect(formattedValue).toBe("150K");
+ });
+
+ it("should compact million-scale values", () => {
+ // Given
+ const tickValue = 1200000;
+
+ // When
+ const formattedValue = formatYAxisTick(tickValue);
+
+ // Then
+ expect(formattedValue).toBe("1.2M");
+ });
+ });
+
+ describe("when findings counts are small", () => {
+ it("should keep values below 1000 readable without compact notation", () => {
+ // Given
+ const tickValue = 999;
+
+ // When
+ const formattedValue = formatYAxisTick(tickValue);
+
+ // Then
+ expect(formattedValue).toBe("999");
+ });
+ });
+});
diff --git a/ui/components/graphs/line-chart.tsx b/ui/components/graphs/line-chart.tsx
index eab0551bb6..1ffdd19dc3 100644
--- a/ui/components/graphs/line-chart.tsx
+++ b/ui/components/graphs/line-chart.tsx
@@ -17,6 +17,7 @@ import {
ChartTooltip,
} from "@/components/shadcn/chart/Chart";
+import { formatYAxisTick } from "./line-chart.utils";
import { AlertPill } from "./shared/alert-pill";
import { ChartLegend } from "./shared/chart-legend";
import { CustomActiveDot, PointClickData } from "./shared/custom-active-dot";
@@ -222,6 +223,8 @@ export function LineChart({
tickLine={false}
axisLine={false}
tickMargin={8}
+ tickFormatter={formatYAxisTick}
+ width={56}
padding={{ top: 20 }}
tick={{
fill: "var(--color-text-neutral-secondary)",
diff --git a/ui/components/graphs/line-chart.utils.ts b/ui/components/graphs/line-chart.utils.ts
new file mode 100644
index 0000000000..b219529c24
--- /dev/null
+++ b/ui/components/graphs/line-chart.utils.ts
@@ -0,0 +1,8 @@
+const Y_AXIS_TICK_FORMATTER = new Intl.NumberFormat("en-US", {
+ notation: "compact",
+ maximumFractionDigits: 1,
+});
+
+export function formatYAxisTick(value: number) {
+ return Y_AXIS_TICK_FORMATTER.format(value);
+}
diff --git a/ui/components/icons/prowler/ProwlerIcons.test.tsx b/ui/components/icons/prowler/ProwlerIcons.test.tsx
index 5fca4420e4..b75c5402af 100644
--- a/ui/components/icons/prowler/ProwlerIcons.test.tsx
+++ b/ui/components/icons/prowler/ProwlerIcons.test.tsx
@@ -11,7 +11,7 @@ describe("ProwlerBrand", () => {
it("should render the Local Server lockups outside Cloud", () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
render(
);
@@ -30,7 +30,7 @@ describe("ProwlerBrand", () => {
it("should render the Prowler Cloud lockups in Cloud", () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
render(
);
diff --git a/ui/components/integrations/jira/jira-integration-card.tsx b/ui/components/integrations/jira/jira-integration-card.tsx
index 4629215f5e..b99cf7cdbe 100644
--- a/ui/components/integrations/jira/jira-integration-card.tsx
+++ b/ui/components/integrations/jira/jira-integration-card.tsx
@@ -4,11 +4,9 @@ import { SettingsIcon } from "lucide-react";
import Link from "next/link";
import { JiraIcon } from "@/components/icons/services/IconServices";
-import { Button } from "@/components/shadcn";
+import { Button, Card, CardContent, CardHeader } from "@/components/shadcn";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
-import { Card, CardContent, CardHeader } from "../../shadcn";
-
export const JiraIntegrationCard = () => {
return (
diff --git a/ui/components/integrations/jira/jira-integrations-manager.tsx b/ui/components/integrations/jira/jira-integrations-manager.tsx
index c3f3a1412f..e63b17dac3 100644
--- a/ui/components/integrations/jira/jira-integrations-manager.tsx
+++ b/ui/components/integrations/jira/jira-integrations-manager.tsx
@@ -15,15 +15,19 @@ import {
IntegrationCardHeader,
IntegrationSkeleton,
} from "@/components/integrations/shared";
-import { Button } from "@/components/shadcn";
-import { useToast } from "@/components/shadcn";
+import {
+ Button,
+ useToast,
+ Card,
+ CardContent,
+ CardHeader,
+} from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { DataTablePagination } from "@/components/shadcn/table/data-table-pagination";
import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper";
import { MetaDataProps } from "@/types";
import { IntegrationProps } from "@/types/integrations";
-import { Card, CardContent, CardHeader } from "../../shadcn";
import { JiraIntegrationForm } from "./jira-integration-form";
interface JiraIntegrationsManagerProps {
diff --git a/ui/components/integrations/s3/s3-integration-card.tsx b/ui/components/integrations/s3/s3-integration-card.tsx
index 7e2be1890d..be173c5609 100644
--- a/ui/components/integrations/s3/s3-integration-card.tsx
+++ b/ui/components/integrations/s3/s3-integration-card.tsx
@@ -4,11 +4,9 @@ import { SettingsIcon } from "lucide-react";
import Link from "next/link";
import { AmazonS3Icon } from "@/components/icons/services/IconServices";
-import { Button } from "@/components/shadcn";
+import { Button, Card, CardContent, CardHeader } from "@/components/shadcn";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
-import { Card, CardContent, CardHeader } from "../../shadcn";
-
export const S3IntegrationCard = () => {
return (
diff --git a/ui/components/integrations/s3/s3-integration-form.tsx b/ui/components/integrations/s3/s3-integration-form.tsx
index 555e7d0d1f..30587fc3b3 100644
--- a/ui/components/integrations/s3/s3-integration-form.tsx
+++ b/ui/components/integrations/s3/s3-integration-form.tsx
@@ -13,8 +13,7 @@ import {
ProviderTypeIcon,
} from "@/components/icons/providers-badge/provider-type-icon";
import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form";
-import { Separator } from "@/components/shadcn";
-import { useToast } from "@/components/shadcn";
+import { Separator, useToast } from "@/components/shadcn";
import { CustomInput } from "@/components/shadcn/custom";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
import {
@@ -26,6 +25,7 @@ import {
import { FormButtons } from "@/components/shadcn/form/form-buttons";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { getAWSCredentialsTemplateLinks } from "@/lib";
+import { isCloud } from "@/lib/shared/env";
import type { AWSCredentialsRole } from "@/types";
import type { IntegrationProps } from "@/types/integrations";
import {
@@ -78,10 +78,9 @@ export const S3IntegrationForm = ({
const isEditingConfig = editMode === "configuration";
const isEditingCredentials = editMode === "credentials";
- const defaultCredentialsType =
- process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"
- ? "aws-sdk-default"
- : "access-secret-key";
+ const defaultCredentialsType = isCloud()
+ ? "aws-sdk-default"
+ : "access-secret-key";
const form = useForm({
resolver: zodResolver(
diff --git a/ui/components/integrations/s3/s3-integrations-manager.tsx b/ui/components/integrations/s3/s3-integrations-manager.tsx
index 1e75d1b43c..03ec525abc 100644
--- a/ui/components/integrations/s3/s3-integrations-manager.tsx
+++ b/ui/components/integrations/s3/s3-integrations-manager.tsx
@@ -15,8 +15,13 @@ import {
IntegrationCardHeader,
IntegrationSkeleton,
} from "@/components/integrations/shared";
-import { Button } from "@/components/shadcn";
-import { useToast } from "@/components/shadcn";
+import {
+ Button,
+ useToast,
+ Card,
+ CardContent,
+ CardHeader,
+} from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { DataTablePagination } from "@/components/shadcn/table/data-table-pagination";
import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper";
@@ -24,7 +29,6 @@ import { MetaDataProps } from "@/types";
import { IntegrationProps } from "@/types/integrations";
import { ProviderProps } from "@/types/providers";
-import { Card, CardContent, CardHeader } from "../../shadcn";
import { S3IntegrationForm } from "./s3-integration-form";
interface S3IntegrationsManagerProps {
diff --git a/ui/components/integrations/saml/saml-config-form.tsx b/ui/components/integrations/saml/saml-config-form.tsx
index db713252a8..82d5fccfd5 100644
--- a/ui/components/integrations/saml/saml-config-form.tsx
+++ b/ui/components/integrations/saml/saml-config-form.tsx
@@ -12,8 +12,13 @@ import { z } from "zod";
import { createSamlConfig, updateSamlConfig } from "@/actions/integrations";
import { AddIcon } from "@/components/icons";
-import { Button, Card, CardContent, CardHeader } from "@/components/shadcn";
-import { useToast } from "@/components/shadcn";
+import {
+ Button,
+ Card,
+ CardContent,
+ CardHeader,
+ useToast,
+} from "@/components/shadcn";
import { CodeSnippet } from "@/components/shadcn/code-snippet/code-snippet";
import { CustomServerInput } from "@/components/shadcn/custom";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
diff --git a/ui/components/integrations/security-hub/security-hub-integration-card.tsx b/ui/components/integrations/security-hub/security-hub-integration-card.tsx
index 4003f8c286..3861702fe0 100644
--- a/ui/components/integrations/security-hub/security-hub-integration-card.tsx
+++ b/ui/components/integrations/security-hub/security-hub-integration-card.tsx
@@ -4,11 +4,9 @@ import { SettingsIcon } from "lucide-react";
import Link from "next/link";
import { AWSSecurityHubIcon } from "@/components/icons/services/IconServices";
-import { Button } from "@/components/shadcn";
+import { Button, Card, CardContent, CardHeader } from "@/components/shadcn";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
-import { Card, CardContent, CardHeader } from "../../shadcn";
-
export const SecurityHubIntegrationCard = () => {
return (
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 b7fac10706..24e88b744f 100644
--- a/ui/components/integrations/security-hub/security-hub-integration-form.tsx
+++ b/ui/components/integrations/security-hub/security-hub-integration-form.tsx
@@ -12,8 +12,7 @@ import {
ProviderTypeIcon,
} from "@/components/icons/providers-badge/provider-type-icon";
import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form";
-import { Checkbox, Separator } from "@/components/shadcn";
-import { useToast } from "@/components/shadcn";
+import { Checkbox, Separator, useToast } from "@/components/shadcn";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
import {
Form,
diff --git a/ui/components/integrations/security-hub/security-hub-integrations-manager.tsx b/ui/components/integrations/security-hub/security-hub-integrations-manager.tsx
index 34c2d9e303..c0c5f9f02e 100644
--- a/ui/components/integrations/security-hub/security-hub-integrations-manager.tsx
+++ b/ui/components/integrations/security-hub/security-hub-integrations-manager.tsx
@@ -15,8 +15,14 @@ import {
IntegrationCardHeader,
IntegrationSkeleton,
} from "@/components/integrations/shared";
-import { Badge, Button } from "@/components/shadcn";
-import { useToast } from "@/components/shadcn";
+import {
+ Badge,
+ Button,
+ useToast,
+ Card,
+ CardContent,
+ CardHeader,
+} from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { DataTablePagination } from "@/components/shadcn/table/data-table-pagination";
import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper";
@@ -24,7 +30,6 @@ import { MetaDataProps } from "@/types";
import { IntegrationProps } from "@/types/integrations";
import { ProviderProps } from "@/types/providers";
-import { Card, CardContent, CardHeader } from "../../shadcn";
import { SecurityHubIntegrationForm } from "./security-hub-integration-form";
interface SecurityHubIntegrationsManagerProps {
diff --git a/ui/components/integrations/shared/link-card.tsx b/ui/components/integrations/shared/link-card.tsx
index ceb74d6a1b..212c95ed39 100644
--- a/ui/components/integrations/shared/link-card.tsx
+++ b/ui/components/integrations/shared/link-card.tsx
@@ -3,11 +3,9 @@
import { ExternalLinkIcon, LucideIcon } from "lucide-react";
import Link from "next/link";
-import { Button } from "@/components/shadcn";
+import { Button, Card, CardContent, CardHeader } from "@/components/shadcn";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
-import { Card, CardContent, CardHeader } from "../../shadcn";
-
interface LinkCardProps {
icon: LucideIcon;
title: string;
diff --git a/ui/components/invitations/forms/delete-form.tsx b/ui/components/invitations/forms/delete-form.tsx
index 58618b9dc6..e34a5e30bd 100644
--- a/ui/components/invitations/forms/delete-form.tsx
+++ b/ui/components/invitations/forms/delete-form.tsx
@@ -7,8 +7,7 @@ import * as z from "zod";
import { revokeInvite } from "@/actions/invitations/invitation";
import { DeleteIcon } from "@/components/icons";
-import { Button } from "@/components/shadcn";
-import { useToast } from "@/components/shadcn";
+import { Button, useToast } from "@/components/shadcn";
import { Form } from "@/components/shadcn/form";
const formSchema = z.object({
diff --git a/ui/components/invitations/forms/edit-form.tsx b/ui/components/invitations/forms/edit-form.tsx
index b8bfaf6f70..bbfc57b2fb 100644
--- a/ui/components/invitations/forms/edit-form.tsx
+++ b/ui/components/invitations/forms/edit-form.tsx
@@ -5,7 +5,7 @@ import { Controller, useForm } from "react-hook-form";
import * as z from "zod";
import { updateInvite } from "@/actions/invitations/invitation";
-import { useToast } from "@/components/shadcn";
+import { useToast, Card, CardContent } from "@/components/shadcn";
import { CustomInput } from "@/components/shadcn/custom";
import { Form, FormButtons } from "@/components/shadcn/form";
import {
@@ -17,8 +17,6 @@ import {
} from "@/components/shadcn/select/select";
import { editInviteFormSchema } from "@/types";
-import { Card, CardContent } from "../../shadcn";
-
export const EditForm = ({
invitationId,
invitationEmail,
diff --git a/ui/components/invitations/workflow/forms/send-invitation-form.tsx b/ui/components/invitations/workflow/forms/send-invitation-form.tsx
index 67e6938d4a..ce98cfdc97 100644
--- a/ui/components/invitations/workflow/forms/send-invitation-form.tsx
+++ b/ui/components/invitations/workflow/forms/send-invitation-form.tsx
@@ -7,8 +7,7 @@ import { Controller, useForm } from "react-hook-form";
import * as z from "zod";
import { sendInvite } from "@/actions/invitations/invitation";
-import { Button } from "@/components/shadcn";
-import { useToast } from "@/components/shadcn";
+import { Button, useToast } from "@/components/shadcn";
import { CustomInput } from "@/components/shadcn/custom";
import { Form } from "@/components/shadcn/form";
import {
diff --git a/ui/components/invitations/workflow/vertical-steps.tsx b/ui/components/invitations/workflow/vertical-steps.tsx
index 17abec63d2..79fb77678c 100644
--- a/ui/components/invitations/workflow/vertical-steps.tsx
+++ b/ui/components/invitations/workflow/vertical-steps.tsx
@@ -2,19 +2,18 @@
import { useControlledState } from "@react-stately/utils";
import { domAnimation, LazyMotion, m } from "framer-motion";
-import type { ComponentProps } from "react";
-import React from "react";
+import { forwardRef, useMemo } from "react";
+import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
import { cn } from "@/lib/utils";
export type VerticalStepProps = {
className?: string;
- description?: React.ReactNode;
- title?: React.ReactNode;
+ description?: ReactNode;
+ title?: ReactNode;
};
-export interface VerticalStepsProps
- extends React.HTMLAttributes {
+export interface VerticalStepsProps extends HTMLAttributes {
/**
* An array of steps.
*
@@ -89,10 +88,7 @@ function CheckIcon(props: ComponentProps<"svg">) {
);
}
-export const VerticalSteps = React.forwardRef<
- HTMLButtonElement,
- VerticalStepsProps
->(
+export const VerticalSteps = forwardRef(
(
{
color = "primary",
@@ -113,7 +109,7 @@ export const VerticalSteps = React.forwardRef<
onStepChange,
);
- const colors = React.useMemo(() => {
+ const colors = useMemo(() => {
let userColor;
let fgColor;
diff --git a/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx b/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx
index 54dedad07c..d17432e78c 100644
--- a/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx
+++ b/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx
@@ -65,7 +65,7 @@ describe("AppSidebarContent", () => {
it("shares the brand, Launch Scan action and Local Server Cloud affordances", async () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
vi.stubEnv("NEXT_PUBLIC_PROWLER_RELEASE_VERSION", "5.8.0");
const user = userEvent.setup();
@@ -91,7 +91,7 @@ describe("AppSidebarContent", () => {
it("keeps the existing Lighthouse chat sidebar in Cloud Chat mode", () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
useAppSidebarMode.setState({ mode: APP_SIDEBAR_MODE.CHAT });
// When
@@ -109,7 +109,7 @@ describe("AppSidebarContent", () => {
it("opens the current scan modal instead of navigating from the scans route", async () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
pathnameValue.current = "/scans";
const user = userEvent.setup();
diff --git a/ui/components/layout/app-sidebar/app-sidebar-content.tsx b/ui/components/layout/app-sidebar/app-sidebar-content.tsx
index ca2b28949d..8d5e869793 100644
--- a/ui/components/layout/app-sidebar/app-sidebar-content.tsx
+++ b/ui/components/layout/app-sidebar/app-sidebar-content.tsx
@@ -37,7 +37,7 @@ export function AppSidebarContent({ onSelect }: AppSidebarContentProps) {
return (
-
+
{
it("groups the Local Server navigation without losing available features", () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
const sections = getNavigationConfig({
@@ -71,7 +71,7 @@ describe("getNavigationConfig", () => {
it("models Local Server Cloud features as contextual upgrade actions", () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
const children = getConfigurationChildren();
@@ -108,7 +108,7 @@ describe("getNavigationConfig", () => {
it("uses Cloud destinations and current New badges", () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
const sections = getNavigationConfig({
@@ -148,7 +148,7 @@ describe("getNavigationConfig", () => {
it("keeps the Cloud Billing destination for users with billing permission", () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
const permissions = {
manage_billing: true,
} as RolePermissionAttributes;
@@ -178,7 +178,7 @@ describe("getNavigationConfig", () => {
const permissions = {
manage_billing: false,
} as RolePermissionAttributes;
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
const cloudItems = getNavigationConfig({
@@ -193,7 +193,7 @@ describe("getNavigationConfig", () => {
cloudBillingEnabled: false,
permissions: { ...permissions, manage_billing: true },
}).flatMap((section) => section.items);
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
const localItems = getNavigationConfig({
pathname: "/",
apiDocsUrl: null,
@@ -211,7 +211,7 @@ describe("getNavigationConfig", () => {
it("keeps environment-specific API documentation destinations", () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
const localApiReference = getNavigationConfig({
@@ -221,7 +221,7 @@ describe("getNavigationConfig", () => {
.flatMap((section) => section.items)
.find((item) => item.label === "API Reference");
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
const cloudApiReference = getNavigationConfig({
pathname: "/",
apiDocsUrl: "https://ignored.example/docs",
@@ -240,7 +240,7 @@ describe("getNavigationConfig", () => {
it("omits the Local Server API reference when no URL is configured", () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
const items = getNavigationConfig({
@@ -256,7 +256,7 @@ describe("getNavigationConfig", () => {
it("filters navigation by required permission after visible copy changes", () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
const sections = getNavigationConfig({
pathname: "/integrations",
apiDocsUrl: null,
@@ -294,7 +294,7 @@ describe("getNavigationConfig", () => {
it("keeps navigation when the required permission is granted", () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
const permissions = {
manage_integrations: true,
} as RolePermissionAttributes;
@@ -320,7 +320,7 @@ describe("getNavigationConfig", () => {
it("matches complete route segments without stealing nested settings routes", () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
const scanDetails = getNavigationConfig({
diff --git a/ui/components/layout/main-layout/main-layout.test.tsx b/ui/components/layout/main-layout/main-layout.test.tsx
index 26ebcc55e6..6198f0daf9 100644
--- a/ui/components/layout/main-layout/main-layout.test.tsx
+++ b/ui/components/layout/main-layout/main-layout.test.tsx
@@ -11,6 +11,10 @@ vi.mock("@/components/shared/cloud-upgrade-modal", () => ({
CloudUpgradeModal: () =>
,
}));
+vi.mock("@/components/findings/jira-dispatch-modal-host", () => ({
+ JiraDispatchModalHost: () =>
,
+}));
+
describe("MainLayout", () => {
it("mounts the shared Cloud upgrade modal with page content", () => {
render(
@@ -20,6 +24,7 @@ describe("MainLayout", () => {
);
expect(screen.getByTestId("cloud-upgrade-modal")).toBeInTheDocument();
+ expect(screen.getByTestId("jira-dispatch-modal-host")).toBeInTheDocument();
expect(screen.getByTestId("sidebar")).toBeInTheDocument();
expect(screen.getByText("Page content")).toBeVisible();
expect(screen.getByRole("main")).toBeVisible();
diff --git a/ui/components/layout/main-layout/main-layout.tsx b/ui/components/layout/main-layout/main-layout.tsx
index 09141c8daa..68193326ed 100644
--- a/ui/components/layout/main-layout/main-layout.tsx
+++ b/ui/components/layout/main-layout/main-layout.tsx
@@ -3,6 +3,7 @@
import { usePathname } from "next/navigation";
import { type ReactNode, Suspense } from "react";
+import { JiraDispatchModalHost } from "@/components/findings/jira-dispatch-modal-host";
import { AppSidebar } from "@/components/layout/app-sidebar";
import { CloudUpgradeModal } from "@/components/shared/cloud-upgrade-modal";
import { useMediaQuery } from "@/hooks/use-media-query";
@@ -38,6 +39,7 @@ export default function MainLayout({ children }: { children: ReactNode }) {
+
is the reference for the app's (container-query)
// breakpoints, so pushing it with the side panel re-evaluates them.
diff --git a/ui/components/layout/nav-bar/navbar-client.test.tsx b/ui/components/layout/nav-bar/navbar-client.test.tsx
index c93ed3f6e5..5be780cc30 100644
--- a/ui/components/layout/nav-bar/navbar-client.test.tsx
+++ b/ui/components/layout/nav-bar/navbar-client.test.tsx
@@ -67,7 +67,7 @@ describe("NavbarClient", () => {
navigationMocks.searchParams = new URLSearchParams();
window.localStorage.clear();
// Replay icon is Cloud-only.
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
// Default: the current route's content has loaded, so the icon is enabled.
usePageReadyStore.setState({ readyPath: "/findings" });
useSidePanelStore.setState({
@@ -223,7 +223,7 @@ describe("NavbarClient", () => {
});
it("hides the replay icon entirely in self-hosted (OSS) deployments", () => {
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
{
it("hides the Lighthouse AI side-panel trigger in self-hosted (OSS) deployments", () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
render( );
diff --git a/ui/components/lighthouse-v1/chat.tsx b/ui/components/lighthouse-v1/chat.tsx
index 851762b8f5..72cf6eac28 100644
--- a/ui/components/lighthouse-v1/chat.tsx
+++ b/ui/components/lighthouse-v1/chat.tsx
@@ -34,8 +34,8 @@ import {
CardHeader,
CardTitle,
Combobox,
+ useToast,
} from "@/components/shadcn";
-import { useToast } from "@/components/shadcn";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
import { useMountEffect } from "@/hooks/use-mount-effect";
import type { LighthouseProvider } from "@/types/lighthouse-v1";
diff --git a/ui/components/lighthouse-v1/lighthouse-settings.tsx b/ui/components/lighthouse-v1/lighthouse-settings.tsx
index 825786d4d6..14c534a278 100644
--- a/ui/components/lighthouse-v1/lighthouse-settings.tsx
+++ b/ui/components/lighthouse-v1/lighthouse-settings.tsx
@@ -17,8 +17,8 @@ import {
CardContent,
CardHeader,
CardTitle,
+ useToast,
} from "@/components/shadcn";
-import { useToast } from "@/components/shadcn";
import { CustomTextarea } from "@/components/shadcn/custom";
import { Form } from "@/components/shadcn/form";
diff --git a/ui/components/manage-groups/forms/add-group-form.tsx b/ui/components/manage-groups/forms/add-group-form.tsx
index 886d462e14..251b7b11d7 100644
--- a/ui/components/manage-groups/forms/add-group-form.tsx
+++ b/ui/components/manage-groups/forms/add-group-form.tsx
@@ -5,8 +5,7 @@ import { Controller, useForm } from "react-hook-form";
import * as z from "zod";
import { createProviderGroup } from "@/actions/manage-groups";
-import { Button, Separator } from "@/components/shadcn";
-import { useToast } from "@/components/shadcn";
+import { Button, Separator, useToast } from "@/components/shadcn";
import { CustomInput } from "@/components/shadcn/custom";
import { Form } from "@/components/shadcn/form";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
diff --git a/ui/components/manage-groups/forms/delete-group-form.tsx b/ui/components/manage-groups/forms/delete-group-form.tsx
index 6a2a035875..c3f04fed94 100644
--- a/ui/components/manage-groups/forms/delete-group-form.tsx
+++ b/ui/components/manage-groups/forms/delete-group-form.tsx
@@ -8,8 +8,7 @@ import * as z from "zod";
import { deleteProviderGroup } from "@/actions/manage-groups/manage-groups";
import { DeleteIcon } from "@/components/icons";
-import { Button } from "@/components/shadcn";
-import { useToast } from "@/components/shadcn";
+import { Button, useToast } from "@/components/shadcn";
import { Form } from "@/components/shadcn/form";
const formSchema = z.object({
diff --git a/ui/components/manage-groups/forms/edit-group-form.tsx b/ui/components/manage-groups/forms/edit-group-form.tsx
index 7db75aef2b..997c92e370 100644
--- a/ui/components/manage-groups/forms/edit-group-form.tsx
+++ b/ui/components/manage-groups/forms/edit-group-form.tsx
@@ -7,8 +7,7 @@ import { Controller, useForm } from "react-hook-form";
import * as z from "zod";
import { updateProviderGroup } from "@/actions/manage-groups/manage-groups";
-import { Button, Separator } from "@/components/shadcn";
-import { useToast } from "@/components/shadcn";
+import { Button, Separator, useToast } from "@/components/shadcn";
import { CustomInput } from "@/components/shadcn/custom";
import { Form } from "@/components/shadcn/form";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
diff --git a/ui/components/onboarding/__tests__/onboarding-trigger.test.tsx b/ui/components/onboarding/__tests__/onboarding-trigger.test.tsx
index ca9f2cd106..e0f040f832 100644
--- a/ui/components/onboarding/__tests__/onboarding-trigger.test.tsx
+++ b/ui/components/onboarding/__tests__/onboarding-trigger.test.tsx
@@ -84,7 +84,7 @@ describe("OnboardingTrigger", () => {
useDriverTourMock.mockClear();
capturedOnClosed = undefined;
// Trigger only resolves in cloud.
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
searchParamsValue = new URLSearchParams();
sliceState = {
active: false,
@@ -196,7 +196,7 @@ describe("OnboardingTrigger", () => {
describe("in self-hosted (OSS) deployments", () => {
it("renders null and never starts the tour, even with a matching param", async () => {
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue = new URLSearchParams("onboarding=add-provider");
const { container } = render(
@@ -208,7 +208,7 @@ describe("OnboardingTrigger", () => {
});
it("ignores an active sequence slice in OSS", async () => {
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
setSlice({
active: true,
currentFlowId: "add-provider",
diff --git a/ui/components/providers/forms/delete-form.tsx b/ui/components/providers/forms/delete-form.tsx
index 1547e9f83d..45e8abde73 100644
--- a/ui/components/providers/forms/delete-form.tsx
+++ b/ui/components/providers/forms/delete-form.tsx
@@ -7,8 +7,7 @@ import * as z from "zod";
import { deleteProvider } from "@/actions/providers";
import { DeleteIcon } from "@/components/icons";
-import { Button } from "@/components/shadcn";
-import { useToast } from "@/components/shadcn";
+import { Button, useToast } from "@/components/shadcn";
import { Form } from "@/components/shadcn/form";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
diff --git a/ui/components/providers/forms/delete-organization-form.tsx b/ui/components/providers/forms/delete-organization-form.tsx
index 9d17e4251c..b7c90570ef 100644
--- a/ui/components/providers/forms/delete-organization-form.tsx
+++ b/ui/components/providers/forms/delete-organization-form.tsx
@@ -7,8 +7,7 @@ import {
deleteOrganizationalUnit,
} from "@/actions/organizations/organizations";
import { DeleteIcon } from "@/components/icons";
-import { Button } from "@/components/shadcn";
-import { useToast } from "@/components/shadcn";
+import { Button, useToast } from "@/components/shadcn";
import {
PROVIDERS_GROUP_KIND,
ProvidersGroupKind,
diff --git a/ui/components/providers/forms/edit-name-form.tsx b/ui/components/providers/forms/edit-name-form.tsx
index b6d937baf6..511744527c 100644
--- a/ui/components/providers/forms/edit-name-form.tsx
+++ b/ui/components/providers/forms/edit-name-form.tsx
@@ -4,8 +4,7 @@ import type { Dispatch, FormEvent, SetStateAction } from "react";
import { useState } from "react";
import { SaveIcon } from "@/components/icons";
-import { Button } from "@/components/shadcn";
-import { useToast } from "@/components/shadcn";
+import { Button, useToast } from "@/components/shadcn";
import { Input } from "@/components/shadcn/input/input";
interface EditNameFormProps {
diff --git a/ui/components/providers/organizations/aws-method-selector.test.tsx b/ui/components/providers/organizations/aws-method-selector.test.tsx
index 9279e8dfb2..7ea1d9a95e 100644
--- a/ui/components/providers/organizations/aws-method-selector.test.tsx
+++ b/ui/components/providers/organizations/aws-method-selector.test.tsx
@@ -15,7 +15,7 @@ describe("AwsMethodSelector", () => {
it("opens the AWS Organizations upgrade in Local Server", async () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
const user = userEvent.setup();
const onSelectOrganizations = vi.fn();
diff --git a/ui/components/providers/organizations/hooks/use-org-account-selection-flow.ts b/ui/components/providers/organizations/hooks/use-org-account-selection-flow.ts
index a5332fdfd5..ffe3b0d089 100644
--- a/ui/components/providers/organizations/hooks/use-org-account-selection-flow.ts
+++ b/ui/components/providers/organizations/hooks/use-org-account-selection-flow.ts
@@ -26,6 +26,7 @@ import {
pollConnectionTask,
runWithConcurrencyLimit,
} from "../org-account-selection.utils";
+
import { extractErrorMessage } from "./error-utils";
interface SelectionState {
diff --git a/ui/components/providers/organizations/org-setup-form.tsx b/ui/components/providers/organizations/org-setup-form.tsx
index 8b2870e998..85903b76d2 100644
--- a/ui/components/providers/organizations/org-setup-form.tsx
+++ b/ui/components/providers/organizations/org-setup-form.tsx
@@ -486,7 +486,7 @@ export function OrgSetupForm({
)}
{!isOrgUnitIdValid && (
-
+
Enter a valid Organizational Unit or Root ID above to enable
deployment.
diff --git a/ui/components/providers/scan-config/manage-scan-config-modal.tsx b/ui/components/providers/scan-config/manage-scan-config-modal.tsx
index d1d4d6471c..34da9cb056 100644
--- a/ui/components/providers/scan-config/manage-scan-config-modal.tsx
+++ b/ui/components/providers/scan-config/manage-scan-config-modal.tsx
@@ -3,8 +3,7 @@
import { useState } from "react";
import { setScanConfigurationProviders } from "@/actions/scan-configurations";
-import { Button } from "@/components/shadcn";
-import { useToast } from "@/components/shadcn";
+import { Button, useToast } from "@/components/shadcn";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
import { Modal } from "@/components/shadcn/modal";
import {
diff --git a/ui/components/providers/table/column-providers.tsx b/ui/components/providers/table/column-providers.tsx
index 2590b5669e..49ea8c265f 100644
--- a/ui/components/providers/table/column-providers.tsx
+++ b/ui/components/providers/table/column-providers.tsx
@@ -31,6 +31,7 @@ import type {
} from "@/types/schedules";
import { LinkToScans } from "../link-to-scans";
+
import { DataTableRowActions } from "./data-table-row-actions";
interface GroupNameChipsProps {
diff --git a/ui/components/providers/table/data-table-row-actions.test.tsx b/ui/components/providers/table/data-table-row-actions.test.tsx
index 0a0a774b50..3166cca65d 100644
--- a/ui/components/providers/table/data-table-row-actions.test.tsx
+++ b/ui/components/providers/table/data-table-row-actions.test.tsx
@@ -393,7 +393,7 @@ describe("DataTableRowActions", () => {
it("opens Edit Scan Schedule for Prowler Cloud subscribed provider rows", async () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
const user = userEvent.setup();
render(
@@ -420,7 +420,7 @@ describe("DataTableRowActions", () => {
it("hides Edit Scan Schedule for manual-only Cloud provider rows", async () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
const user = userEvent.setup();
render(
@@ -445,7 +445,7 @@ describe("DataTableRowActions", () => {
it("hides Edit Scan Schedule for blocked Cloud provider rows", async () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
const user = userEvent.setup();
render(
@@ -470,7 +470,7 @@ describe("DataTableRowActions", () => {
it("opens scan config management with the precomputed current config id", async () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
const user = userEvent.setup();
render(
@@ -500,7 +500,7 @@ describe("DataTableRowActions", () => {
it("shows scan config management as unavailable when scan configs failed to load", async () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
const user = userEvent.setup();
render(
@@ -528,7 +528,7 @@ describe("DataTableRowActions", () => {
it("hides Edit Scan Configuration for dynamic providers in Prowler Cloud", async () => {
// Given a dynamic provider in a Cloud tenant with scan configs available.
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
const user = userEvent.setup();
render(
diff --git a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx
index 0d0ce0b0a2..b12a541697 100644
--- a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx
+++ b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx
@@ -10,6 +10,7 @@ import {
} from "@/types/provider-wizard";
import type { ProviderWizardInitialData } from "../types";
+
import { useProviderWizardController } from "./use-provider-wizard-controller";
const { refreshMock, requestOpenOnWizardCloseMock } = vi.hoisted(() => ({
@@ -46,7 +47,7 @@ describe("useProviderWizardController", () => {
sessionStorage.clear();
localStorage.clear();
// Checkpoint is Cloud-only.
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
useProviderWizardStore.getState().reset();
useOrgSetupStore.getState().reset();
});
@@ -117,7 +118,7 @@ describe("useProviderWizardController", () => {
});
it("does not request the onboarding checkpoint in self-hosted (OSS) deployments", () => {
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
const onOpenChange = vi.fn();
const { result } = renderHook(() =>
useProviderWizardController({
diff --git a/ui/components/providers/wizard/steps/credentials-step.tsx b/ui/components/providers/wizard/steps/credentials-step.tsx
index 48c6418d46..371f356d30 100644
--- a/ui/components/providers/wizard/steps/credentials-step.tsx
+++ b/ui/components/providers/wizard/steps/credentials-step.tsx
@@ -22,6 +22,7 @@ import {
import { SelectViaGitHub } from "../../workflow/forms/select-credentials-type/github";
import { SelectViaM365 } from "../../workflow/forms/select-credentials-type/m365";
import { UpdateViaServiceAccountForm } from "../../workflow/forms/update-via-service-account-key-form";
+
import {
WIZARD_FOOTER_ACTION_TYPE,
WizardFooterConfig,
diff --git a/ui/components/providers/wizard/steps/launch-step.test.tsx b/ui/components/providers/wizard/steps/launch-step.test.tsx
index dbc31771d9..11bd3bbba6 100644
--- a/ui/components/providers/wizard/steps/launch-step.test.tsx
+++ b/ui/components/providers/wizard/steps/launch-step.test.tsx
@@ -76,7 +76,7 @@ describe("LaunchStep", () => {
describe("Prowler OSS (non-Cloud)", () => {
beforeEach(() => {
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } });
});
@@ -174,7 +174,7 @@ describe("LaunchStep", () => {
describe("Prowler Cloud subscribed", () => {
beforeEach(() => {
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
updateScheduleMock.mockResolvedValue({ data: { id: "provider-1" } });
});
@@ -370,7 +370,7 @@ describe("LaunchStep", () => {
describe("Prowler Cloud trial/onboarding (manual scan only)", () => {
beforeEach(() => {
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } });
});
diff --git a/ui/components/providers/wizard/steps/launch-step.tsx b/ui/components/providers/wizard/steps/launch-step.tsx
index 1c24f2b893..0474821a71 100644
--- a/ui/components/providers/wizard/steps/launch-step.tsx
+++ b/ui/components/providers/wizard/steps/launch-step.tsx
@@ -11,8 +11,7 @@ import {
saveScheduleWithInitialScan,
} from "@/components/scans/schedule/save-schedule";
import { ScanScheduleFields } from "@/components/scans/schedule/scan-schedule-fields";
-import { Field, FieldLabel } from "@/components/shadcn";
-import { ToastAction, useToast } from "@/components/shadcn";
+import { Field, FieldLabel, ToastAction, useToast } from "@/components/shadcn";
import { Badge } from "@/components/shadcn/badge/badge";
import { EntityInfo } from "@/components/shadcn/entities";
import {
diff --git a/ui/components/providers/wizard/steps/test-connection-step.tsx b/ui/components/providers/wizard/steps/test-connection-step.tsx
index 0032771848..cc211c4f47 100644
--- a/ui/components/providers/wizard/steps/test-connection-step.tsx
+++ b/ui/components/providers/wizard/steps/test-connection-step.tsx
@@ -11,6 +11,7 @@ import {
TestConnectionForm,
TestConnectionProviderData,
} from "../../workflow/forms/test-connection-form";
+
import {
WIZARD_FOOTER_ACTION_TYPE,
WizardFooterConfig,
diff --git a/ui/components/providers/workflow/forms/base-credentials-form.tsx b/ui/components/providers/workflow/forms/base-credentials-form.tsx
index 8bedaa2514..b90c9095c4 100644
--- a/ui/components/providers/workflow/forms/base-credentials-form.tsx
+++ b/ui/components/providers/workflow/forms/base-credentials-form.tsx
@@ -37,6 +37,7 @@ import {
} from "@/types";
import { ProviderTitleDocs } from "../provider-title-docs";
+
import {
AlibabaCloudRoleCredentialsForm,
AlibabaCloudStaticCredentialsForm,
diff --git a/ui/components/providers/workflow/forms/connect-account-form.tsx b/ui/components/providers/workflow/forms/connect-account-form.tsx
index 303a5c918a..d8dd37edde 100644
--- a/ui/components/providers/workflow/forms/connect-account-form.tsx
+++ b/ui/components/providers/workflow/forms/connect-account-form.tsx
@@ -11,8 +11,7 @@ import { addProvider } from "@/actions/providers/providers";
import { AwsMethodSelector } from "@/components/providers/organizations/aws-method-selector";
import { WizardInputField } from "@/components/providers/workflow/forms/fields";
import { ProviderTitleDocs } from "@/components/providers/workflow/provider-title-docs";
-import { Button } from "@/components/shadcn";
-import { useToast } from "@/components/shadcn";
+import { Button, useToast } from "@/components/shadcn";
import { Form } from "@/components/shadcn/form";
import {
addProviderFormSchema,
diff --git a/ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form.tsx b/ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form.tsx
index 44bfde7de4..e344b7e92b 100644
--- a/ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form.tsx
+++ b/ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form.tsx
@@ -14,6 +14,7 @@ import {
} from "@/components/shadcn/select/select";
import { Separator } from "@/components/shadcn/separator/separator";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
+import { isCloud } from "@/lib/shared/env";
import { AWSCredentialsRole } from "@/types";
import { IntegrationType } from "@/types/integrations";
@@ -36,7 +37,7 @@ export const AWSRoleCredentialsForm = ({
type?: "providers" | "integrations";
integrationType?: IntegrationType;
}) => {
- const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
+ const isCloudEnv = isCloud();
const defaultCredentialsType = isCloudEnv
? "aws-sdk-default"
: "access-secret-key";
diff --git a/ui/components/providers/workflow/forms/test-connection-form.tsx b/ui/components/providers/workflow/forms/test-connection-form.tsx
index 503c74e1b0..687b036cce 100644
--- a/ui/components/providers/workflow/forms/test-connection-form.tsx
+++ b/ui/components/providers/workflow/forms/test-connection-form.tsx
@@ -14,8 +14,7 @@ import { CheckIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import { Form } from "@/components/shadcn/form";
import { testProviderConnection } from "@/lib/provider-helpers";
-import { ProviderType } from "@/types";
-import { testConnectionFormSchema } from "@/types";
+import { ProviderType, testConnectionFormSchema } from "@/types";
import { ProviderConnectionInfo } from "./provider-connection-info";
diff --git a/ui/components/providers/workflow/forms/via-credentials/k8s-credentials-form.tsx b/ui/components/providers/workflow/forms/via-credentials/k8s-credentials-form.tsx
index a0f687e037..2c3e84d5ff 100644
--- a/ui/components/providers/workflow/forms/via-credentials/k8s-credentials-form.tsx
+++ b/ui/components/providers/workflow/forms/via-credentials/k8s-credentials-form.tsx
@@ -1,13 +1,12 @@
"use client";
-import { Control } from "react-hook-form";
-import { useWatch } from "react-hook-form";
+import { Control, useWatch } from "react-hook-form";
import { WizardTextareaField } from "@/components/providers/workflow/forms/fields";
import { KubernetesCredentials } from "@/types";
import {
- KUBECONFIG_EXEC_AUTHENTICATION_ERROR,
- kubeconfigContainsExecAuthentication,
+ KUBECONFIG_UNSUPPORTED_COMMAND_AUTHENTICATION_ERROR,
+ kubeconfigContainsUnsupportedCommandAuthentication,
} from "@/types/formSchemas";
export const KubernetesCredentialsForm = ({
@@ -19,9 +18,8 @@ export const KubernetesCredentialsForm = ({
control,
name: "kubeconfig_content",
});
- const hasExecAuthentication = kubeconfigContainsExecAuthentication(
- kubeconfigContent ?? "",
- );
+ const hasUnsupportedCommandAuthentication =
+ kubeconfigContainsUnsupportedCommandAuthentication(kubeconfigContent ?? "");
return (
<>
@@ -43,9 +41,9 @@ export const KubernetesCredentialsForm = ({
minRows={10}
isRequired
/>
- {hasExecAuthentication && (
+ {hasUnsupportedCommandAuthentication && (
- {KUBECONFIG_EXEC_AUTHENTICATION_ERROR}
+ {KUBECONFIG_UNSUPPORTED_COMMAND_AUTHENTICATION_ERROR}
)}
>
diff --git a/ui/components/providers/workflow/provider-title-docs.tsx b/ui/components/providers/workflow/provider-title-docs.tsx
index 3f06dd5f88..3556d96bc8 100644
--- a/ui/components/providers/workflow/provider-title-docs.tsx
+++ b/ui/components/providers/workflow/provider-title-docs.tsx
@@ -1,7 +1,9 @@
"use client";
-import { getProviderName } from "@/components/shadcn/entities/get-provider-logo";
-import { getProviderLogo } from "@/components/shadcn/entities/get-provider-logo";
+import {
+ getProviderName,
+ getProviderLogo,
+} from "@/components/shadcn/entities/get-provider-logo";
import { ProviderType } from "@/types";
export const ProviderTitleDocs = ({
diff --git a/ui/components/resources/table/resource-detail-content.tsx b/ui/components/resources/table/resource-detail-content.tsx
index fba3949bc8..b4811b8f1b 100644
--- a/ui/components/resources/table/resource-detail-content.tsx
+++ b/ui/components/resources/table/resource-detail-content.tsx
@@ -8,7 +8,7 @@ import {
loadLatestFindingTriageNote,
updateFindingTriage,
} from "@/actions/findings";
-import { FloatingMuteButton } from "@/components/findings/floating-mute-button";
+import { FloatingSelectionActions } from "@/components/findings/floating-selection-actions";
import { FindingDetailDrawer } from "@/components/findings/table";
import {
Tabs,
@@ -18,8 +18,6 @@ import {
Tooltip,
TooltipContent,
TooltipTrigger,
-} from "@/components/shadcn";
-import {
BreadcrumbNavigation,
CustomBreadcrumbItem,
} from "@/components/shadcn";
@@ -408,9 +406,12 @@ export const ResourceDetailContent = ({
isLoading={findingsLoading}
/>
{selectedFindingIds.length > 0 && (
-
)}
diff --git a/ui/components/roles/workflow/forms/add-role-form.test.tsx b/ui/components/roles/workflow/forms/add-role-form.test.tsx
index e5e791fe07..e39683e7dc 100644
--- a/ui/components/roles/workflow/forms/add-role-form.test.tsx
+++ b/ui/components/roles/workflow/forms/add-role-form.test.tsx
@@ -96,7 +96,7 @@ describe("AddRoleForm", () => {
it("shows Manage Alerts in Prowler Cloud", () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
render( );
@@ -108,7 +108,7 @@ describe("AddRoleForm", () => {
it("hides Manage Alerts outside Prowler Cloud", () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
render( );
diff --git a/ui/components/roles/workflow/forms/add-role-form.tsx b/ui/components/roles/workflow/forms/add-role-form.tsx
index 7795500e15..eb16ea4ebe 100644
--- a/ui/components/roles/workflow/forms/add-role-form.tsx
+++ b/ui/components/roles/workflow/forms/add-role-form.tsx
@@ -6,6 +6,7 @@ import { DefaultValues } from "react-hook-form";
import { addRole } from "@/actions/roles/roles";
import { useToast } from "@/components/shadcn";
import { getErrorMessage } from "@/lib";
+import { isCloud } from "@/lib/shared/env";
import { RoleFormValues } from "@/types";
import { RoleForm, RoleFormSubmitContext, RoleGroupOption } from "./role-form";
@@ -13,7 +14,7 @@ import { RoleForm, RoleFormSubmitContext, RoleGroupOption } from "./role-form";
export const AddRoleForm = ({ groups }: { groups: RoleGroupOption[] }) => {
const { toast } = useToast();
const router = useRouter();
- const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
+ const isCloudEnvironment = isCloud();
const defaultValues: DefaultValues = {
name: "",
diff --git a/ui/components/roles/workflow/forms/delete-role-form.tsx b/ui/components/roles/workflow/forms/delete-role-form.tsx
index 0c4aa127f1..d6b9cabdff 100644
--- a/ui/components/roles/workflow/forms/delete-role-form.tsx
+++ b/ui/components/roles/workflow/forms/delete-role-form.tsx
@@ -7,8 +7,7 @@ import * as z from "zod";
import { deleteRole } from "@/actions/roles";
import { DeleteIcon } from "@/components/icons";
-import { Button } from "@/components/shadcn";
-import { useToast } from "@/components/shadcn";
+import { Button, useToast } from "@/components/shadcn";
import { Form } from "@/components/shadcn/form";
const formSchema = z.object({
diff --git a/ui/components/roles/workflow/forms/edit-role-form.tsx b/ui/components/roles/workflow/forms/edit-role-form.tsx
index fb42e628a0..f64c7aee3c 100644
--- a/ui/components/roles/workflow/forms/edit-role-form.tsx
+++ b/ui/components/roles/workflow/forms/edit-role-form.tsx
@@ -6,6 +6,7 @@ import { DefaultValues } from "react-hook-form";
import { updateRole } from "@/actions/roles/roles";
import { useToast } from "@/components/shadcn";
import { getErrorMessage } from "@/lib";
+import { isCloud } from "@/lib/shared/env";
import { RoleFormValues } from "@/types";
import { RoleForm, RoleFormSubmitContext, RoleGroupOption } from "./role-form";
@@ -30,7 +31,7 @@ export const EditRoleForm = ({
}) => {
const { toast } = useToast();
const router = useRouter();
- const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
+ const isCloudEnvironment = isCloud();
const defaultValues: DefaultValues = {
...roleData.data.attributes,
diff --git a/ui/components/roles/workflow/forms/role-form.tsx b/ui/components/roles/workflow/forms/role-form.tsx
index d0978f7721..bf6f46b905 100644
--- a/ui/components/roles/workflow/forms/role-form.tsx
+++ b/ui/components/roles/workflow/forms/role-form.tsx
@@ -35,6 +35,7 @@ import {
getUnlimitedVisibilityField,
getVisiblePermissionFormFields,
} from "@/lib/role-permissions";
+import { isCloud } from "@/lib/shared/env";
import { roleFormSchema, RoleFormValues } from "@/types";
import { UnlimitedVisibilityField } from "./unlimited-visibility-section";
@@ -74,7 +75,7 @@ export const RoleForm = ({
defaultValues,
});
- const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
+ const isCloudEnvironment = isCloud();
const visiblePermissionFormFields =
getVisiblePermissionFormFields(isCloudEnvironment);
const showUnlimitedVisibilityField = !!getUnlimitedVisibilityField();
diff --git a/ui/components/roles/workflow/vertical-steps.tsx b/ui/components/roles/workflow/vertical-steps.tsx
index 17abec63d2..79fb77678c 100644
--- a/ui/components/roles/workflow/vertical-steps.tsx
+++ b/ui/components/roles/workflow/vertical-steps.tsx
@@ -2,19 +2,18 @@
import { useControlledState } from "@react-stately/utils";
import { domAnimation, LazyMotion, m } from "framer-motion";
-import type { ComponentProps } from "react";
-import React from "react";
+import { forwardRef, useMemo } from "react";
+import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
import { cn } from "@/lib/utils";
export type VerticalStepProps = {
className?: string;
- description?: React.ReactNode;
- title?: React.ReactNode;
+ description?: ReactNode;
+ title?: ReactNode;
};
-export interface VerticalStepsProps
- extends React.HTMLAttributes {
+export interface VerticalStepsProps extends HTMLAttributes {
/**
* An array of steps.
*
@@ -89,10 +88,7 @@ function CheckIcon(props: ComponentProps<"svg">) {
);
}
-export const VerticalSteps = React.forwardRef<
- HTMLButtonElement,
- VerticalStepsProps
->(
+export const VerticalSteps = forwardRef(
(
{
color = "primary",
@@ -113,7 +109,7 @@ export const VerticalSteps = React.forwardRef<
onStepChange,
);
- const colors = React.useMemo(() => {
+ const colors = useMemo(() => {
let userColor;
let fgColor;
diff --git a/ui/components/scans/scans-page-shell.test.tsx b/ui/components/scans/scans-page-shell.test.tsx
index c142c2e3ac..bfafb77f13 100644
--- a/ui/components/scans/scans-page-shell.test.tsx
+++ b/ui/components/scans/scans-page-shell.test.tsx
@@ -154,7 +154,7 @@ describe("ScansPageShell", () => {
});
it("does not render an imported findings tab", () => {
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
@@ -172,7 +172,7 @@ describe("ScansPageShell", () => {
});
it("uses the shared scan filter bar for scan filters", () => {
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
@@ -190,7 +190,7 @@ describe("ScansPageShell", () => {
});
it("clears the active sort when switching tabs", async () => {
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue.current = "tab=active&sort=trigger";
const user = userEvent.setup();
@@ -209,7 +209,7 @@ describe("ScansPageShell", () => {
});
it("uses a generic type filter label in Cloud", () => {
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
render(
@@ -221,7 +221,7 @@ describe("ScansPageShell", () => {
});
it("shows the CLI import banner in Cloud", () => {
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
render(
@@ -239,7 +239,7 @@ describe("ScansPageShell", () => {
});
it("hides the CLI import banner outside Cloud", () => {
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
@@ -251,7 +251,7 @@ describe("ScansPageShell", () => {
});
it("keeps launch scan with filters and mutelist with tabs", () => {
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
@@ -273,7 +273,7 @@ describe("ScansPageShell", () => {
});
it("shows the active scans count in the in progress tab", () => {
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
{
});
it("opens the launch scan modal from the URL", () => {
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue.current = "launchScan=true";
render(
@@ -305,7 +305,7 @@ describe("ScansPageShell", () => {
});
it("strips the launchScan URL param via the History API when closing the URL-opened modal", async () => {
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue.current = "tab=completed&launchScan=true";
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
const user = userEvent.setup();
@@ -333,7 +333,7 @@ describe("ScansPageShell", () => {
});
it("opens and closes the launch scan modal from client state without navigation", async () => {
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
const user = userEvent.setup();
useScansStore.getState().openLaunchScanModal();
@@ -352,7 +352,7 @@ describe("ScansPageShell", () => {
});
it("shows the status filter only on the completed tab", () => {
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue.current = "tab=completed";
render(
@@ -367,7 +367,7 @@ describe("ScansPageShell", () => {
});
it("hides the status filter outside of the completed tab", () => {
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue.current = "tab=active";
render(
@@ -382,7 +382,7 @@ describe("ScansPageShell", () => {
});
it("clears status filter when switching scan tabs", async () => {
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue.current = "tab=completed&filter%5Bstate__in%5D=failed";
const user = userEvent.setup();
@@ -400,7 +400,7 @@ describe("ScansPageShell", () => {
});
it("clears type filter when switching to scheduled scans", async () => {
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue.current = "tab=completed&filter%5Btrigger%5D=manual";
const user = userEvent.setup();
diff --git a/ui/components/scans/table/scan-jobs-columns.test.tsx b/ui/components/scans/table/scan-jobs-columns.test.tsx
index 3a30d74800..496fd11e14 100644
--- a/ui/components/scans/table/scan-jobs-columns.test.tsx
+++ b/ui/components/scans/table/scan-jobs-columns.test.tsx
@@ -3,7 +3,11 @@ import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
-import type { ScanProps } from "@/types";
+import { type ScanProps, SCAN_JOBS_TAB, type ScanJobsTab } from "@/types";
+import {
+ SCAN_SCHEDULE_CAPABILITY,
+ type ScanScheduleCapability,
+} from "@/types/schedules";
vi.mock("@/components/shadcn", async (importOriginal) => ({
...(await importOriginal>()),
@@ -65,12 +69,6 @@ vi.mock("./scan-jobs-row-actions", () => ({
),
}));
-import { SCAN_JOBS_TAB, type ScanJobsTab } from "@/types";
-import {
- SCAN_SCHEDULE_CAPABILITY,
- type ScanScheduleCapability,
-} from "@/types/schedules";
-
import { getScanJobsColumns } from "./scan-jobs-columns";
const getColumnIds = (tab: ScanJobsTab) =>
diff --git a/ui/components/scans/table/scan-jobs-columns.tsx b/ui/components/scans/table/scan-jobs-columns.tsx
index 553310ec0d..dc040368fa 100644
--- a/ui/components/scans/table/scan-jobs-columns.tsx
+++ b/ui/components/scans/table/scan-jobs-columns.tsx
@@ -10,6 +10,7 @@ import { SCAN_JOBS_TAB, type ScanJobsTab, type ScanProps } from "@/types";
import type { ScanScheduleCapability } from "@/types/schedules";
import { formatScanDuration } from "../scans.utils";
+
import {
AccountCell,
ProgressCell,
diff --git a/ui/components/scans/table/scan-jobs-row-actions.test.tsx b/ui/components/scans/table/scan-jobs-row-actions.test.tsx
index e547374098..de4e89ebdc 100644
--- a/ui/components/scans/table/scan-jobs-row-actions.test.tsx
+++ b/ui/components/scans/table/scan-jobs-row-actions.test.tsx
@@ -151,7 +151,7 @@ describe("ScanJobsRowActions", () => {
it("opens Edit Scan Schedule for Prowler Cloud subscribed scan rows", async () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
const user = userEvent.setup();
render( );
@@ -173,7 +173,7 @@ describe("ScanJobsRowActions", () => {
it("hides Edit Scan Schedule outside Prowler Cloud (OSS)", async () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
const user = userEvent.setup();
render( );
@@ -191,7 +191,7 @@ describe("ScanJobsRowActions", () => {
it("hides Edit Scan Schedule outside the Scheduled tab even on Cloud", async () => {
// Given - advanced capability (Cloud) but rendered in the Completed tab.
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
const user = userEvent.setup();
render(
@@ -214,7 +214,7 @@ describe("ScanJobsRowActions", () => {
it("hides Edit Scan Schedule for manual-only Cloud scan rows", async () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
const user = userEvent.setup();
render(
@@ -238,7 +238,7 @@ describe("ScanJobsRowActions", () => {
it("hides Edit Scan Schedule for blocked Cloud scan rows", async () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
const user = userEvent.setup();
render(
diff --git a/ui/components/scans/table/scan-jobs-table.tsx b/ui/components/scans/table/scan-jobs-table.tsx
index 35c53b8e3e..5e0792b891 100644
--- a/ui/components/scans/table/scan-jobs-table.tsx
+++ b/ui/components/scans/table/scan-jobs-table.tsx
@@ -7,6 +7,7 @@ import type { ScanScheduleCapability } from "@/types/schedules";
import { AutoRefresh } from "../auto-refresh";
import { NoScansEmptyState } from "../no-scans-empty-state";
+
import { getScanJobsColumns } from "./scan-jobs-columns";
interface ScanJobsTableProps {
diff --git a/ui/components/shadcn/custom/custom-banner.tsx b/ui/components/shadcn/custom/custom-banner.tsx
index 4f9580316d..c0a593082c 100644
--- a/ui/components/shadcn/custom/custom-banner.tsx
+++ b/ui/components/shadcn/custom/custom-banner.tsx
@@ -11,6 +11,7 @@ interface CustomBannerProps {
message: string;
buttonLabel?: string;
buttonLink?: string;
+ onButtonClick?: () => void;
}
export const CustomBanner = ({
@@ -18,6 +19,7 @@ export const CustomBanner = ({
message,
buttonLabel = "Go Home",
buttonLink = "/",
+ onButtonClick,
}: CustomBannerProps) => {
return (
@@ -40,7 +42,9 @@ export const CustomBanner = ({
className="w-full justify-center md:w-fit"
size="default"
>
- {buttonLabel}
+
+ {buttonLabel}
+
diff --git a/ui/components/shadcn/custom/custom-radio.tsx b/ui/components/shadcn/custom/custom-radio.tsx
index 53fd0e6bd6..d6bf6dec2c 100644
--- a/ui/components/shadcn/custom/custom-radio.tsx
+++ b/ui/components/shadcn/custom/custom-radio.tsx
@@ -4,12 +4,17 @@ import { RadioGroupItem } from "@/components/shadcn/radio-group/radio-group";
import { cn } from "@/lib/utils";
interface CustomRadioProps {
+ ariaLabel?: string;
description?: string;
value?: string;
children?: React.ReactNode;
}
-export const CustomRadio = ({ value, children }: CustomRadioProps) => {
+export const CustomRadio = ({
+ ariaLabel,
+ value,
+ children,
+}: CustomRadioProps) => {
return (
{
"has-[[data-state=checked]]:border-button-primary",
)}
>
-
+
{children}
);
diff --git a/ui/components/shadcn/dropdown/action-dropdown.tsx b/ui/components/shadcn/dropdown/action-dropdown.tsx
index 47c72b3afd..2b6436608c 100644
--- a/ui/components/shadcn/dropdown/action-dropdown.tsx
+++ b/ui/components/shadcn/dropdown/action-dropdown.tsx
@@ -5,6 +5,8 @@ import { ComponentProps, ReactNode, useEffect, useState } from "react";
import { cn } from "@/lib/utils";
+import { Tooltip, TooltipContent, TooltipTrigger } from "../tooltip";
+
import {
DropdownMenu,
DropdownMenuContent,
@@ -104,6 +106,10 @@ interface ActionDropdownItemProps
description?: string;
/** Whether the item is destructive (danger styling) */
destructive?: boolean;
+ /** Tooltip shown while the item remains interactive. */
+ tooltip?: string;
+ /** Tooltip shown when the item is disabled. */
+ disabledTooltip?: string;
}
export function ActionDropdownItem({
@@ -112,9 +118,13 @@ export function ActionDropdownItem({
description,
destructive = false,
className,
+ tooltip,
+ disabledTooltip,
+ disabled,
+ onSelect,
...props
}: ActionDropdownItemProps) {
- return (
+ const item = (
{
+ if (disabled) {
+ event.preventDefault();
+ return;
+ }
+
+ onSelect?.(event);
+ }}
{...props}
>
{icon && (
@@ -149,6 +169,19 @@ export function ActionDropdownItem({
);
+
+ const tooltipContent = tooltip ?? (disabled ? disabledTooltip : undefined);
+
+ if (tooltipContent) {
+ return (
+
+ {item}
+ {tooltipContent}
+
+ );
+ }
+
+ return item;
}
export function ActionDropdownDangerZone({
diff --git a/ui/components/shared/cloud-upgrade-modal.test.tsx b/ui/components/shared/cloud-upgrade-modal.test.tsx
index 318c03740c..2ac1ec1c68 100644
--- a/ui/components/shared/cloud-upgrade-modal.test.tsx
+++ b/ui/components/shared/cloud-upgrade-modal.test.tsx
@@ -7,16 +7,47 @@ import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import { CloudUpgradeModal } from "./cloud-upgrade-modal";
+const modalTestState = vi.hoisted(() => ({
+ keepContentMounted: false,
+}));
+
+vi.mock("@/components/shadcn/modal", async (importOriginal) => {
+ const actual =
+ await importOriginal
();
+ const { createElement } = await import("react");
+
+ return {
+ ...actual,
+ Modal: (props: Parameters[0]) => {
+ if (!modalTestState.keepContentMounted) {
+ return createElement(actual.Modal, props);
+ }
+
+ return createElement(
+ "div",
+ { "aria-label": props.title, role: "dialog" },
+ createElement(
+ "button",
+ { onClick: () => props.onOpenChange?.(false), type: "button" },
+ "Close",
+ ),
+ props.children,
+ );
+ },
+ };
+});
+
describe("CloudUpgradeModal", () => {
afterEach(() => {
cleanup();
+ modalTestState.keepContentMounted = false;
vi.unstubAllEnvs();
useCloudUpgradeStore.getState().closeCloudUpgrade();
});
it("renders the active contextual upgrade in Local Server", async () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
useCloudUpgradeStore
.getState()
.openCloudUpgrade(CLOUD_UPGRADE_FEATURE.ALERTS);
@@ -45,7 +76,7 @@ describe("CloudUpgradeModal", () => {
it("uses the standard equal-width CTA layout", async () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
useCloudUpgradeStore
.getState()
.openCloudUpgrade(CLOUD_UPGRADE_FEATURE.AWS_ORGANIZATIONS);
@@ -85,9 +116,41 @@ describe("CloudUpgradeModal", () => {
);
});
- it("closes the active upgrade and returns focus to its trigger", async () => {
+ it("renders the contextual Jira dispatch upgrade", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ useCloudUpgradeStore
+ .getState()
+ .openCloudUpgrade(CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH);
+
+ // When
+ render( );
+
+ // Then
+ expect(
+ await screen.findByRole("dialog", {
+ name: "Send Findings to Jira at Scale",
+ }),
+ ).toBeVisible();
+ expect(
+ screen.getByRole("link", {
+ name: "Send Findings to Jira in Prowler Cloud",
+ }),
+ ).toHaveAttribute(
+ "href",
+ "https://cloud.prowler.com/sign-up?utm_source=prowler-local-server&utm_content=jira-dispatch",
+ );
+ expect(
+ screen.getByRole("link", { name: "View Plans & Pricing" }),
+ ).toHaveAttribute(
+ "href",
+ "https://prowler.com/pricing?utm_source=prowler-local-server&utm_content=jira-dispatch",
+ );
+ });
+
+ it("closes the active upgrade and returns focus to its trigger", async () => {
+ // Given
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
const user = userEvent.setup();
render(
@@ -120,9 +183,45 @@ describe("CloudUpgradeModal", () => {
expect(useCloudUpgradeStore.getState().activeFeature).toBeNull();
});
+ it.each([
+ {
+ feature: CLOUD_UPGRADE_FEATURE.ALERTS,
+ otherTitle: "Add Your Entire AWS Organization",
+ title: "Turn Findings into Alerts",
+ },
+ {
+ feature: CLOUD_UPGRADE_FEATURE.AWS_ORGANIZATIONS,
+ otherTitle: "Turn Findings into Alerts",
+ title: "Add Your Entire AWS Organization",
+ },
+ ])(
+ "does not replace $title with another upgrade while closing",
+ async ({ feature, otherTitle, title }) => {
+ // Given
+ vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ modalTestState.keepContentMounted = true;
+ const user = userEvent.setup();
+ useCloudUpgradeStore.getState().openCloudUpgrade(feature);
+
+ render( );
+ expect(screen.getByRole("dialog", { name: title })).toBeVisible();
+
+ // When
+ await user.click(screen.getByRole("button", { name: "Close" }));
+
+ // Then
+ expect(useCloudUpgradeStore.getState().activeFeature).toBeNull();
+ expect(screen.getByRole("dialog", { name: title })).toBeVisible();
+ expect(
+ screen.queryByText("Scale Prowler Without Operating It"),
+ ).not.toBeInTheDocument();
+ expect(screen.queryByText(otherTitle)).not.toBeInTheDocument();
+ },
+ );
+
it("does not render upgrade UI in Prowler Cloud", () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
useCloudUpgradeStore
.getState()
.openCloudUpgrade(CLOUD_UPGRADE_FEATURE.ALERTS);
diff --git a/ui/components/shared/cloud-upgrade-modal.tsx b/ui/components/shared/cloud-upgrade-modal.tsx
index 9d7617d941..3c7080427b 100644
--- a/ui/components/shared/cloud-upgrade-modal.tsx
+++ b/ui/components/shared/cloud-upgrade-modal.tsx
@@ -14,12 +14,14 @@ import {
} from "@/lib/cloud-upgrade";
import { isCloud } from "@/lib/shared/env";
import { useCloudUpgradeStore } from "@/store";
-import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
const allowInitialAutoFocus = () => {};
export const CloudUpgradeModal = () => {
const activeFeature = useCloudUpgradeStore((state) => state.activeFeature);
+ const retainedFeature = useCloudUpgradeStore(
+ (state) => state.retainedFeature,
+ );
const closeCloudUpgrade = useCloudUpgradeStore(
(state) => state.closeCloudUpgrade,
);
@@ -29,7 +31,7 @@ export const CloudUpgradeModal = () => {
if (isCloud()) return null;
- const feature = activeFeature ?? CLOUD_UPGRADE_FEATURE.GENERAL;
+ const feature = activeFeature ?? retainedFeature;
const content = CLOUD_UPGRADE_CONTENT[feature];
return (
diff --git a/ui/components/shared/task-polling-watcher.tsx b/ui/components/shared/task-polling-watcher.tsx
index 18f9749854..8624f0d9c4 100644
--- a/ui/components/shared/task-polling-watcher.tsx
+++ b/ui/components/shared/task-polling-watcher.tsx
@@ -4,16 +4,19 @@ import {
CROSS_PROVIDER_PDF_TASK_KIND,
crossProviderPdfHandler,
} from "@/app/(prowler)/compliance/_lib/cross-provider-pdf";
+import { jiraDispatchTaskHandler } from "@/components/findings/jira-dispatch-task-handler";
import { useMountEffect } from "@/hooks/use-mount-effect";
import {
registerTaskKindHandler,
resumePendingTasks,
} from "@/store/task-watcher/store";
+import { JIRA_DISPATCH_TASK_KIND } from "@/types/integrations";
// Kind registrations happen at module scope, before any task can settle in
// this tab. Adding a new watched task kind (integration tests, scan exports,
// …) is one line here plus a handler next to the feature that owns it.
registerTaskKindHandler(CROSS_PROVIDER_PDF_TASK_KIND, crossProviderPdfHandler);
+registerTaskKindHandler(JIRA_DISPATCH_TASK_KIND, jiraDispatchTaskHandler);
/**
* Mounted once in the app layout (next to `Toaster`): resumes polling any
diff --git a/ui/components/ui/toast/index.test.ts b/ui/components/ui/toast/index.test.ts
new file mode 100644
index 0000000000..c2b253e5b6
--- /dev/null
+++ b/ui/components/ui/toast/index.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ toast as shadcnToast,
+ Toaster as ShadcnToaster,
+} from "@/components/shadcn/toast";
+import { toast as uiToast, Toaster as UiToaster } from "@/components/ui/toast";
+
+describe("components/ui/toast", () => {
+ it("uses the mounted shadcn toast store and provider", () => {
+ expect(uiToast).toBe(shadcnToast);
+ expect(UiToaster).toBe(ShadcnToaster);
+ });
+});
diff --git a/ui/components/users/forms/delete-form.tsx b/ui/components/users/forms/delete-form.tsx
index 9a86efed8e..ad4c3baf75 100644
--- a/ui/components/users/forms/delete-form.tsx
+++ b/ui/components/users/forms/delete-form.tsx
@@ -7,8 +7,7 @@ import * as z from "zod";
import { deleteUser } from "@/actions/users/users";
import { DeleteIcon } from "@/components/icons";
-import { Button } from "@/components/shadcn";
-import { useToast } from "@/components/shadcn";
+import { Button, useToast } from "@/components/shadcn";
import { Form } from "@/components/shadcn/form";
const formSchema = z.object({
diff --git a/ui/components/users/forms/edit-form.tsx b/ui/components/users/forms/edit-form.tsx
index 362ec3088e..5bcfe51545 100644
--- a/ui/components/users/forms/edit-form.tsx
+++ b/ui/components/users/forms/edit-form.tsx
@@ -7,8 +7,7 @@ import { Controller, useForm } from "react-hook-form";
import * as z from "zod";
import { updateUser, updateUserRole } from "@/actions/users/users";
-import { Card } from "@/components/shadcn";
-import { useToast } from "@/components/shadcn";
+import { Card, useToast } from "@/components/shadcn";
import { CustomInput } from "@/components/shadcn/custom";
import { Form, FormButtons } from "@/components/shadcn/form";
import {
diff --git a/ui/components/users/forms/expel-user-form.tsx b/ui/components/users/forms/expel-user-form.tsx
index d333b1ab49..a9a98b5a15 100644
--- a/ui/components/users/forms/expel-user-form.tsx
+++ b/ui/components/users/forms/expel-user-form.tsx
@@ -4,8 +4,7 @@ import { Dispatch, SetStateAction, useTransition } from "react";
import { removeUserFromTenant } from "@/actions/users/users";
import { DeleteIcon } from "@/components/icons";
-import { Button } from "@/components/shadcn";
-import { useToast } from "@/components/shadcn";
+import { Button, useToast } from "@/components/shadcn";
interface ExpelUserFormProps {
userId: string;
diff --git a/ui/components/users/forms/switch-tenant-form.test.tsx b/ui/components/users/forms/switch-tenant-form.test.tsx
index 018e55b7ae..1bfe8a7ac7 100644
--- a/ui/components/users/forms/switch-tenant-form.test.tsx
+++ b/ui/components/users/forms/switch-tenant-form.test.tsx
@@ -1,27 +1,42 @@
-import { render, screen } from "@testing-library/react";
+import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
-import { describe, expect, it, vi } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
import { SwitchTenantForm } from "./switch-tenant-form";
-const mockUpdate = vi.fn();
+const { mockReloadPage, mockSwitchTenant, mockToast, mockUpdate } = vi.hoisted(
+ () => ({
+ mockReloadPage: vi.fn(),
+ mockSwitchTenant: vi.fn(),
+ mockToast: vi.fn(),
+ mockUpdate: vi.fn(),
+ }),
+);
+
vi.mock("next-auth/react", () => ({
useSession: () => ({ update: mockUpdate }),
}));
vi.mock("@/actions/users/tenants", () => ({
- switchTenant: vi.fn(),
+ switchTenant: mockSwitchTenant,
}));
-const mockToast = vi.fn();
vi.mock("@/components/shadcn", async (importOriginal) => ({
...(await importOriginal>()),
useToast: () => ({ toast: mockToast }),
}));
+vi.mock("@/lib/navigation", () => ({
+ reloadPage: mockReloadPage,
+}));
+
describe("SwitchTenantForm", () => {
const setIsOpen = vi.fn();
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
it("renders confirm and cancel buttons", () => {
render( );
@@ -48,4 +63,55 @@ describe("SwitchTenantForm", () => {
await user.click(screen.getByRole("button", { name: /cancel/i }));
expect(setIsOpen).toHaveBeenCalledWith(false);
});
+
+ it("shows an error when the session cannot apply the tenant switch", async () => {
+ // Given
+ const user = userEvent.setup();
+ mockSwitchTenant.mockResolvedValue({
+ success: true,
+ accessToken: "switched-access-token",
+ refreshToken: "switched-refresh-token",
+ });
+ mockUpdate.mockResolvedValue({ error: "TenantSwitchError" });
+ render( );
+
+ // When
+ await user.click(screen.getByRole("button", { name: /confirm/i }));
+
+ // Then
+ await waitFor(() =>
+ expect(mockToast).toHaveBeenCalledWith({
+ variant: "destructive",
+ title: "Oops! Something went wrong",
+ description: "Unable to switch organization. Please try again.",
+ }),
+ );
+ expect(mockReloadPage).not.toHaveBeenCalled();
+ });
+
+ it("reloads after the session applies the tenant switch", async () => {
+ // Given
+ const user = userEvent.setup();
+ mockSwitchTenant.mockResolvedValue({
+ success: true,
+ accessToken: "switched-access-token",
+ refreshToken: "switched-refresh-token",
+ });
+ mockUpdate.mockResolvedValue({
+ expires: "2026-12-31T23:59:59.999Z",
+ });
+ render( );
+
+ // When
+ await user.click(screen.getByRole("button", { name: /confirm/i }));
+
+ // Then
+ await waitFor(() =>
+ expect(mockToast).toHaveBeenCalledWith({
+ title: "Organization switched",
+ description: "The page will reload to apply the change.",
+ }),
+ );
+ expect(mockReloadPage).toHaveBeenCalledOnce();
+ });
});
diff --git a/ui/components/users/forms/switch-tenant-form.tsx b/ui/components/users/forms/switch-tenant-form.tsx
index b518ebd5bf..9c45848b2b 100644
--- a/ui/components/users/forms/switch-tenant-form.tsx
+++ b/ui/components/users/forms/switch-tenant-form.tsx
@@ -23,15 +23,32 @@ export const SwitchTenantForm = ({
const handleSwitch = async () => {
if ("success" in state) {
- await update({
- accessToken: state.accessToken,
- refreshToken: state.refreshToken,
- });
- toast({
- title: "Organization switched",
- description: "The page will reload to apply the change.",
- });
- reloadPage();
+ try {
+ const updatedSession = await update({
+ accessToken: state.accessToken,
+ refreshToken: state.refreshToken,
+ });
+
+ if (
+ !updatedSession ||
+ ("error" in updatedSession && updatedSession.error)
+ ) {
+ throw new Error("Session update failed");
+ }
+
+ toast({
+ title: "Organization switched",
+ description: "The page will reload to apply the change.",
+ });
+ reloadPage();
+ } catch {
+ toast({
+ variant: "destructive",
+ title: "Oops! Something went wrong",
+ description: "Unable to switch organization. Please try again.",
+ });
+ setIsOpen(false);
+ }
} else {
toast({
variant: "destructive",
diff --git a/ui/components/users/profile/role-item.test.tsx b/ui/components/users/profile/role-item.test.tsx
index 947da3515c..2df8d92ae4 100644
--- a/ui/components/users/profile/role-item.test.tsx
+++ b/ui/components/users/profile/role-item.test.tsx
@@ -34,7 +34,7 @@ describe("RoleItem", () => {
it("shows Manage Alerts in Prowler Cloud role details", () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
render( );
@@ -45,7 +45,7 @@ describe("RoleItem", () => {
it("hides Manage Alerts outside Prowler Cloud role details", () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
render( );
@@ -56,7 +56,7 @@ describe("RoleItem", () => {
it("displays the permission state as a badge", () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
render( );
@@ -67,7 +67,7 @@ describe("RoleItem", () => {
it("does not render the details toggle", () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
render( );
diff --git a/ui/config/site.test.ts b/ui/config/site.test.ts
index 686b38c988..ed647ff8ce 100644
--- a/ui/config/site.test.ts
+++ b/ui/config/site.test.ts
@@ -8,7 +8,7 @@ describe("siteConfig", () => {
it("names the open-source application Prowler Local Server", async () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
const { siteConfig } = await import("./site");
@@ -19,7 +19,7 @@ describe("siteConfig", () => {
it("keeps the Prowler Cloud name in Cloud", async () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
const { siteConfig } = await import("./site");
diff --git a/ui/dependency-log.json b/ui/dependency-log.json
index acb4622141..8908168515 100644
--- a/ui/dependency-log.json
+++ b/ui/dependency-log.json
@@ -498,18 +498,18 @@
{
"section": "dependencies",
"name": "next",
- "from": "16.2.6",
- "to": "16.2.9",
+ "from": "16.2.9",
+ "to": "16.2.11",
"strategy": "installed",
- "generatedAt": "2026-06-15T07:49:41.143Z"
+ "generatedAt": "2026-07-24T08:32:45.227Z"
},
{
"section": "dependencies",
"name": "next-auth",
- "from": "5.0.0-beta.29",
- "to": "5.0.0-beta.30",
+ "from": "5.0.0-beta.30",
+ "to": "5.0.0-beta.32",
"strategy": "installed",
- "generatedAt": "2025-12-15T11:18:25.093Z"
+ "generatedAt": "2026-07-24T08:32:45.227Z"
},
{
"section": "dependencies",
@@ -759,22 +759,6 @@
"strategy": "installed",
"generatedAt": "2025-10-30T10:22:21.335Z"
},
- {
- "section": "devDependencies",
- "name": "@typescript-eslint/eslint-plugin",
- "from": "7.18.0",
- "to": "8.53.0",
- "strategy": "installed",
- "generatedAt": "2026-01-19T13:54:24.770Z"
- },
- {
- "section": "devDependencies",
- "name": "@typescript-eslint/parser",
- "from": "7.18.0",
- "to": "8.53.0",
- "strategy": "installed",
- "generatedAt": "2026-01-19T13:54:24.770Z"
- },
{
"section": "devDependencies",
"name": "@vitejs/plugin-react",
@@ -786,26 +770,26 @@
{
"section": "devDependencies",
"name": "@vitest/browser",
- "from": "4.0.18",
- "to": "4.1.8",
+ "from": "4.1.8",
+ "to": "4.1.10",
"strategy": "installed",
- "generatedAt": "2026-06-02T11:34:46.264Z"
+ "generatedAt": "2026-07-24T08:32:45.227Z"
},
{
"section": "devDependencies",
"name": "@vitest/browser-playwright",
- "from": "4.0.18",
- "to": "4.1.8",
+ "from": "4.1.8",
+ "to": "4.1.10",
"strategy": "installed",
- "generatedAt": "2026-06-02T11:34:46.264Z"
+ "generatedAt": "2026-07-24T08:32:45.227Z"
},
{
"section": "devDependencies",
"name": "@vitest/coverage-v8",
- "from": "4.0.18",
- "to": "4.1.8",
+ "from": "4.1.8",
+ "to": "4.1.10",
"strategy": "installed",
- "generatedAt": "2026-06-02T11:34:46.264Z"
+ "generatedAt": "2026-07-24T08:32:45.227Z"
},
{
"section": "devDependencies",
@@ -849,17 +833,25 @@
},
{
"section": "devDependencies",
- "name": "eslint-plugin-jsx-a11y",
- "from": "6.10.2",
- "to": "6.10.2",
+ "name": "eslint-import-resolver-typescript",
+ "from": "4.4.4",
+ "to": "4.4.4",
"strategy": "installed",
- "generatedAt": "2025-10-22T12:36:37.962Z"
+ "generatedAt": "2026-05-13T15:04:07.559Z"
},
{
"section": "devDependencies",
- "name": "eslint-plugin-prettier",
- "from": "5.5.1",
- "to": "5.5.1",
+ "name": "eslint-plugin-import-x",
+ "from": "4.16.2",
+ "to": "4.16.2",
+ "strategy": "installed",
+ "generatedAt": "2026-05-13T15:02:04.867Z"
+ },
+ {
+ "section": "devDependencies",
+ "name": "eslint-plugin-jsx-a11y",
+ "from": "6.10.2",
+ "to": "6.10.2",
"strategy": "installed",
"generatedAt": "2025-10-22T12:36:37.962Z"
},
@@ -887,22 +879,6 @@
"strategy": "installed",
"generatedAt": "2025-10-22T12:36:37.962Z"
},
- {
- "section": "devDependencies",
- "name": "eslint-plugin-simple-import-sort",
- "from": "12.1.1",
- "to": "12.1.1",
- "strategy": "installed",
- "generatedAt": "2025-10-22T12:36:37.962Z"
- },
- {
- "section": "devDependencies",
- "name": "eslint-plugin-unused-imports",
- "from": "3.2.0",
- "to": "4.3.0",
- "strategy": "installed",
- "generatedAt": "2026-01-19T13:54:24.770Z"
- },
{
"section": "devDependencies",
"name": "globals",
@@ -911,6 +887,14 @@
"strategy": "installed",
"generatedAt": "2026-01-19T13:54:24.770Z"
},
+ {
+ "section": "devDependencies",
+ "name": "jiti",
+ "from": "2.7.0",
+ "to": "2.7.0",
+ "strategy": "installed",
+ "generatedAt": "2026-05-13T15:02:04.867Z"
+ },
{
"section": "devDependencies",
"name": "jsdom",
@@ -985,11 +969,19 @@
},
{
"section": "devDependencies",
- "name": "vitest",
- "from": "4.0.18",
- "to": "4.1.8",
+ "name": "typescript-eslint",
+ "from": "8.59.3",
+ "to": "8.59.3",
"strategy": "installed",
- "generatedAt": "2026-06-02T11:34:46.264Z"
+ "generatedAt": "2026-05-13T15:02:04.867Z"
+ },
+ {
+ "section": "devDependencies",
+ "name": "vitest",
+ "from": "4.1.8",
+ "to": "4.1.10",
+ "strategy": "installed",
+ "generatedAt": "2026-07-24T08:32:45.227Z"
},
{
"section": "devDependencies",
diff --git a/ui/eslint.config.mjs b/ui/eslint.config.ts
similarity index 53%
rename from ui/eslint.config.mjs
rename to ui/eslint.config.ts
index b62359296f..dc0658649f 100644
--- a/ui/eslint.config.mjs
+++ b/ui/eslint.config.ts
@@ -1,22 +1,15 @@
-import { dirname } from "path";
-import { fileURLToPath } from "url";
-import tsPlugin from "@typescript-eslint/eslint-plugin";
-import tsParser from "@typescript-eslint/parser";
-import prettierPlugin from "eslint-plugin-prettier";
-import simpleImportSort from "eslint-plugin-simple-import-sort";
-import jsxA11y from "eslint-plugin-jsx-a11y";
-import security from "eslint-plugin-security";
-import unusedImports from "eslint-plugin-unused-imports";
import nextPlugin from "@next/eslint-plugin-next";
+import prettierConfig from "eslint-config-prettier/flat";
+import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript";
+import importX, { createNodeResolver } from "eslint-plugin-import-x";
+import jsxA11y from "eslint-plugin-jsx-a11y";
import reactPlugin from "eslint-plugin-react";
import reactHooksPlugin from "eslint-plugin-react-hooks";
+import security from "eslint-plugin-security";
import globals from "globals";
+import tseslint from "typescript-eslint";
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = dirname(__filename);
-
-export default [
- // Global ignores (replaces .eslintignore)
+export default tseslint.config(
{
ignores: [
".now/**",
@@ -29,6 +22,11 @@ export default [
"scripts/**",
"*.config.js",
"*.config.mjs",
+ "auth.config.ts",
+ "eslint.config.ts",
+ "knip.config.ts",
+ "playwright.config.ts",
+ "vitest.config.ts",
".DS_Store",
"node_modules/**",
"coverage/**",
@@ -37,27 +35,33 @@ export default [
"next-env.d.ts",
],
},
-
- // TypeScript and React files configuration
+ importX.flatConfigs.recommended,
+ importX.flatConfigs.typescript,
{
files: ["**/*.{ts,tsx,js,jsx}"],
linterOptions: {
reportUnusedDisableDirectives: "error",
},
plugins: {
- "@typescript-eslint": tsPlugin,
+ "@typescript-eslint": tseslint.plugin,
"@next/next": nextPlugin,
react: reactPlugin,
"react-hooks": reactHooksPlugin,
- prettier: prettierPlugin,
- "simple-import-sort": simpleImportSort,
"jsx-a11y": jsxA11y,
- security: security,
- "unused-imports": unusedImports,
+ security,
},
languageOptions: {
- parser: tsParser,
+ parser: tseslint.parser,
parserOptions: {
+ projectService: {
+ allowDefaultProject: [
+ // Duplicate of events-timeline.test.ts in the same folder;
+ // TypeScript only picks the .ts sibling, so this .tsx file is
+ // outside the project graph. Tracked for follow-up cleanup.
+ "components/shared/events-timeline/events-timeline.test.tsx",
+ ],
+ },
+ tsconfigRootDir: import.meta.dirname,
ecmaVersion: "latest",
sourceType: "module",
ecmaFeatures: {
@@ -75,46 +79,54 @@ export default [
react: {
version: "detect",
},
+ "import-x/resolver-next": [
+ createTypeScriptImportResolver({
+ alwaysTryTypes: true,
+ project: "./tsconfig.json",
+ }),
+ createNodeResolver(),
+ ],
},
rules: {
- // Console rules - allow console.error but no console.log
"no-console": ["error", { allow: ["error"] }],
- eqeqeq: 2,
- quotes: ["error", "double", "avoid-escape"],
+ eqeqeq: "error",
+ quotes: ["error", "double", { avoidEscape: true }],
- // TypeScript rules
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
+ enableAutofixRemoval: { imports: true },
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
- // Security
"security/detect-object-injection": "off",
- // Prettier integration
- "prettier/prettier": [
- "error",
- {
- endOfLine: "auto",
- tabWidth: 2,
- useTabs: false,
- },
- ],
"eol-last": ["error", "always"],
- // Import sorting
- "simple-import-sort/imports": "error",
- "simple-import-sort/exports": "error",
+ "import-x/order": [
+ "error",
+ {
+ groups: [
+ "builtin",
+ "external",
+ "internal",
+ "parent",
+ "sibling",
+ "index",
+ ],
+ "newlines-between": "always",
+ alphabetize: { order: "asc", caseInsensitive: true },
+ },
+ ],
+ // Pre-existing duplicate exports and re-export shape mismatches are
+ // tracked separately; the migration keeps behavior parity with the
+ // legacy config until the rule is enforced in the canonical Base layer.
+ "import-x/export": "off",
- // Unused imports
- "unused-imports/no-unused-imports": "error",
-
- // Accessibility
"jsx-a11y/anchor-is-valid": [
"error",
{
@@ -125,14 +137,13 @@ export default [
],
"jsx-a11y/alt-text": "error",
- // React Hooks
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
- // Next.js specific rules
"@next/next/no-html-link-for-pages": "error",
"@next/next/no-img-element": "warn",
"@next/next/no-sync-scripts": "error",
},
},
-];
+ prettierConfig,
+);
diff --git a/ui/hooks/use-credentials-form.ts b/ui/hooks/use-credentials-form.ts
index 6fee1ac9e5..5dd896cd0b 100644
--- a/ui/hooks/use-credentials-form.ts
+++ b/ui/hooks/use-credentials-form.ts
@@ -7,6 +7,7 @@ import { useFormServerErrors } from "@/hooks/use-form-server-errors";
import { filterEmptyValues } from "@/lib";
import { PROVIDER_CREDENTIALS_ERROR_MAPPING } from "@/lib/error-mappings";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
+import { isCloud } from "@/lib/shared/env";
import {
addCredentialsFormSchema,
addCredentialsRoleFormSchema,
@@ -76,7 +77,7 @@ export const useCredentialsForm = ({
// AWS Role credentials
if (providerType === "aws" && effectiveVia === "role") {
- const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
+ const isCloudEnv = isCloud();
const defaultCredentialsType = isCloudEnv
? "aws-sdk-default"
: "access-secret-key";
diff --git a/ui/hooks/use-filter-batch.test.ts b/ui/hooks/use-filter-batch.test.ts
index 0607c041ef..cd59291081 100644
--- a/ui/hooks/use-filter-batch.test.ts
+++ b/ui/hooks/use-filter-batch.test.ts
@@ -98,6 +98,25 @@ describe("useFilterBatch", () => {
"filter[delta]": ["new"],
});
});
+
+ it("should parse filter[check_id] from grouped finding deep links", () => {
+ // Given - URL produced by the grouped finding resources panel deep link.
+ setSearchParams({
+ "filter[check_id]": "teams_external_users_can_join",
+ expandedCheckId: "teams_external_users_can_join",
+ });
+
+ // When
+ const { result } = renderHook(() => useFilterBatch());
+
+ // Then - expandedCheckId is not a filter chip, but check_id is URL-backed.
+ expect(result.current.pendingFilters).toEqual({
+ "filter[check_id]": ["teams_external_users_can_join"],
+ });
+ expect(result.current.getFilterValue("filter[check_id]")).toEqual([
+ "teams_external_users_can_join",
+ ]);
+ });
});
// ── Excluded keys ──────────────────────────────────────────────────────────
@@ -179,6 +198,30 @@ describe("useFilterBatch", () => {
// Then
expect(result.current.pendingFilters["filter[severity__in]"]).toEqual([]);
});
+
+ it("should replace exclusive legacy filters when the new grouped filter is edited", () => {
+ // Given - a legacy deep link with the exact check_id filter applied.
+ setSearchParams({
+ "filter[check_id]": "teams_external_users_can_join",
+ });
+ const { result } = renderHook(() =>
+ useFilterBatch({
+ exclusiveFilterGroups: [["filter[check_id]", "filter[check_id__in]"]],
+ }),
+ );
+
+ // When - the Finding Group multi-select writes the __in filter.
+ act(() => {
+ result.current.setPending("filter[check_id__in]", [
+ "teams_external_users_cannot_join",
+ ]);
+ });
+
+ // Then - the stale exact filter is removed from pending state.
+ expect(result.current.pendingFilters).toEqual({
+ "filter[check_id__in]": ["teams_external_users_cannot_join"],
+ });
+ });
});
// ── getFilterValue ─────────────────────────────────────────────────────────
@@ -227,6 +270,24 @@ describe("useFilterBatch", () => {
// Then
expect(values).toEqual(["critical"]);
});
+
+ it("should expose legacy exclusive filter values through the replacement key", () => {
+ // Given - a legacy grouped finding deep link uses filter[check_id].
+ setSearchParams({
+ "filter[check_id]": "teams_external_users_can_join",
+ });
+ const { result } = renderHook(() =>
+ useFilterBatch({
+ exclusiveFilterGroups: [["filter[check_id]", "filter[check_id__in]"]],
+ }),
+ );
+
+ // When - the new Finding Group control asks for filter[check_id__in].
+ const values = result.current.getFilterValue("filter[check_id__in]");
+
+ // Then - the existing exact value appears selected in the control.
+ expect(values).toEqual(["teams_external_users_can_join"]);
+ });
});
// ── hasChanges & changeCount ───────────────────────────────────────────────
diff --git a/ui/hooks/use-filter-batch.ts b/ui/hooks/use-filter-batch.ts
index 8e13ee8bc8..4451b29c61 100644
--- a/ui/hooks/use-filter-batch.ts
+++ b/ui/hooks/use-filter-batch.ts
@@ -151,6 +151,23 @@ export interface UseFilterBatchOptions {
* (e.g. `{ "filter[muted]": "false" }` on the Findings page).
*/
defaultParams?: Record;
+ /**
+ * Filter keys that represent the same logical control. Updating one removes
+ * the others so legacy exact params and new multi-select params cannot be
+ * applied together.
+ */
+ exclusiveFilterGroups?: string[][];
+}
+
+function normalizeFilterKey(key: string): string {
+ return key.startsWith("filter[") ? key : `filter[${key}]`;
+}
+
+function getExclusiveFilterGroup(
+ filterKey: string,
+ exclusiveFilterGroups: string[][] | undefined,
+): string[] | undefined {
+ return exclusiveFilterGroups?.find((group) => group.includes(filterKey));
}
/**
@@ -187,15 +204,27 @@ export const useFilterBatch = (
}, [searchParams]);
const setPending = (key: string, values: string[]) => {
- const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
- setPendingFilters((prev) => ({
- ...prev,
- [filterKey]: values,
- }));
+ const filterKey = normalizeFilterKey(key);
+ const exclusiveGroup = getExclusiveFilterGroup(
+ filterKey,
+ options?.exclusiveFilterGroups,
+ );
+ setPendingFilters((prev) => {
+ const next = { ...prev };
+
+ exclusiveGroup
+ ?.filter((exclusiveKey) => exclusiveKey !== filterKey)
+ .forEach((exclusiveKey) => {
+ delete next[exclusiveKey];
+ });
+
+ next[filterKey] = values;
+ return next;
+ });
};
const removePending = (key: string) => {
- const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
+ const filterKey = normalizeFilterKey(key);
setPendingFilters((prev) => {
const next = { ...prev };
delete next[filterKey];
@@ -265,7 +294,7 @@ export const useFilterBatch = (
};
const removeAppliedAndApply = (key: string, value?: string) => {
- const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
+ const filterKey = normalizeFilterKey(key);
const applied = deriveAppliedFromUrl(
new URLSearchParams(searchParams.toString()),
);
@@ -286,8 +315,20 @@ export const useFilterBatch = (
};
const getFilterValue = (key: string): string[] => {
- const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
- return pendingFilters[filterKey] ?? [];
+ const filterKey = normalizeFilterKey(key);
+ const values = pendingFilters[filterKey];
+ if (values) return values;
+
+ const exclusiveGroup = getExclusiveFilterGroup(
+ filterKey,
+ options?.exclusiveFilterGroups,
+ );
+ const alternateKey = exclusiveGroup?.find(
+ (exclusiveKey) =>
+ exclusiveKey !== filterKey && pendingFilters[exclusiveKey],
+ );
+
+ return alternateKey ? pendingFilters[alternateKey] : [];
};
const hasChanges = !areFiltersEqual(pendingFilters, appliedFilters);
diff --git a/ui/hooks/use-scan-schedule-capability.test.ts b/ui/hooks/use-scan-schedule-capability.test.ts
index 9310365cf9..18cee616cb 100644
--- a/ui/hooks/use-scan-schedule-capability.test.ts
+++ b/ui/hooks/use-scan-schedule-capability.test.ts
@@ -12,7 +12,7 @@ describe("useScanScheduleCapability", () => {
it("returns DAILY_LEGACY for OSS without loading", () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
const { result } = renderHook(() => useScanScheduleCapability());
@@ -26,7 +26,7 @@ describe("useScanScheduleCapability", () => {
it("returns ADVANCED for Cloud env without loading", () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
const { result } = renderHook(() => useScanScheduleCapability());
diff --git a/ui/lib/cloud-upgrade.test.ts b/ui/lib/cloud-upgrade.test.ts
index f16f54413b..233c5dffaf 100644
--- a/ui/lib/cloud-upgrade.test.ts
+++ b/ui/lib/cloud-upgrade.test.ts
@@ -24,6 +24,7 @@ describe("cloud upgrade content", () => {
"Bring CLI Findings into One Cloud View",
"See Compliance Across Every Provider",
"Coordinate Finding Remediation",
+ "Send Findings to Jira at Scale",
"Use The Agent Cloud Defender",
"Scale Prowler Without Operating It",
"Configure Every Scan Once",
@@ -71,6 +72,7 @@ describe("cloud upgrade URLs", () => {
"cross-provider-compliance",
],
[CLOUD_UPGRADE_FEATURE.FINDING_TRIAGE, "findings"],
+ [CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH, "jira-dispatch"],
[CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI, "lighthouse-ai"],
[CLOUD_UPGRADE_FEATURE.GENERAL, "general"],
[CLOUD_UPGRADE_FEATURE.SCAN_CONFIGURATION, "scan-configuration"],
diff --git a/ui/lib/cloud-upgrade.ts b/ui/lib/cloud-upgrade.ts
index a8993cdfbd..b5ae3e55cb 100644
--- a/ui/lib/cloud-upgrade.ts
+++ b/ui/lib/cloud-upgrade.ts
@@ -26,6 +26,7 @@ const CLOUD_UPGRADE_UTM_CONTENT = {
[CLOUD_UPGRADE_FEATURE.CROSS_PROVIDER_COMPLIANCE]:
"cross-provider-compliance",
[CLOUD_UPGRADE_FEATURE.FINDING_TRIAGE]: "findings",
+ [CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH]: "jira-dispatch",
[CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI]: "lighthouse-ai",
[CLOUD_UPGRADE_FEATURE.GENERAL]: "general",
[CLOUD_UPGRADE_FEATURE.SCAN_CONFIGURATION]: "scan-configuration",
@@ -98,6 +99,17 @@ export const CLOUD_UPGRADE_CONTENT = {
],
primaryCta: "Triage Findings in Prowler Cloud",
},
+ [CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH]: {
+ title: "Send Findings to Jira at Scale",
+ description:
+ "Create Jira issues from selected findings and finding groups without handling each item separately.",
+ benefits: [
+ "Send selected findings or finding groups in one action",
+ "Choose between grouped and individual Jira issues",
+ "Track dispatch progress and retry failed findings",
+ ],
+ primaryCta: "Send Findings to Jira in Prowler Cloud",
+ },
[CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI]: {
title: "Use The Agent Cloud Defender",
description:
diff --git a/ui/lib/deployment.test.ts b/ui/lib/deployment.test.ts
new file mode 100644
index 0000000000..574733feb6
--- /dev/null
+++ b/ui/lib/deployment.test.ts
@@ -0,0 +1,34 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+const importFresh = async () => {
+ vi.resetModules();
+ return import("./deployment");
+};
+
+describe("enterprise feature flags", () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it("should keep grouped Jira dispatch disabled outside cloud", async () => {
+ // Given
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
+
+ // When
+ const { isGroupedJiraDispatchEnabled } = await importFresh();
+
+ // Then
+ expect(isGroupedJiraDispatchEnabled()).toBe(false);
+ });
+
+ it("should enable grouped Jira dispatch in cloud without an enterprise flag", async () => {
+ // Given
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
+
+ // When
+ const { isGroupedJiraDispatchEnabled } = await importFresh();
+
+ // Then
+ expect(isGroupedJiraDispatchEnabled()).toBe(true);
+ });
+});
diff --git a/ui/lib/deployment.ts b/ui/lib/deployment.ts
new file mode 100644
index 0000000000..8fb5e16c56
--- /dev/null
+++ b/ui/lib/deployment.ts
@@ -0,0 +1,5 @@
+import { isCloud } from "./shared/env";
+
+export const PROWLER_CLOUD_ONLY_TOOLTIP = "Available only in Prowler Cloud";
+
+export const isGroupedJiraDispatchEnabled = (): boolean => isCloud();
diff --git a/ui/lib/env.test.ts b/ui/lib/env.test.ts
index eaeab7e92d..ee916e6b12 100644
--- a/ui/lib/env.test.ts
+++ b/ui/lib/env.test.ts
@@ -169,3 +169,111 @@ describe("lib/env gated integration validation", () => {
await expect(import("@/lib/env")).resolves.toBeDefined();
});
});
+
+describe("lib/env billing and Stripe boot warnings", () => {
+ // Clear billing, cloud, Stripe and gated flags so ambient shell env cannot
+ // affect assertions, then satisfy the unconditional REQUIRED vars.
+ const CLEARED_ENV_VARS = [
+ "UI_CLOUD_ENABLED",
+ "CLOUD_BILLING_ENABLED",
+ "UI_SENTRY_ENABLED",
+ "UI_SENTRY_DSN",
+ "NEXT_PUBLIC_SENTRY_DSN",
+ "UI_SENTRY_ENVIRONMENT",
+ "NEXT_PUBLIC_SENTRY_ENVIRONMENT",
+ "UI_GOOGLE_TAG_MANAGER_ENABLED",
+ "UI_GOOGLE_TAG_MANAGER_ID",
+ "NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID",
+ "UI_POSTHOG_ENABLED",
+ "UI_POSTHOG_KEY",
+ "POSTHOG_KEY",
+ "UI_POSTHOG_HOST",
+ "POSTHOG_HOST",
+ "UI_CLOUD_STRIPE_PUBLISHABLE_KEY",
+ "NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY",
+ "UI_CLOUD_STRIPE_PUBLISHABLE_KEY_V2",
+ "NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_V2",
+ ] as const;
+
+ let warnSpy: ReturnType;
+
+ beforeEach(() => {
+ vi.resetModules();
+ for (const key of CLEARED_ENV_VARS) {
+ vi.stubEnv(key, undefined);
+ }
+ vi.stubEnv("UI_API_BASE_URL", "https://api.example.com/api/v1");
+ vi.stubEnv("AUTH_URL", "http://localhost:3000");
+ vi.stubEnv("AUTH_SECRET", "secret");
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.restoreAllMocks();
+ });
+
+ it('warns when billing is "legacy" without the cloud flag', async () => {
+ vi.stubEnv("CLOUD_BILLING_ENABLED", "legacy");
+
+ await import("@/lib/env");
+
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'CLOUD_BILLING_ENABLED is "legacy" but UI_CLOUD_ENABLED is not "true"',
+ ),
+ );
+ });
+
+ it('warns when billing is "metronome" (PostHog enabled) without the cloud flag', async () => {
+ vi.stubEnv("CLOUD_BILLING_ENABLED", "metronome");
+ vi.stubEnv("UI_POSTHOG_ENABLED", "true");
+ vi.stubEnv("UI_POSTHOG_KEY", "phc_key");
+ vi.stubEnv("UI_POSTHOG_HOST", "https://eu.i.posthog.com");
+
+ await import("@/lib/env");
+
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'CLOUD_BILLING_ENABLED is "metronome" but UI_CLOUD_ENABLED is not "true"',
+ ),
+ );
+ });
+
+ it("does not warn about billing when the cloud flag is set", async () => {
+ vi.stubEnv("CLOUD_BILLING_ENABLED", "legacy");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
+
+ await import("@/lib/env");
+
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+
+ it("does not warn when billing is off and no Stripe keys are set", async () => {
+ await import("@/lib/env");
+
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+
+ it("warns when a Stripe key is set without billing enabled", async () => {
+ vi.stubEnv("UI_CLOUD_STRIPE_PUBLISHABLE_KEY", "pk_test_123");
+
+ await import("@/lib/env");
+
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ "UI_CLOUD_STRIPE_PUBLISHABLE_KEY is set but CLOUD_BILLING_ENABLED is not enabled; Stripe will not load.",
+ ),
+ );
+ });
+
+ it("does not warn about Stripe when cloud, billing, and Stripe are all set", async () => {
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
+ vi.stubEnv("CLOUD_BILLING_ENABLED", "legacy");
+ vi.stubEnv("UI_CLOUD_STRIPE_PUBLISHABLE_KEY", "pk_test_123");
+
+ await import("@/lib/env");
+
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/ui/lib/env.ts b/ui/lib/env.ts
index 46b019d7df..791715afb2 100644
--- a/ui/lib/env.ts
+++ b/ui/lib/env.ts
@@ -38,4 +38,37 @@ if (
warnGatedIntegrationsMisconfig();
+// The billing UI is Cloud-only: navigation (navigation-config.ts) and the
+// /billing route (proxy.ts) additionally gate on the cloud flag, so billing
+// enabled without it is inert — warn, don't throw.
+const cloudEnabled = readBoolEnv("UI_CLOUD_ENABLED");
+const cloudBillingSelector = readEnv("CLOUD_BILLING_ENABLED");
+const cloudBillingOn =
+ cloudBillingSelector !== null && cloudBillingSelector !== "false";
+
+if (cloudBillingOn && !cloudEnabled) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `CLOUD_BILLING_ENABLED is "${cloudBillingSelector}" but UI_CLOUD_ENABLED is not "true"; the billing UI will not be shown.`,
+ );
+}
+
+// Stripe publishable keys load only on billing flows; a key without billing
+// enabled is inert.
+if (!cloudBillingOn) {
+ for (const name of [
+ "UI_CLOUD_STRIPE_PUBLISHABLE_KEY",
+ "NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY",
+ "UI_CLOUD_STRIPE_PUBLISHABLE_KEY_V2",
+ "NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_V2",
+ ] as const) {
+ if (readEnv(name)) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `${name} is set but CLOUD_BILLING_ENABLED is not enabled; Stripe will not load.`,
+ );
+ }
+ }
+}
+
export {};
diff --git a/ui/lib/external-urls.ts b/ui/lib/external-urls.ts
index 0ae17384a9..e4b0a1573e 100644
--- a/ui/lib/external-urls.ts
+++ b/ui/lib/external-urls.ts
@@ -15,6 +15,7 @@ export const DOCS_URLS = {
"https://docs.prowler.com/user-guide/tutorials/prowler-app-scan-configuration",
ATTACK_PATHS_CUSTOM_QUERIES:
"https://docs.prowler.com/user-guide/tutorials/prowler-app-attack-paths#writing-custom-opencypher-queries",
+ AI_AGENTS: "https://docs.prowler.com/user-guide/ai-agents/",
} as const;
// CloudFormation template URL for the ProwlerScan role.
diff --git a/ui/lib/finding-group-filter-options.test.ts b/ui/lib/finding-group-filter-options.test.ts
new file mode 100644
index 0000000000..396762ffbb
--- /dev/null
+++ b/ui/lib/finding-group-filter-options.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { getFindingGroupFilterOptions } from "./finding-group-filter-options";
+
+function makeResponse(
+ pageCount: number,
+ groups: Array<{ id: string; title: string }>,
+) {
+ return {
+ data: groups.map(({ id, title }) => ({
+ type: "finding-groups",
+ id,
+ attributes: {
+ check_id: id,
+ check_title: title,
+ check_description: null,
+ severity: "high",
+ status: "FAIL",
+ impacted_providers: [],
+ resources_total: 1,
+ resources_fail: 1,
+ pass_count: 0,
+ fail_count: 1,
+ muted_count: 0,
+ new_count: 0,
+ changed_count: 0,
+ first_seen_at: null,
+ last_seen_at: null,
+ failing_since: null,
+ },
+ })),
+ meta: { pagination: { pages: pageCount } },
+ };
+}
+
+describe("getFindingGroupFilterOptions", () => {
+ it("loads every page without applying the filter's own selection", async () => {
+ // Given
+ const fetchFindingGroups = vi
+ .fn()
+ .mockResolvedValueOnce(
+ makeResponse(2, [{ id: "check-a", title: "Check A" }]),
+ )
+ .mockResolvedValueOnce(
+ makeResponse(2, [
+ { id: "check-a", title: "Check A updated" },
+ { id: "check-b", title: "Check B" },
+ ]),
+ );
+
+ // When
+ const options = await getFindingGroupFilterOptions({
+ fetchFindingGroups,
+ filters: {
+ "filter[check_id__in]": "check-a",
+ "filter[severity__in]": "high",
+ },
+ });
+
+ // Then
+ expect(fetchFindingGroups).toHaveBeenNthCalledWith(1, {
+ filters: { "filter[severity__in]": "high" },
+ page: 1,
+ pageSize: 100,
+ });
+ expect(fetchFindingGroups).toHaveBeenNthCalledWith(2, {
+ filters: { "filter[severity__in]": "high" },
+ page: 2,
+ pageSize: 100,
+ });
+ expect(options).toEqual([
+ { checkId: "check-a", checkTitle: "Check A updated" },
+ { checkId: "check-b", checkTitle: "Check B" },
+ ]);
+ });
+});
diff --git a/ui/lib/finding-group-filter-options.ts b/ui/lib/finding-group-filter-options.ts
new file mode 100644
index 0000000000..4c40dde659
--- /dev/null
+++ b/ui/lib/finding-group-filter-options.ts
@@ -0,0 +1,81 @@
+import { adaptFindingGroupsResponse } from "@/actions/finding-groups/finding-groups.adapter";
+
+const FINDING_GROUP_FILTER_OPTION_PAGE_SIZE = 100;
+const FINDING_GROUP_OWN_FILTER_KEYS = new Set([
+ "filter[check_id]",
+ "filter[check_id__in]",
+]);
+
+interface FindingGroupFilterFetcherParams {
+ page: number;
+ pageSize: number;
+ filters: Record;
+}
+
+type FindingGroupFilterFetcher = (
+ params: FindingGroupFilterFetcherParams,
+) => Promise;
+
+function excludeFindingGroupOwnFilters(
+ filters: Record,
+) {
+ return Object.fromEntries(
+ Object.entries(filters).filter(
+ ([key]) => !FINDING_GROUP_OWN_FILTER_KEYS.has(key),
+ ),
+ );
+}
+
+function getTotalPages(response: unknown, currentPage: number): number {
+ if (!response || typeof response !== "object" || !("meta" in response)) {
+ return currentPage;
+ }
+
+ const meta = response.meta;
+ if (!meta || typeof meta !== "object" || !("pagination" in meta)) {
+ return currentPage;
+ }
+
+ const pagination = meta.pagination;
+ if (
+ !pagination ||
+ typeof pagination !== "object" ||
+ !("pages" in pagination)
+ ) {
+ return currentPage;
+ }
+
+ return typeof pagination.pages === "number" ? pagination.pages : currentPage;
+}
+
+export async function getFindingGroupFilterOptions({
+ fetchFindingGroups,
+ filters,
+}: {
+ fetchFindingGroups: FindingGroupFilterFetcher;
+ filters: Record;
+}) {
+ const optionFilters = excludeFindingGroupOwnFilters(filters);
+ const options = new Map();
+ let page = 1;
+
+ while (true) {
+ const response = await fetchFindingGroups({
+ filters: optionFilters,
+ page,
+ pageSize: FINDING_GROUP_FILTER_OPTION_PAGE_SIZE,
+ });
+
+ for (const group of adaptFindingGroupsResponse(response)) {
+ options.set(group.checkId, {
+ checkId: group.checkId,
+ checkTitle: group.checkTitle,
+ });
+ }
+
+ if (page >= getTotalPages(response, page)) break;
+ page += 1;
+ }
+
+ return Array.from(options.values());
+}
diff --git a/ui/lib/get-runtime-config.client.test.ts b/ui/lib/get-runtime-config.client.test.ts
index d788a01132..403f66001d 100644
--- a/ui/lib/get-runtime-config.client.test.ts
+++ b/ui/lib/get-runtime-config.client.test.ts
@@ -62,6 +62,20 @@ describe("getRuntimeConfigClient", () => {
expect(config.reoDevClientId).toBeNull();
});
+ it("reads the cloudEnabled flag from the island", async () => {
+ // Given
+ writeIsland(JSON.stringify({ cloudEnabled: true }));
+ const { getRuntimeConfigClient } = await import(
+ "./get-runtime-config.client"
+ );
+
+ // When
+ const config = getRuntimeConfigClient();
+
+ // Then
+ expect(config.cloudEnabled).toBe(true);
+ });
+
it("falls back to an all-null config when the island is malformed JSON", async () => {
// Given
writeIsland("{ not valid json");
@@ -99,6 +113,7 @@ describe("getRuntimeConfigClient", () => {
"apiBaseUrl",
"apiDocsUrl",
"cloudBillingEnabled",
+ "cloudEnabled",
"googleTagManagerId",
"posthogHost",
"posthogKey",
@@ -110,9 +125,10 @@ describe("getRuntimeConfigClient", () => {
].sort(),
);
expect(config.apiBaseUrl).toBe("https://api.example.com/api/v1");
- // cloudBillingEnabled is a boolean flag, so it defaults to false (not null)
- // when absent from the island.
+ // cloudBillingEnabled and cloudEnabled are boolean flags, so they default to
+ // false (not null) when absent from the island.
expect(config.cloudBillingEnabled).toBe(false);
+ expect(config.cloudEnabled).toBe(false);
expect(
(config as unknown as Record).notAllowlisted,
).toBeUndefined();
diff --git a/ui/lib/get-runtime-config.client.ts b/ui/lib/get-runtime-config.client.ts
index e1bcd2bf1a..2b6e98c245 100644
--- a/ui/lib/get-runtime-config.client.ts
+++ b/ui/lib/get-runtime-config.client.ts
@@ -2,45 +2,16 @@
import {
EMPTY_RUNTIME_PUBLIC_CONFIG,
- RUNTIME_CONFIG_SCRIPT_ID,
+ readRuntimeConfigIsland,
type RuntimePublicConfig,
} from "@/lib/runtime-config.shared";
let cached: RuntimePublicConfig | null = null;
-// Explicit per-key copy (not a spread) so unexpected island keys can't leak through.
-const pickConfig = (
- parsed: Partial,
-): RuntimePublicConfig => ({
- sentryDsn: parsed.sentryDsn ?? null,
- sentryEnvironment: parsed.sentryEnvironment ?? null,
- googleTagManagerId: parsed.googleTagManagerId ?? null,
- apiBaseUrl: parsed.apiBaseUrl ?? null,
- apiDocsUrl: parsed.apiDocsUrl ?? null,
- posthogKey: parsed.posthogKey ?? null,
- posthogHost: parsed.posthogHost ?? null,
- reoDevClientId: parsed.reoDevClientId ?? null,
- cloudBillingEnabled: parsed.cloudBillingEnabled ?? false,
- stripePublishableKey: parsed.stripePublishableKey ?? null,
- stripePublishableKeyV2: parsed.stripePublishableKeyV2 ?? null,
-});
-
// Reads the island once (memoized); all-null during SSR or if it's
// missing/malformed, so callers can treat every integration as disabled.
export function getRuntimeConfigClient(): RuntimePublicConfig {
if (cached) return cached;
- if (typeof document === "undefined") return EMPTY_RUNTIME_PUBLIC_CONFIG;
-
- const el = document.getElementById(RUNTIME_CONFIG_SCRIPT_ID);
- let resolved: RuntimePublicConfig;
- try {
- resolved = el?.textContent
- ? pickConfig(JSON.parse(el.textContent) as Partial)
- : EMPTY_RUNTIME_PUBLIC_CONFIG;
- } catch {
- resolved = EMPTY_RUNTIME_PUBLIC_CONFIG;
- }
-
- cached = resolved;
- return resolved;
+ cached = readRuntimeConfigIsland() ?? EMPTY_RUNTIME_PUBLIC_CONFIG;
+ return cached;
}
diff --git a/ui/lib/jira-dispatch-action.test.ts b/ui/lib/jira-dispatch-action.test.ts
new file mode 100644
index 0000000000..ba4b1443ee
--- /dev/null
+++ b/ui/lib/jira-dispatch-action.test.ts
@@ -0,0 +1,127 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ createJiraBatchSelection,
+ createJiraTargetSelection,
+} from "@/lib/jira-dispatch-selection";
+import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
+
+import {
+ buildJiraActionLabel,
+ getJiraDispatchActionState,
+} from "./jira-dispatch-action";
+
+describe("getJiraDispatchActionState", () => {
+ it("allows one Finding without grouped dispatch", () => {
+ // Given
+ const selection = createJiraTargetSelection(
+ ["finding-1"],
+ JIRA_DISPATCH_TARGET.FINDING_ID,
+ )!;
+
+ // When
+ const state = getJiraDispatchActionState({ selection }, false);
+
+ // Then
+ expect(state).toEqual({
+ canChooseGroupedDispatch: false,
+ defaultDispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL,
+ requiresUpgrade: false,
+ });
+ });
+
+ it("offers grouped choice for multiple Findings in Cloud", () => {
+ // Given
+ const selection = createJiraTargetSelection(
+ ["finding-1", "finding-2"],
+ JIRA_DISPATCH_TARGET.FINDING_ID,
+ )!;
+
+ // When
+ const state = getJiraDispatchActionState({ selection }, true);
+
+ // Then
+ expect(state).toEqual({
+ canChooseGroupedDispatch: true,
+ defaultDispatchMode: JIRA_DISPATCH_MODE.GROUPED,
+ requiresUpgrade: false,
+ });
+ });
+
+ it("requires upgrade for multiple Findings outside Cloud", () => {
+ // Given
+ const selection = createJiraTargetSelection(
+ ["finding-1", "finding-2"],
+ JIRA_DISPATCH_TARGET.FINDING_ID,
+ )!;
+
+ // When
+ const state = getJiraDispatchActionState({ selection }, false);
+
+ // Then
+ expect(state.requiresUpgrade).toBe(true);
+ expect(state.canChooseGroupedDispatch).toBe(false);
+ });
+
+ it("offers grouped choice for one Finding Group with multiple resources", () => {
+ // Given
+ const selection = createJiraTargetSelection(
+ ["check-1"],
+ JIRA_DISPATCH_TARGET.CHECK_ID,
+ )!;
+
+ // When
+ const state = getJiraDispatchActionState(
+ { selection, selectedResourceCount: 2 },
+ true,
+ );
+
+ // Then
+ expect(state).toEqual({
+ canChooseGroupedDispatch: true,
+ defaultDispatchMode: JIRA_DISPATCH_MODE.GROUPED,
+ requiresUpgrade: false,
+ });
+ });
+
+ it("keeps mixed batches grouped without offering one global choice", () => {
+ // Given
+ const selection = createJiraBatchSelection([
+ {
+ targetIds: ["check-1"],
+ targetType: JIRA_DISPATCH_TARGET.CHECK_ID,
+ dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
+ },
+ {
+ targetIds: ["finding-1"],
+ targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
+ dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL,
+ },
+ ])!;
+
+ // When
+ const state = getJiraDispatchActionState({ selection }, true);
+
+ // Then
+ expect(state.defaultDispatchMode).toBe(JIRA_DISPATCH_MODE.GROUPED);
+ expect(state.canChooseGroupedDispatch).toBe(false);
+ });
+});
+
+describe("buildJiraActionLabel", () => {
+ it.each([
+ [{ findingCount: 1 }, "Send 1 Finding to Jira"],
+ [{ findingCount: 2 }, "Send 2 Findings to Jira"],
+ [{ findingGroupCount: 1 }, "Send 1 Finding Group to Jira"],
+ [
+ { findingGroupCount: 2, findingCount: 1 },
+ "Send 2 Finding Groups and 1 Finding to Jira",
+ ],
+ ])("builds consistent Jira action copy", (counts, expected) => {
+ // Given / When
+ const label = buildJiraActionLabel(counts);
+
+ // Then
+ expect(label).toBe(expected);
+ });
+});
diff --git a/ui/lib/jira-dispatch-action.ts b/ui/lib/jira-dispatch-action.ts
new file mode 100644
index 0000000000..69d8675ccf
--- /dev/null
+++ b/ui/lib/jira-dispatch-action.ts
@@ -0,0 +1,74 @@
+import { getJiraSelectionBatches } from "@/lib/jira-dispatch-selection";
+import {
+ JIRA_DISPATCH_MODE,
+ JIRA_DISPATCH_TARGET,
+ JIRA_TARGET_SELECTION_KIND,
+ type JiraDispatchMode,
+} from "@/types/integrations";
+import type { JiraDispatchModalPayload } from "@/types/jira-dispatch";
+
+export interface JiraDispatchActionState {
+ defaultDispatchMode: JiraDispatchMode;
+ canChooseGroupedDispatch: boolean;
+ requiresUpgrade: boolean;
+}
+
+export interface JiraActionLabelCounts {
+ findingGroupCount?: number;
+ findingCount?: number;
+}
+
+export const getJiraDispatchActionState = (
+ payload: JiraDispatchModalPayload,
+ groupedDispatchEnabled: boolean,
+): JiraDispatchActionState => {
+ const batches = getJiraSelectionBatches(payload.selection);
+ const targetCount = batches.reduce(
+ (count, batch) => count + batch.targetIds.length,
+ 0,
+ );
+ const hasFindingGroupTargets = batches.some(
+ (batch) => batch.targetType === JIRA_DISPATCH_TARGET.CHECK_ID,
+ );
+ const requiresGroupedFeature =
+ hasFindingGroupTargets || batches.length > 1 || targetCount > 1;
+ const firstBatch = batches[0];
+ const canChooseGroupedDispatch =
+ groupedDispatchEnabled &&
+ payload.selection.kind !== JIRA_TARGET_SELECTION_KIND.BATCHES &&
+ (firstBatch.targetType === JIRA_DISPATCH_TARGET.FINDING_ID
+ ? firstBatch.targetIds.length > 1
+ : firstBatch.targetIds.length === 1 &&
+ (payload.selectedResourceCount ?? 0) > 1);
+
+ return {
+ defaultDispatchMode: requiresGroupedFeature
+ ? JIRA_DISPATCH_MODE.GROUPED
+ : JIRA_DISPATCH_MODE.INDIVIDUAL,
+ canChooseGroupedDispatch,
+ requiresUpgrade: requiresGroupedFeature && !groupedDispatchEnabled,
+ };
+};
+
+const buildEntityLabel = (
+ count: number,
+ singular: string,
+ plural: string,
+): string | null => {
+ if (count === 0) return null;
+ return `${count} ${count === 1 ? singular : plural}`;
+};
+
+export const buildJiraActionLabel = ({
+ findingGroupCount = 0,
+ findingCount = 0,
+}: JiraActionLabelCounts): string => {
+ const entities = [
+ buildEntityLabel(findingGroupCount, "Finding Group", "Finding Groups"),
+ buildEntityLabel(findingCount, "Finding", "Findings"),
+ ].filter(Boolean);
+
+ return entities.length > 0
+ ? `Send ${entities.join(" and ")} to Jira`
+ : "Send to Jira";
+};
diff --git a/ui/lib/jira-dispatch-execution.test.ts b/ui/lib/jira-dispatch-execution.test.ts
new file mode 100644
index 0000000000..b96d84c893
--- /dev/null
+++ b/ui/lib/jira-dispatch-execution.test.ts
@@ -0,0 +1,141 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import {
+ JIRA_DISPATCH_MODE,
+ type JiraDispatchTargetBatch,
+} from "@/types/integrations";
+
+import { executeJiraDispatchBatches } from "./jira-dispatch-execution";
+
+const { sendJiraDispatchMock, trackAndPollTaskMock } = vi.hoisted(() => ({
+ sendJiraDispatchMock: vi.fn(),
+ trackAndPollTaskMock: vi.fn(),
+}));
+
+vi.mock("@/actions/integrations/jira-dispatch", () => ({
+ sendJiraDispatch: sendJiraDispatchMock,
+}));
+
+vi.mock("@/store/task-watcher/store", () => ({
+ TASK_WATCHER_STATUS: { READY: "ready" },
+ trackAndPollTask: trackAndPollTaskMock,
+}));
+
+const settings = {
+ integrationId: "jira-1",
+ projectKey: "SEC",
+ issueType: "Task",
+ dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
+};
+
+describe("executeJiraDispatchBatches", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ sendJiraDispatchMock.mockResolvedValue({
+ success: true,
+ taskId: "task-1",
+ message: "Started",
+ });
+ trackAndPollTaskMock.mockResolvedValue({
+ status: "ready",
+ result: { created_count: 2, failed_count: 0 },
+ });
+ });
+
+ it("uses one dispatch path and aggregates successful batches", async () => {
+ // Given
+ const batches: JiraDispatchTargetBatch[] = [
+ {
+ targetIds: ["check-a"],
+ targetType: "check_id" as const,
+ dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
+ },
+ {
+ targetIds: ["finding-a"],
+ targetType: "finding_id" as const,
+ dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL,
+ },
+ ];
+
+ // When
+ const result = await executeJiraDispatchBatches(batches, settings);
+
+ // Then
+ expect(sendJiraDispatchMock).toHaveBeenCalledTimes(2);
+ expect(sendJiraDispatchMock).toHaveBeenNthCalledWith(2, {
+ integrationId: "jira-1",
+ targetIds: ["finding-a"],
+ filter: "finding_id",
+ projectKey: "SEC",
+ issueType: "Task",
+ dispatchMode: "individual",
+ });
+ expect(result).toMatchObject({
+ startedTaskCount: 2,
+ successfulTaskCount: 2,
+ successfulIssueCount: 4,
+ successMessage: "4 Jira issues were created or updated successfully.",
+ errors: [],
+ warnings: [],
+ });
+ });
+
+ it("returns only failed finding IDs as an individual retry batch", async () => {
+ // Given
+ trackAndPollTaskMock.mockResolvedValue({
+ status: "ready",
+ result: {
+ created_count: 1,
+ failed_count: 2,
+ failed_finding_ids: ["finding-b", "finding-b", "finding-c"],
+ error: "Two issues failed.",
+ },
+ });
+
+ // When
+ const result = await executeJiraDispatchBatches(
+ [
+ {
+ targetIds: ["check-a"],
+ targetType: "check_id",
+ dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
+ },
+ ],
+ settings,
+ );
+
+ // Then
+ expect(result.retryBatch).toEqual({
+ targetIds: ["finding-b", "finding-c"],
+ targetType: "finding_id",
+ dispatchMode: "individual",
+ });
+ expect(result.warnings).toEqual([
+ "Two issues failed. Jira dispatch completed with 3 failed and 1 created/updated issue.",
+ ]);
+ });
+
+ it("does not offer an automatic retry after an unknown launch failure", async () => {
+ // Given
+ sendJiraDispatchMock.mockRejectedValue(new Error("Connection closed"));
+
+ // When
+ const result = await executeJiraDispatchBatches(
+ [
+ {
+ targetIds: ["finding-a"],
+ targetType: "finding_id",
+ dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL,
+ },
+ ],
+ settings,
+ );
+
+ // Then
+ expect(result.startedTaskCount).toBe(0);
+ expect(result.retryBatch).toBeUndefined();
+ expect(result.errors).toEqual([
+ "The Jira dispatch status is unknown after a connection error. Check Jira before retrying.",
+ ]);
+ });
+});
diff --git a/ui/lib/jira-dispatch-execution.ts b/ui/lib/jira-dispatch-execution.ts
new file mode 100644
index 0000000000..74dfd7f616
--- /dev/null
+++ b/ui/lib/jira-dispatch-execution.ts
@@ -0,0 +1,187 @@
+import { sendJiraDispatch } from "@/actions/integrations/jira-dispatch";
+import {
+ evaluateJiraDispatchTask,
+ getJiraDispatchSuccessCount,
+} from "@/lib/jira-dispatch-result";
+import { buildJiraDispatchTaskMeta } from "@/lib/jira-dispatch-task";
+import {
+ TASK_WATCHER_STATUS,
+ type TaskTrackingResult,
+ trackAndPollTask,
+} from "@/store/task-watcher/store";
+import {
+ JIRA_DISPATCH_MODE,
+ JIRA_DISPATCH_TARGET,
+ JIRA_DISPATCH_TASK_KIND,
+ type JiraDispatchMode,
+ type JiraDispatchTargetBatch,
+ type JiraDispatchTaskResult,
+} from "@/types/integrations";
+
+export interface JiraDispatchSettings {
+ integrationId: string;
+ projectKey: string;
+ issueType: string;
+ dispatchMode: JiraDispatchMode;
+}
+
+export interface JiraDispatchExecutionResult {
+ startedTaskCount: number;
+ successfulTaskCount: number;
+ successfulIssueCount: number;
+ successMessage?: string;
+ warnings: string[];
+ errors: string[];
+ retryBatch?: JiraDispatchTargetBatch;
+}
+
+interface JiraTrackedOutcome {
+ success: boolean;
+ message?: string;
+ error?: string;
+ warning?: string;
+ failedFindingIds?: string[];
+ successfulCount?: number;
+}
+
+export function getJiraRetryBatch(
+ failedFindingIds: string[] | undefined,
+): JiraDispatchTargetBatch | undefined {
+ const [firstTargetId, ...remainingTargetIds] = Array.from(
+ new Set(failedFindingIds?.filter(Boolean) ?? []),
+ );
+ if (!firstTargetId) return undefined;
+
+ return {
+ targetIds: [firstTargetId, ...remainingTargetIds],
+ targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
+ dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL,
+ };
+}
+
+export async function executeJiraDispatchBatches(
+ batches: JiraDispatchTargetBatch[],
+ settings: JiraDispatchSettings,
+ options: { notifyHandler?: boolean } = {},
+): Promise {
+ const startedTasks: Array<{
+ taskId: string;
+ dispatchMode: JiraDispatchMode;
+ }> = [];
+ const launchErrors: string[] = [];
+
+ for (const batch of batches) {
+ const dispatchMode = batch.dispatchMode ?? settings.dispatchMode;
+ try {
+ const result = await sendJiraDispatch({
+ integrationId: settings.integrationId,
+ targetIds: batch.targetIds,
+ filter: batch.targetType,
+ projectKey: settings.projectKey,
+ issueType: settings.issueType,
+ dispatchMode,
+ });
+
+ if (!result.success) {
+ launchErrors.push(result.error || "Failed to send to Jira");
+ continue;
+ }
+
+ startedTasks.push({ taskId: result.taskId, dispatchMode });
+ } catch {
+ // The request may have reached the server before the RPC failed. An
+ // automatic retry could create duplicate issues.
+ launchErrors.push(
+ "The Jira dispatch status is unknown after a connection error. Check Jira before retrying.",
+ );
+ }
+ }
+
+ const trackedOutcomes = await Promise.all(
+ startedTasks.map(async ({ taskId, dispatchMode }) => {
+ let trackedTask: TaskTrackingResult;
+ try {
+ trackedTask = await trackAndPollTask({
+ taskId,
+ kind: JIRA_DISPATCH_TASK_KIND,
+ meta: buildJiraDispatchTaskMeta({
+ integrationId: settings.integrationId,
+ projectKey: settings.projectKey,
+ issueType: settings.issueType,
+ dispatchMode,
+ }),
+ notifyHandler: options.notifyHandler ?? false,
+ });
+ } catch {
+ return {
+ success: false,
+ error:
+ "Tracking the Jira dispatch failed unexpectedly. Check Jira before retrying.",
+ } satisfies JiraTrackedOutcome;
+ }
+
+ if (trackedTask.status !== TASK_WATCHER_STATUS.READY) {
+ return {
+ success: false,
+ error: trackedTask.error || "Failed to track Jira issue creation.",
+ } satisfies JiraTrackedOutcome;
+ }
+
+ const outcome = evaluateJiraDispatchTask("completed", trackedTask.result);
+ if (!outcome.success) {
+ return {
+ success: false,
+ error: outcome.error,
+ failedFindingIds: outcome.failedFindingIds,
+ } satisfies JiraTrackedOutcome;
+ }
+
+ return {
+ success: true,
+ message: outcome.message,
+ warning: outcome.warning,
+ failedFindingIds: outcome.failedFindingIds,
+ successfulCount: getJiraDispatchSuccessCount(trackedTask.result),
+ } satisfies JiraTrackedOutcome;
+ }),
+ );
+
+ const successfulOutcomes = trackedOutcomes.filter(
+ (outcome) => outcome.success,
+ );
+ const successfulIssueCount = successfulOutcomes.reduce(
+ (count, outcome) => count + (outcome.successfulCount ?? 0),
+ 0,
+ );
+ const successMessage =
+ successfulOutcomes.length === 1
+ ? successfulOutcomes[0].message
+ : successfulOutcomes.length > 1
+ ? `${successfulIssueCount} Jira issues were created or updated successfully.`
+ : undefined;
+
+ return {
+ startedTaskCount: startedTasks.length,
+ successfulTaskCount: successfulOutcomes.length,
+ successfulIssueCount,
+ successMessage,
+ warnings: Array.from(
+ new Set(
+ trackedOutcomes.flatMap((outcome) =>
+ outcome.warning ? [outcome.warning] : [],
+ ),
+ ),
+ ),
+ errors: Array.from(
+ new Set([
+ ...trackedOutcomes.flatMap((outcome) =>
+ outcome.error ? [outcome.error] : [],
+ ),
+ ...launchErrors,
+ ]),
+ ),
+ retryBatch: getJiraRetryBatch(
+ trackedOutcomes.flatMap((outcome) => outcome.failedFindingIds ?? []),
+ ),
+ };
+}
diff --git a/ui/lib/jira-dispatch-result.ts b/ui/lib/jira-dispatch-result.ts
new file mode 100644
index 0000000000..2ee30d9d84
--- /dev/null
+++ b/ui/lib/jira-dispatch-result.ts
@@ -0,0 +1,141 @@
+import type { JiraDispatchTaskResult } from "@/types/integrations";
+import type { TaskState } from "@/types/tasks";
+
+export interface JiraDispatchSuccessOutcome {
+ success: true;
+ message: string;
+ warning?: string;
+ failedFindingIds?: string[];
+}
+
+export interface JiraDispatchFailureOutcome {
+ success: false;
+ error: string;
+ failedFindingIds?: string[];
+}
+
+export type JiraDispatchOutcome =
+ | JiraDispatchSuccessOutcome
+ | JiraDispatchFailureOutcome;
+
+const getArrayCount = (value: unknown[] | undefined) =>
+ Array.isArray(value) ? value.length : 0;
+
+const getFailedCount = (result: JiraDispatchTaskResult | undefined) => {
+ if (!result) return 0;
+ return Math.max(
+ result.failed_count ?? 0,
+ getArrayCount(result.failed_groups),
+ getArrayCount(result.failed_batches),
+ getArrayCount(result.failed_finding_ids),
+ );
+};
+
+export const getJiraDispatchSuccessCount = (
+ result: JiraDispatchTaskResult | undefined,
+) => {
+ if (!result) return 0;
+
+ const createdCount = Math.max(
+ result.created_count ?? 0,
+ getArrayCount(result.created_issues),
+ );
+ const updatedCount = Math.max(
+ result.updated_count ?? 0,
+ getArrayCount(result.updated_issues),
+ );
+
+ return Math.max(
+ result.successful_count ?? 0,
+ createdCount + updatedCount,
+ result.issue_key || result.issue_url ? 1 : 0,
+ );
+};
+
+const ensureSentence = (message: string) =>
+ /[.!?]$/.test(message.trim()) ? message.trim() : `${message.trim()}.`;
+
+const buildFailureMessage = (
+ result: JiraDispatchTaskResult | undefined,
+ failedCount: number,
+) => {
+ const successCount = getJiraDispatchSuccessCount(result);
+ const summary = `Jira dispatch completed with ${failedCount} failed and ${successCount} created/updated issue${successCount === 1 ? "" : "s"}.`;
+
+ return result?.error ? `${ensureSentence(result.error)} ${summary}` : summary;
+};
+
+const buildSuccessMessage = (result: JiraDispatchTaskResult | undefined) => {
+ const successCount = getJiraDispatchSuccessCount(result);
+ if (successCount > 1) {
+ return `${successCount} Jira issues were created or updated successfully.`;
+ }
+
+ return "Finding successfully sent to Jira!";
+};
+
+const getFailedFindingIds = (result: JiraDispatchTaskResult | undefined) =>
+ Array.from(new Set(result?.failed_finding_ids?.filter(Boolean) ?? []));
+
+const withFailedFindingIds = (failedFindingIds: string[]) =>
+ failedFindingIds.length > 0 ? { failedFindingIds } : {};
+
+export const evaluateJiraDispatchTask = (
+ state: TaskState,
+ result: JiraDispatchTaskResult | null | undefined,
+): JiraDispatchOutcome => {
+ const jiraResult = result ?? undefined;
+ const failedFindingIds = getFailedFindingIds(jiraResult);
+
+ if (state === "completed") {
+ const failedCount = getFailedCount(jiraResult);
+ if (failedCount > 0) {
+ const successCount = getJiraDispatchSuccessCount(jiraResult);
+ if (successCount > 0) {
+ return {
+ success: true,
+ message: buildSuccessMessage(jiraResult),
+ warning: buildFailureMessage(jiraResult, failedCount),
+ ...withFailedFindingIds(failedFindingIds),
+ };
+ }
+
+ return {
+ success: false,
+ error: buildFailureMessage(jiraResult, failedCount),
+ ...withFailedFindingIds(failedFindingIds),
+ };
+ }
+
+ if (jiraResult?.success === false || jiraResult?.error) {
+ return {
+ success: false,
+ error: jiraResult.error || "Failed to create Jira issue.",
+ ...withFailedFindingIds(failedFindingIds),
+ };
+ }
+
+ if (!jiraResult || getJiraDispatchSuccessCount(jiraResult) === 0) {
+ return {
+ success: false,
+ error:
+ "Jira dispatch completed but did not create or update any issues.",
+ };
+ }
+
+ return {
+ success: true,
+ message: buildSuccessMessage(jiraResult),
+ };
+ }
+
+ if (state === "failed") {
+ return {
+ success: false,
+ error: jiraResult?.error || "Task failed.",
+ ...withFailedFindingIds(failedFindingIds),
+ };
+ }
+
+ return { success: false, error: `Unknown task state: ${state}` };
+};
diff --git a/ui/lib/jira-dispatch-selection.ts b/ui/lib/jira-dispatch-selection.ts
new file mode 100644
index 0000000000..acfc05cf99
--- /dev/null
+++ b/ui/lib/jira-dispatch-selection.ts
@@ -0,0 +1,101 @@
+import {
+ JIRA_TARGET_SELECTION_KIND,
+ type JiraBatchSelection,
+ type JiraDispatchTarget,
+ type JiraDispatchTargetBatch,
+ type JiraSelection,
+ type NonEmptyStringArray,
+} from "@/types/integrations";
+import type { JiraDispatchModalPayload } from "@/types/jira-dispatch";
+
+export interface JiraDispatchTargetBatchInput {
+ targetIds: string[];
+ targetType: JiraDispatchTarget;
+ dispatchMode?: JiraDispatchTargetBatch["dispatchMode"];
+}
+
+export interface CreateJiraDispatchPayloadInput
+ extends Omit {
+ targetIds: string[];
+ targetType: JiraDispatchTarget;
+}
+
+export const toNonEmptyStringArray = (
+ values: string[],
+): NonEmptyStringArray | null => {
+ const [first, ...rest] = values.filter(Boolean);
+ return first ? [first, ...rest] : null;
+};
+
+export const createJiraTargetSelection = (
+ targetIds: string[],
+ targetType: JiraDispatchTarget,
+): JiraSelection | null => {
+ const nonEmptyTargetIds = toNonEmptyStringArray(targetIds);
+ if (!nonEmptyTargetIds) return null;
+
+ if (nonEmptyTargetIds.length === 1) {
+ return {
+ kind: JIRA_TARGET_SELECTION_KIND.SINGLE,
+ targetId: nonEmptyTargetIds[0],
+ targetType,
+ };
+ }
+
+ return {
+ kind: JIRA_TARGET_SELECTION_KIND.TARGET_LIST,
+ targetIds: nonEmptyTargetIds,
+ targetType,
+ };
+};
+
+export const createJiraDispatchPayload = ({
+ targetIds,
+ targetType,
+ ...payload
+}: CreateJiraDispatchPayloadInput): JiraDispatchModalPayload | null => {
+ const selection = createJiraTargetSelection(targetIds, targetType);
+
+ return selection ? { selection, ...payload } : null;
+};
+
+export const createJiraBatchSelection = (
+ batches: JiraDispatchTargetBatchInput[],
+): JiraBatchSelection | null => {
+ const normalizedBatches = batches.flatMap((batch) => {
+ const targetIds = toNonEmptyStringArray(batch.targetIds);
+ return targetIds ? [{ ...batch, targetIds }] : [];
+ });
+ const [firstBatch, ...remainingBatches] = normalizedBatches;
+
+ return firstBatch
+ ? {
+ kind: JIRA_TARGET_SELECTION_KIND.BATCHES,
+ batches: [firstBatch, ...remainingBatches],
+ }
+ : null;
+};
+
+export const getJiraSelectionBatches = (
+ selection: JiraSelection,
+): [JiraDispatchTargetBatch, ...JiraDispatchTargetBatch[]] => {
+ if (selection.kind === JIRA_TARGET_SELECTION_KIND.BATCHES) {
+ return selection.batches;
+ }
+
+ if (selection.kind === JIRA_TARGET_SELECTION_KIND.SINGLE) {
+ return [
+ {
+ targetIds: [selection.targetId],
+ targetType: selection.targetType,
+ },
+ ];
+ }
+
+ return [
+ {
+ targetIds: selection.targetIds,
+ targetType: selection.targetType,
+ },
+ ];
+};
diff --git a/ui/lib/jira-dispatch-task.ts b/ui/lib/jira-dispatch-task.ts
new file mode 100644
index 0000000000..053455baee
--- /dev/null
+++ b/ui/lib/jira-dispatch-task.ts
@@ -0,0 +1,46 @@
+import type { WatchedTask } from "@/store/task-watcher/store";
+import {
+ JIRA_DISPATCH_MODE,
+ type JiraDispatchMode,
+} from "@/types/integrations";
+
+export interface JiraDispatchTaskMeta {
+ integrationId: string;
+ projectKey: string;
+ issueType: string;
+ dispatchMode: JiraDispatchMode;
+}
+
+export const buildJiraDispatchTaskMeta = ({
+ integrationId,
+ projectKey,
+ issueType,
+ dispatchMode,
+}: JiraDispatchTaskMeta): Record => ({
+ integrationId,
+ projectKey,
+ issueType,
+ dispatchMode,
+});
+
+export const parseJiraDispatchTaskMeta = (
+ task: WatchedTask,
+): JiraDispatchTaskMeta | null => {
+ const { integrationId, projectKey, issueType, dispatchMode } = task.meta;
+ if (
+ !integrationId ||
+ !projectKey ||
+ !issueType ||
+ (dispatchMode !== JIRA_DISPATCH_MODE.GROUPED &&
+ dispatchMode !== JIRA_DISPATCH_MODE.INDIVIDUAL)
+ ) {
+ return null;
+ }
+
+ return {
+ integrationId,
+ projectKey,
+ issueType,
+ dispatchMode,
+ };
+};
diff --git a/ui/lib/permissions.test.ts b/ui/lib/permissions.test.ts
index 828b7e8daa..00e409097d 100644
--- a/ui/lib/permissions.test.ts
+++ b/ui/lib/permissions.test.ts
@@ -22,7 +22,7 @@ describe("getRolePermissions", () => {
it("includes Manage Alerts in Prowler Cloud when role attributes provide it", () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
const permissions = getRolePermissions(attributes);
@@ -37,7 +37,7 @@ describe("getRolePermissions", () => {
it("hides Manage Alerts outside Prowler Cloud", () => {
// Given
- vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
const permissions = getRolePermissions(attributes);
diff --git a/ui/lib/permissions.ts b/ui/lib/permissions.ts
index 9a83582d4a..f34f6d6c0e 100644
--- a/ui/lib/permissions.ts
+++ b/ui/lib/permissions.ts
@@ -1,3 +1,4 @@
+import { isCloud } from "@/lib/shared/env";
import { RolePermissionAttributes } from "@/types/users";
/**
@@ -30,7 +31,7 @@ export const isUserOwnerAndHasManageAccount = (
* @returns The permissions for the user role
*/
export const getRolePermissions = (attributes: RolePermissionAttributes) => {
- const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
+ const isCloudEnvironment = isCloud();
const permissions = [
{
diff --git a/ui/lib/runtime-config.shared.ts b/ui/lib/runtime-config.shared.ts
index 6e820296d9..11aa25e52d 100644
--- a/ui/lib/runtime-config.shared.ts
+++ b/ui/lib/runtime-config.shared.ts
@@ -9,6 +9,7 @@ export interface RuntimePublicConfig {
posthogKey: string | null; // reserved
posthogHost: string | null; // reserved
reoDevClientId: string | null; // reserved
+ cloudEnabled: boolean;
cloudBillingEnabled: boolean;
stripePublishableKey: string | null; // reserved
stripePublishableKeyV2: string | null; // reserved
@@ -26,7 +27,43 @@ export const EMPTY_RUNTIME_PUBLIC_CONFIG: RuntimePublicConfig = {
posthogKey: null,
posthogHost: null,
reoDevClientId: null,
+ cloudEnabled: false,
cloudBillingEnabled: false,
stripePublishableKey: null,
stripePublishableKeyV2: null,
};
+
+// Explicit per-key copy (not a spread) so unexpected island keys can't leak through.
+const pickConfig = (
+ parsed: Partial,
+): RuntimePublicConfig => ({
+ sentryDsn: parsed.sentryDsn ?? null,
+ sentryEnvironment: parsed.sentryEnvironment ?? null,
+ googleTagManagerId: parsed.googleTagManagerId ?? null,
+ apiBaseUrl: parsed.apiBaseUrl ?? null,
+ apiDocsUrl: parsed.apiDocsUrl ?? null,
+ posthogKey: parsed.posthogKey ?? null,
+ posthogHost: parsed.posthogHost ?? null,
+ reoDevClientId: parsed.reoDevClientId ?? null,
+ cloudEnabled: parsed.cloudEnabled ?? false,
+ cloudBillingEnabled: parsed.cloudBillingEnabled ?? false,
+ stripePublishableKey: parsed.stripePublishableKey ?? null,
+ stripePublishableKeyV2: parsed.stripePublishableKeyV2 ?? null,
+});
+
+// Reads and validates the island. Null when there is no DOM (server /
+// edge), no island (jsdom unit tests), or the JSON is malformed — callers
+// choose the fallback. Deliberately uncached: a module-level cache would
+// leak state across jsdom tests.
+export function readRuntimeConfigIsland(): RuntimePublicConfig | null {
+ if (typeof document === "undefined") return null;
+ const el = document.getElementById(RUNTIME_CONFIG_SCRIPT_ID);
+ if (!el?.textContent) return null;
+ try {
+ return pickConfig(
+ JSON.parse(el.textContent) as Partial,
+ );
+ } catch {
+ return null;
+ }
+}
diff --git a/ui/lib/runtime-config.ts b/ui/lib/runtime-config.ts
index f1ef52900b..889b375ce6 100644
--- a/ui/lib/runtime-config.ts
+++ b/ui/lib/runtime-config.ts
@@ -3,8 +3,8 @@ import "server-only";
import { connection } from "next/server";
import { readGatedEnv } from "@/lib/integrations";
-import type { RuntimePublicConfig } from "@/lib/runtime-config.shared";
-import { readEnv } from "@/lib/runtime-env";
+import { type RuntimePublicConfig } from "@/lib/runtime-config.shared";
+import { readBoolEnv, readEnv } from "@/lib/runtime-env";
// `connection()` forces a per-request runtime read (never build-snapshotted);
// only this allowlist reaches the client. Each migrated key falls back to its
@@ -44,6 +44,7 @@ export async function getRuntimePublicConfig(): Promise {
"POSTHOG_HOST",
),
reoDevClientId: readEnv("REO_DEV_CLIENT_ID"),
+ cloudEnabled: readBoolEnv("UI_CLOUD_ENABLED"),
// Install-level selector "legacy" | "metronome" | "false"; the client only
// needs on/off, so expose a derived boolean (the raw selector is read
// server-side for V1/V2 routing). Default (unset) is off.
diff --git a/ui/lib/shared/env.test.ts b/ui/lib/shared/env.test.ts
new file mode 100644
index 0000000000..6154171e94
--- /dev/null
+++ b/ui/lib/shared/env.test.ts
@@ -0,0 +1,57 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { RUNTIME_CONFIG_SCRIPT_ID } from "@/lib/runtime-config.shared";
+
+import { isCloud } from "./env";
+
+const writeIsland = (content: Record | string) => {
+ const el = document.createElement("script");
+ el.id = RUNTIME_CONFIG_SCRIPT_ID;
+ el.type = "application/json";
+ el.textContent =
+ typeof content === "string" ? content : JSON.stringify(content);
+ document.head.appendChild(el);
+};
+
+describe("isCloud", () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ document.head.innerHTML = "";
+ });
+
+ describe("without an island (server / jsdom env fallback)", () => {
+ it('returns true when UI_CLOUD_ENABLED is "true"', () => {
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
+ expect(isCloud()).toBe(true);
+ });
+
+ it('returns false when UI_CLOUD_ENABLED is "false"', () => {
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
+ expect(isCloud()).toBe(false);
+ });
+
+ it("returns false when UI_CLOUD_ENABLED is unset", () => {
+ expect(isCloud()).toBe(false);
+ });
+ });
+
+ describe("with an island (browser)", () => {
+ it("uses the island flag over the env var (island true, env false)", () => {
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
+ writeIsland({ cloudEnabled: true });
+ expect(isCloud()).toBe(true);
+ });
+
+ it("uses the island flag over the env var (island false, env true)", () => {
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
+ writeIsland({ cloudEnabled: false });
+ expect(isCloud()).toBe(false);
+ });
+
+ it("falls back to the env var when the island is malformed", () => {
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
+ writeIsland("{ not valid json");
+ expect(isCloud()).toBe(true);
+ });
+ });
+});
diff --git a/ui/lib/shared/env.ts b/ui/lib/shared/env.ts
index 766394b970..398822bd02 100644
--- a/ui/lib/shared/env.ts
+++ b/ui/lib/shared/env.ts
@@ -1,14 +1,22 @@
/**
* Shared environment helpers.
*/
+import { readRuntimeConfigIsland } from "@/lib/runtime-config.shared";
+import { readBoolEnv } from "@/lib/runtime-env";
/**
* Whether the UI is running inside a Prowler Cloud deployment.
*
- * `NEXT_PUBLIC_*` vars are statically inlined by Next.js wherever the literal
- * `process.env.NEXT_PUBLIC_IS_CLOUD_ENV` appears in source, so keeping this read
- * inside a helper is safe.
+ * Runtime read, resolved from two sources:
+ * - Browser: the runtime public-config island (`cloudEnabled`), rendered in
+ * before any bundle runs, so calling this at module scope is safe.
+ * - Without a DOM (RSC, server actions, SSR, edge, Node) and jsdom tests
+ * without an island: `UI_CLOUD_ENABLED`. The island is produced from the
+ * same env var (lib/runtime-config.ts), so SSR and hydration always agree.
*/
export function isCloud(): boolean {
- return process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
+ const islandConfig = readRuntimeConfigIsland();
+ if (islandConfig) return islandConfig.cloudEnabled;
+
+ return readBoolEnv("UI_CLOUD_ENABLED");
}
diff --git a/ui/lib/tours/store/local-storage-adapter.test.ts b/ui/lib/tours/store/local-storage-adapter.test.ts
index a071b4c169..d58ef9a0f1 100644
--- a/ui/lib/tours/store/local-storage-adapter.test.ts
+++ b/ui/lib/tours/store/local-storage-adapter.test.ts
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { TOUR_COMPLETION_STATES } from "../tour-types";
+
import { buildStorageKey, localStorageAdapter } from "./local-storage-adapter";
const TOUR_ID = { id: "attack-paths", version: 1 };
diff --git a/ui/lib/tours/store/local-storage-adapter.ts b/ui/lib/tours/store/local-storage-adapter.ts
index 9ace12fd69..80154685a8 100644
--- a/ui/lib/tours/store/local-storage-adapter.ts
+++ b/ui/lib/tours/store/local-storage-adapter.ts
@@ -1,4 +1,5 @@
import type { TourCompletionRecord, TourId } from "../tour-types";
+
import type { TourCompletionStore } from "./tour-completion-store";
// All records share ONE localStorage key, keyed by `.v`.
diff --git a/ui/package.json b/ui/package.json
index d6fcf89c72..3703a594f9 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -97,8 +97,8 @@
"marked": "15.0.12",
"modern-screenshot": "4.7.0",
"nanoid": "5.1.6",
- "next": "16.2.9",
- "next-auth": "5.0.0-beta.30",
+ "next": "16.2.11",
+ "next-auth": "5.0.0-beta.32",
"next-themes": "0.2.1",
"react": "19.2.7",
"react-day-picker": "9.13.0",
@@ -132,25 +132,23 @@
"@types/react-dom": "19.2.3",
"@types/topojson-client": "3.1.5",
"@types/topojson-specification": "1.0.5",
- "@typescript-eslint/eslint-plugin": "8.53.0",
- "@typescript-eslint/parser": "8.53.0",
"@vitejs/plugin-react": "5.1.2",
- "@vitest/browser": "4.1.8",
- "@vitest/browser-playwright": "4.1.8",
- "@vitest/coverage-v8": "4.1.8",
+ "@vitest/browser": "4.1.10",
+ "@vitest/browser-playwright": "4.1.10",
+ "@vitest/coverage-v8": "4.1.10",
"babel-plugin-react-compiler": "1.0.0",
"dotenv": "16.6.1",
"dotenv-expand": "12.0.3",
"eslint": "9.39.2",
"eslint-config-prettier": "10.1.5",
+ "eslint-import-resolver-typescript": "4.4.4",
+ "eslint-plugin-import-x": "4.16.2",
"eslint-plugin-jsx-a11y": "6.10.2",
- "eslint-plugin-prettier": "5.5.1",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.0.1",
"eslint-plugin-security": "3.0.1",
- "eslint-plugin-simple-import-sort": "12.1.1",
- "eslint-plugin-unused-imports": "4.3.0",
"globals": "17.0.0",
+ "jiti": "2.7.0",
"jsdom": "27.4.0",
"knip": "6.3.1",
"msw": "2.13.4",
@@ -160,7 +158,8 @@
"prettier-plugin-tailwindcss": "0.6.14",
"tailwindcss": "4.1.18",
"typescript": "5.5.4",
- "vitest": "4.1.8",
+ "typescript-eslint": "8.59.3",
+ "vitest": "4.1.10",
"vitest-browser-react": "2.0.4"
},
"packageManager": "pnpm@11.1.3+sha512.c85357fe17ca12dd23dd7071822666dfd7e3cb76fe214e3370b5ea2fb34f2a231185509b63e717f3cd0acb38dd3f8d82bcd5e8172400ae678b70ea4fbed0896d",
diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml
index f15c1cc800..43aab5c37f 100644
--- a/ui/pnpm-lock.yaml
+++ b/ui/pnpm-lock.yaml
@@ -83,7 +83,7 @@ importers:
version: 1.2.3
'@next/third-parties':
specifier: 16.2.9
- version: 16.2.9(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)
+ version: 16.2.9(next@16.2.11(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)
'@radix-ui/react-alert-dialog':
specifier: 1.1.14
version: 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -158,7 +158,7 @@ importers:
version: 3.26.0(react@19.2.7)
'@sentry/nextjs':
specifier: 10.65.0
- version: 10.65.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(webpack@5.104.1(lightningcss@1.30.2)(postcss@8.5.14))
+ version: 10.65.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))(next@16.2.11(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(webpack@5.104.1(lightningcss@1.30.2)(postcss@8.5.14))
'@tailwindcss/postcss':
specifier: 4.1.18
version: 4.1.18
@@ -229,14 +229,14 @@ importers:
specifier: 5.1.6
version: 5.1.6
next:
- specifier: 16.2.9
- version: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ specifier: 16.2.11
+ version: 16.2.11(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
next-auth:
- specifier: 5.0.0-beta.30
- version: 5.0.0-beta.30(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)
+ specifier: 5.0.0-beta.32
+ version: 5.0.0-beta.32(next@16.2.11(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)
next-themes:
specifier: 0.2.1
- version: 0.2.1(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ version: 0.2.1(next@16.2.11(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react:
specifier: 19.2.7
version: 19.2.7
@@ -328,24 +328,18 @@ importers:
'@types/topojson-specification':
specifier: 1.0.5
version: 1.0.5
- '@typescript-eslint/eslint-plugin':
- specifier: 8.53.0
- version: 8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4))(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4)
- '@typescript-eslint/parser':
- specifier: 8.53.0
- version: 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4)
'@vitejs/plugin-react':
specifier: 5.1.2
- version: 5.1.2(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
+ version: 5.1.2(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
'@vitest/browser':
- specifier: 4.1.8
- version: 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.8)
+ specifier: 4.1.10
+ version: 4.1.10(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.10)
'@vitest/browser-playwright':
- specifier: 4.1.8
- version: 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.8)
+ specifier: 4.1.10
+ version: 4.1.10(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.10)
'@vitest/coverage-v8':
- specifier: 4.1.8
- version: 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)
+ specifier: 4.1.10
+ version: 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10)
babel-plugin-react-compiler:
specifier: 1.0.0
version: 1.0.0
@@ -357,40 +351,40 @@ importers:
version: 12.0.3
eslint:
specifier: 9.39.2
- version: 9.39.2(jiti@2.6.1)
+ version: 9.39.2(jiti@2.7.0)
eslint-config-prettier:
specifier: 10.1.5
- version: 10.1.5(eslint@9.39.2(jiti@2.6.1))
+ version: 10.1.5(eslint@9.39.2(jiti@2.7.0))
+ eslint-import-resolver-typescript:
+ specifier: 4.4.4
+ version: 4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4))(eslint@9.39.2(jiti@2.7.0)))(eslint@9.39.2(jiti@2.7.0))
+ eslint-plugin-import-x:
+ specifier: 4.16.2
+ version: 4.16.2(@typescript-eslint/utils@8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4))(eslint@9.39.2(jiti@2.7.0))
eslint-plugin-jsx-a11y:
specifier: 6.10.2
- version: 6.10.2(eslint@9.39.2(jiti@2.6.1))
- eslint-plugin-prettier:
- specifier: 5.5.1
- version: 5.5.1(@types/eslint@9.6.1)(eslint-config-prettier@10.1.5(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.6.2)
+ version: 6.10.2(eslint@9.39.2(jiti@2.7.0))
eslint-plugin-react:
specifier: 7.37.5
- version: 7.37.5(eslint@9.39.2(jiti@2.6.1))
+ version: 7.37.5(eslint@9.39.2(jiti@2.7.0))
eslint-plugin-react-hooks:
specifier: 7.0.1
- version: 7.0.1(eslint@9.39.2(jiti@2.6.1))
+ version: 7.0.1(eslint@9.39.2(jiti@2.7.0))
eslint-plugin-security:
specifier: 3.0.1
version: 3.0.1
- eslint-plugin-simple-import-sort:
- specifier: 12.1.1
- version: 12.1.1(eslint@9.39.2(jiti@2.6.1))
- eslint-plugin-unused-imports:
- specifier: 4.3.0
- version: 4.3.0(@typescript-eslint/eslint-plugin@8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4))(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4))(eslint@9.39.2(jiti@2.6.1))
globals:
specifier: 17.0.0
version: 17.0.0
+ jiti:
+ specifier: 2.7.0
+ version: 2.7.0
jsdom:
specifier: 27.4.0
version: 27.4.0
knip:
specifier: 6.3.1
- version: 6.3.1(@emnapi/core@1.8.1)(@emnapi/runtime@1.10.0)
+ version: 6.3.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
msw:
specifier: 2.13.4
version: 2.13.4(@types/node@24.10.8)(typescript@5.5.4)
@@ -412,12 +406,15 @@ importers:
typescript:
specifier: 5.5.4
version: 5.5.4
+ typescript-eslint:
+ specifier: 8.59.3
+ version: 8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4)
vitest:
- specifier: 4.1.8
- version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
+ specifier: 4.1.10
+ version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
vitest-browser-react:
specifier: 2.0.4
- version: 2.0.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.8)
+ version: 2.0.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.10)
packages:
@@ -476,12 +473,12 @@ packages:
'@asamuzakjp/nwsapi@2.3.9':
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
- '@auth/core@0.41.0':
- resolution: {integrity: sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==}
+ '@auth/core@0.41.3':
+ resolution: {integrity: sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==}
peerDependencies:
'@simplewebauthn/browser': ^9.0.1
'@simplewebauthn/server': ^9.0.2
- nodemailer: ^6.8.0
+ nodemailer: ^7.0.7 || ^8.0.5
peerDependenciesMeta:
'@simplewebauthn/browser':
optional: true
@@ -820,14 +817,14 @@ packages:
'@date-fns/tz@1.4.1':
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
- '@emnapi/core@1.8.1':
- resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
+ '@emnapi/core@1.10.0':
+ resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
'@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
- '@emnapi/wasi-threads@1.1.0':
- resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
+ '@emnapi/wasi-threads@1.2.1':
+ resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
'@esbuild/aix-ppc64@0.28.1':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
@@ -1514,60 +1511,60 @@ packages:
'@ndaidong/bellajs@12.0.1':
resolution: {integrity: sha512-1iY42uiHz0cxNMbde7O3zVN+ZX1viOOUOBRt6ht6lkRZbSjwOnFV34Zv4URp3hGzEe6L9Byk7BOq/41H0PzAOQ==}
- '@next/env@16.2.9':
- resolution: {integrity: sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==}
+ '@next/env@16.2.11':
+ resolution: {integrity: sha512-0do5A3BJ2gxWr0ZCMcD6BhW+e595jyxdTl3rXTS6lOtD8ektMiW6CO+EPwt1Eca1DBnm90r/7GdiKWBKxH++DA==}
'@next/eslint-plugin-next@16.2.9':
resolution: {integrity: sha512-UZi8+YT/MLgTC9nrrn2Xd4lBYv1B7lVmtWHfPcthAI5Tt/C1LuDe6DfmtCtJ+WQod3ksY4VrKSvk3oMVAnL7qw==}
- '@next/swc-darwin-arm64@16.2.9':
- resolution: {integrity: sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==}
+ '@next/swc-darwin-arm64@16.2.11':
+ resolution: {integrity: sha512-wryL4pjKmDwGv2ox6+GZDFxvmtSRLqApBR8kL1j4+vhB7Z5vJC/zAnXpiR9Xkfzl0AS8WLMnsuGV/UKI67/rrw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
- '@next/swc-darwin-x64@16.2.9':
- resolution: {integrity: sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==}
+ '@next/swc-darwin-x64@16.2.11':
+ resolution: {integrity: sha512-aZl2j4f/fLyjQvOhv0Oe9UaMAQHolYpKhctsoYzplSumKJKPUmgjcf6545aBtysLTcu994TREd0+pSgNE4ohmg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
- '@next/swc-linux-arm64-gnu@16.2.9':
- resolution: {integrity: sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==}
+ '@next/swc-linux-arm64-gnu@16.2.11':
+ resolution: {integrity: sha512-5jEriyEnH/LWFy27L2ZG0XaLlyEJIjhsImEsiS9P563PKEVp2BVups/xfOucIrsvVntp11oNcZwjHvaDPYVB5g==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@next/swc-linux-arm64-musl@16.2.9':
- resolution: {integrity: sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==}
+ '@next/swc-linux-arm64-musl@16.2.11':
+ resolution: {integrity: sha512-eIjcpx2fnnFSSkZDbTxy74KnokUXDjfoLClpWelfgHLf621aTqswhwXQ7GkD5K5rplrS6LZ/Bj+mVuvzluBOEg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@next/swc-linux-x64-gnu@16.2.9':
- resolution: {integrity: sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==}
+ '@next/swc-linux-x64-gnu@16.2.11':
+ resolution: {integrity: sha512-8WgzpaWMs46qJT9kiV47cje86L0x/Mu9t8/Gwj+pnbgW3rETVfCnaScPjlYUwNScpOozdcIMHWmAvuZJUonR2w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@next/swc-linux-x64-musl@16.2.9':
- resolution: {integrity: sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==}
+ '@next/swc-linux-x64-musl@16.2.11':
+ resolution: {integrity: sha512-I3UgPds7G4ZYnTb/H+5GBGuUT2DhAk6j0mL6A4s63RjFs74wB2hOWP0vaxsK+3NJraExt3eYEPQ/UtT0x/64Nw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
- '@next/swc-win32-arm64-msvc@16.2.9':
- resolution: {integrity: sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==}
+ '@next/swc-win32-arm64-msvc@16.2.11':
+ resolution: {integrity: sha512-n89CjtcThnjrwgJMAiI5xbqwLY51zvwC9tSlArmVndAJLYVl9T9UAdlkXTmZvE++idoXe8KdglQlhNRdUp1c6g==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
- '@next/swc-win32-x64-msvc@16.2.9':
- resolution: {integrity: sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==}
+ '@next/swc-win32-x64-msvc@16.2.11':
+ resolution: {integrity: sha512-md8CLNggS1Dx9pUgApzps5uAf+N8GN9xywzmNx9vHAWo94HtBwCCqkSnhIrdfQe83Dhz8Lfo/20Nb1Zxal092w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -1885,13 +1882,12 @@ packages:
cpu: [x64]
os: [win32]
+ '@package-json/types@0.0.12':
+ resolution: {integrity: sha512-uu43FGU34B5VM9mCNjXCwLaGHYjXdNincqKLaraaCW+7S2+SmiBg1Nv8bPnmschrIfZmfKNY9f3fC376MRrObw==}
+
'@panva/hkdf@1.2.1':
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
- '@pkgr/core@0.2.9':
- resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
- engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
-
'@playwright/test@1.56.1':
resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==}
engines: {node: '>=18'}
@@ -3480,63 +3476,67 @@ packages:
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
- '@typescript-eslint/eslint-plugin@8.53.0':
- resolution: {integrity: sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==}
+ '@typescript-eslint/eslint-plugin@8.59.3':
+ resolution: {integrity: sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- '@typescript-eslint/parser': ^8.53.0
- eslint: ^8.57.0 || ^9.0.0
- typescript: '>=4.8.4 <6.0.0'
+ '@typescript-eslint/parser': ^8.59.3
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/parser@8.53.0':
- resolution: {integrity: sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==}
+ '@typescript-eslint/parser@8.59.3':
+ resolution: {integrity: sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- eslint: ^8.57.0 || ^9.0.0
- typescript: '>=4.8.4 <6.0.0'
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/project-service@8.53.0':
- resolution: {integrity: sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==}
+ '@typescript-eslint/project-service@8.59.3':
+ resolution: {integrity: sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- typescript: '>=4.8.4 <6.0.0'
+ typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/scope-manager@8.53.0':
- resolution: {integrity: sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==}
+ '@typescript-eslint/scope-manager@8.59.3':
+ resolution: {integrity: sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript-eslint/tsconfig-utils@8.53.0':
- resolution: {integrity: sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==}
+ '@typescript-eslint/tsconfig-utils@8.59.3':
+ resolution: {integrity: sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- typescript: '>=4.8.4 <6.0.0'
+ typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/type-utils@8.53.0':
- resolution: {integrity: sha512-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw==}
+ '@typescript-eslint/type-utils@8.59.3':
+ resolution: {integrity: sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- eslint: ^8.57.0 || ^9.0.0
- typescript: '>=4.8.4 <6.0.0'
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/types@8.53.0':
- resolution: {integrity: sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==}
+ '@typescript-eslint/types@8.59.3':
+ resolution: {integrity: sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript-eslint/typescript-estree@8.53.0':
- resolution: {integrity: sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==}
+ '@typescript-eslint/types@8.64.0':
+ resolution: {integrity: sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/typescript-estree@8.59.3':
+ resolution: {integrity: sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- typescript: '>=4.8.4 <6.0.0'
+ typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/utils@8.53.0':
- resolution: {integrity: sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA==}
+ '@typescript-eslint/utils@8.59.3':
+ resolution: {integrity: sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- eslint: ^8.57.0 || ^9.0.0
- typescript: '>=4.8.4 <6.0.0'
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/visitor-keys@8.53.0':
- resolution: {integrity: sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==}
+ '@typescript-eslint/visitor-keys@8.59.3':
+ resolution: {integrity: sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@uiw/codemirror-extensions-basic-setup@4.25.8':
@@ -3565,6 +3565,126 @@ packages:
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
+ '@unrs/resolver-binding-android-arm-eabi@1.12.2':
+ resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==}
+ cpu: [arm]
+ os: [android]
+
+ '@unrs/resolver-binding-android-arm64@1.12.2':
+ resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==}
+ cpu: [arm64]
+ os: [android]
+
+ '@unrs/resolver-binding-darwin-arm64@1.12.2':
+ resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@unrs/resolver-binding-darwin-x64@1.12.2':
+ resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@unrs/resolver-binding-freebsd-x64@1.12.2':
+ resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2':
+ resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==}
+ cpu: [arm]
+ os: [linux]
+
+ '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2':
+ resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==}
+ cpu: [arm]
+ os: [linux]
+
+ '@unrs/resolver-binding-linux-arm64-gnu@1.12.2':
+ resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@unrs/resolver-binding-linux-arm64-musl@1.12.2':
+ resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@unrs/resolver-binding-linux-loong64-gnu@1.12.2':
+ resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [glibc]
+
+ '@unrs/resolver-binding-linux-loong64-musl@1.12.2':
+ resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [musl]
+
+ '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2':
+ resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2':
+ resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@unrs/resolver-binding-linux-riscv64-musl@1.12.2':
+ resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [musl]
+
+ '@unrs/resolver-binding-linux-s390x-gnu@1.12.2':
+ resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@unrs/resolver-binding-linux-x64-gnu@1.12.2':
+ resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@unrs/resolver-binding-linux-x64-musl@1.12.2':
+ resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@unrs/resolver-binding-openharmony-arm64@1.12.2':
+ resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@unrs/resolver-binding-wasm32-wasi@1.12.2':
+ resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+
+ '@unrs/resolver-binding-win32-arm64-msvc@1.12.2':
+ resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@unrs/resolver-binding-win32-ia32-msvc@1.12.2':
+ resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@unrs/resolver-binding-win32-x64-msvc@1.12.2':
+ resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==}
+ cpu: [x64]
+ os: [win32]
+
'@upsetjs/venn.js@2.0.0':
resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==}
@@ -3578,31 +3698,31 @@ packages:
peerDependencies:
vite: 7.3.5
- '@vitest/browser-playwright@4.1.8':
- resolution: {integrity: sha512-SR7FqgegaexEg73xvf3ArtygXegagMdXnL0EZMpxrWvvhQxvicD/E8p0ib0J91riPRtQUViyh67Xjw3NqvyhVg==}
+ '@vitest/browser-playwright@4.1.10':
+ resolution: {integrity: sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==}
peerDependencies:
playwright: '*'
- vitest: 4.1.8
+ vitest: 4.1.10
- '@vitest/browser@4.1.8':
- resolution: {integrity: sha512-u21VzX07HzlJYpFgkxmjEXar/tG2UqWGgyGG/46SrrPc7rSdCTPw5vuowopO9CIqF8UCUQzDFdbVnNpw6N0BfQ==}
+ '@vitest/browser@4.1.10':
+ resolution: {integrity: sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==}
peerDependencies:
- vitest: 4.1.8
+ vitest: 4.1.10
- '@vitest/coverage-v8@4.1.8':
- resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==}
+ '@vitest/coverage-v8@4.1.10':
+ resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==}
peerDependencies:
- '@vitest/browser': 4.1.8
- vitest: 4.1.8
+ '@vitest/browser': 4.1.10
+ vitest: 4.1.10
peerDependenciesMeta:
'@vitest/browser':
optional: true
- '@vitest/expect@4.1.8':
- resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==}
+ '@vitest/expect@4.1.10':
+ resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==}
- '@vitest/mocker@4.1.8':
- resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==}
+ '@vitest/mocker@4.1.10':
+ resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==}
peerDependencies:
msw: ^2.4.9
vite: 7.3.5
@@ -3612,20 +3732,20 @@ packages:
vite:
optional: true
- '@vitest/pretty-format@4.1.8':
- resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==}
+ '@vitest/pretty-format@4.1.10':
+ resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==}
- '@vitest/runner@4.1.8':
- resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==}
+ '@vitest/runner@4.1.10':
+ resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==}
- '@vitest/snapshot@4.1.8':
- resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==}
+ '@vitest/snapshot@4.1.10':
+ resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==}
- '@vitest/spy@4.1.8':
- resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==}
+ '@vitest/spy@4.1.10':
+ resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==}
- '@vitest/utils@4.1.8':
- resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==}
+ '@vitest/utils@4.1.10':
+ resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==}
'@webassemblyjs/ast@1.14.1':
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
@@ -4002,6 +4122,10 @@ packages:
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
engines: {node: '>= 12'}
+ comment-parser@1.4.7:
+ resolution: {integrity: sha512-0h+uSNtQGW3D98eQt3jJ8L06Fves8hncB4V/PKdw/Qb8Hnk19VaKuTr55UNRYiSoVa7WwrFls+rh3ux9agmkeQ==}
+ engines: {node: '>= 12.0.0'}
+
commondir@1.0.1:
resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==}
@@ -4449,26 +4573,47 @@ packages:
peerDependencies:
eslint: '>=7.0.0'
+ eslint-import-context@0.1.9:
+ resolution: {integrity: sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==}
+ engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
+ peerDependencies:
+ unrs-resolver: ^1.0.0
+ peerDependenciesMeta:
+ unrs-resolver:
+ optional: true
+
+ eslint-import-resolver-typescript@4.4.4:
+ resolution: {integrity: sha512-1iM2zeBvrYmUNTj2vSC/90JTHDth+dfOfiNKkxApWRsTJYNrc8rOdxxIf5vazX+BiAXTeOT0UvWpGI/7qIWQOw==}
+ engines: {node: ^16.17.0 || >=18.6.0}
+ peerDependencies:
+ eslint: '*'
+ eslint-plugin-import: '*'
+ eslint-plugin-import-x: '*'
+ peerDependenciesMeta:
+ eslint-plugin-import:
+ optional: true
+ eslint-plugin-import-x:
+ optional: true
+
+ eslint-plugin-import-x@4.16.2:
+ resolution: {integrity: sha512-rM9K8UBHcWKpzQzStn1YRN2T5NvdeIfSVoKu/lKF41znQXHAUcBbYXe5wd6GNjZjTrP7viQ49n1D83x/2gYgIw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ '@typescript-eslint/utils': ^8.56.0
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ eslint-import-resolver-node: '*'
+ peerDependenciesMeta:
+ '@typescript-eslint/utils':
+ optional: true
+ eslint-import-resolver-node:
+ optional: true
+
eslint-plugin-jsx-a11y@6.10.2:
resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==}
engines: {node: '>=4.0'}
peerDependencies:
eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9
- eslint-plugin-prettier@5.5.1:
- resolution: {integrity: sha512-dobTkHT6XaEVOo8IO90Q4DOSxnm3Y151QxPJlM/vKC0bVy+d6cVWQZLlFiuZPP0wS6vZwSKeJgKkcS+KfMBlRw==}
- engines: {node: ^14.18.0 || >=16.0.0}
- peerDependencies:
- '@types/eslint': '>=8.0.0'
- eslint: '>=8.0.0'
- eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0'
- prettier: '>=3.0.0'
- peerDependenciesMeta:
- '@types/eslint':
- optional: true
- eslint-config-prettier:
- optional: true
-
eslint-plugin-react-hooks@7.0.1:
resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==}
engines: {node: '>=18'}
@@ -4485,20 +4630,6 @@ packages:
resolution: {integrity: sha512-XjVGBhtDZJfyuhIxnQ/WMm385RbX3DBu7H1J7HNNhmB2tnGxMeqVSnYv79oAj992ayvIBZghsymwkYFS6cGH4Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- eslint-plugin-simple-import-sort@12.1.1:
- resolution: {integrity: sha512-6nuzu4xwQtE3332Uz0to+TxDQYRLTKRESSc2hefVT48Zc8JthmN23Gx9lnYhu0FtkRSL1oxny3kJ2aveVhmOVA==}
- peerDependencies:
- eslint: '>=5.0.0'
-
- eslint-plugin-unused-imports@4.3.0:
- resolution: {integrity: sha512-ZFBmXMGBYfHttdRtOG9nFFpmUvMtbHSjsKrS20vdWdbfiVYsO3yA2SGYy9i9XmZJDfMGBflZGBCm70SEnFQtOA==}
- peerDependencies:
- '@typescript-eslint/eslint-plugin': ^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0
- eslint: ^9.0.0 || ^8.0.0
- peerDependenciesMeta:
- '@typescript-eslint/eslint-plugin':
- optional: true
-
eslint-scope@5.1.1:
resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==}
engines: {node: '>=8.0.0'}
@@ -4515,6 +4646,10 @@ packages:
resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ eslint-visitor-keys@5.0.1:
+ resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
eslint@9.39.2:
resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4603,9 +4738,6 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
- fast-diff@1.3.0:
- resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
-
fast-equals@5.4.0:
resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
engines: {node: '>=6.0.0'}
@@ -5008,6 +5140,9 @@ packages:
resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
engines: {node: '>= 0.4'}
+ is-bun-module@2.0.0:
+ resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==}
+
is-callable@1.2.7:
resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
engines: {node: '>= 0.4'}
@@ -5152,6 +5287,10 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
+ jiti@2.7.0:
+ resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
+ hasBin: true
+
jose@6.1.3:
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
@@ -5707,6 +5846,11 @@ packages:
engines: {node: ^18 || >=20}
hasBin: true
+ napi-postinstall@0.3.4:
+ resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
+ engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
+ hasBin: true
+
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
@@ -5717,13 +5861,13 @@ packages:
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
- next-auth@5.0.0-beta.30:
- resolution: {integrity: sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==}
+ next-auth@5.0.0-beta.32:
+ resolution: {integrity: sha512-CGlChIEWZ6LltNVxrE5yiySMID+Idpmry47JYA5lLwgD8Sx02a8M65VL0TWVz9nbnOioS/tCW/rP/0+mE7Qp4Q==}
peerDependencies:
'@simplewebauthn/browser': ^9.0.1
'@simplewebauthn/server': ^9.0.2
next: ^14.0.0-0 || ^15.0.0 || ^16.0.0
- nodemailer: ^7.0.7
+ nodemailer: ^7.0.7 || ^8.0.5
react: ^18.2.0 || ^19.0.0
peerDependenciesMeta:
'@simplewebauthn/browser':
@@ -5740,8 +5884,8 @@ packages:
react: '*'
react-dom: '*'
- next@16.2.9:
- resolution: {integrity: sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==}
+ next@16.2.11:
+ resolution: {integrity: sha512-B339zaqbyK8cmxhoAvLrcwoabwCP1wz21zSzfqxqXAemTu2BXnH7tQnfcglKv1vnMUIDBc+Hth7XODQriTZiRQ==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -5999,10 +6143,6 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
- prettier-linter-helpers@1.0.1:
- resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==}
- engines: {node: '>=6.0.0'}
-
prettier-plugin-packagejson@2.5.22:
resolution: {integrity: sha512-G6WalmoUssKF8ZXkni0+n4324K+gG143KPysSQNW+FrR0XyNb3BdRxchGC/Q1FE/F702p7/6KU7r4mv0WSWbzA==}
peerDependencies:
@@ -6478,6 +6618,10 @@ packages:
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
+ stable-hash-x@0.2.0:
+ resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==}
+ engines: {node: '>=12.0.0'}
+
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
@@ -6598,10 +6742,6 @@ packages:
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
- synckit@0.11.12:
- resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==}
- engines: {node: ^14.18.0 || >=16.0.0}
-
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
@@ -6686,10 +6826,6 @@ packages:
resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==}
engines: {node: '>=18'}
- tinyglobby@0.2.16:
- resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
- engines: {node: '>=12.0.0'}
-
tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
@@ -6738,8 +6874,8 @@ packages:
trough@2.2.0:
resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
- ts-api-utils@2.4.0:
- resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
+ ts-api-utils@2.5.0:
+ resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
engines: {node: '>=18.12'}
peerDependencies:
typescript: '>=4.8.4'
@@ -6783,6 +6919,13 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'}
+ typescript-eslint@8.59.3:
+ resolution: {integrity: sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
typescript@5.5.4:
resolution: {integrity: sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==}
engines: {node: '>=14.17'}
@@ -6830,6 +6973,9 @@ packages:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
+ unrs-resolver@1.12.2:
+ resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==}
+
until-async@3.0.2:
resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==}
@@ -6949,20 +7095,20 @@ packages:
'@types/react-dom':
optional: true
- vitest@4.1.8:
- resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==}
+ vitest@4.1.10:
+ resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
- '@vitest/browser-playwright': 4.1.8
- '@vitest/browser-preview': 4.1.8
- '@vitest/browser-webdriverio': 4.1.8
- '@vitest/coverage-istanbul': 4.1.8
- '@vitest/coverage-v8': 4.1.8
- '@vitest/ui': 4.1.8
+ '@vitest/browser-playwright': 4.1.10
+ '@vitest/browser-preview': 4.1.10
+ '@vitest/browser-webdriverio': 4.1.10
+ '@vitest/coverage-istanbul': 4.1.10
+ '@vitest/coverage-v8': 4.1.10
+ '@vitest/ui': 4.1.10
happy-dom: '*'
jsdom: '*'
vite: 7.3.5
@@ -7264,7 +7410,7 @@ snapshots:
'@asamuzakjp/nwsapi@2.3.9': {}
- '@auth/core@0.41.0':
+ '@auth/core@0.41.3':
dependencies:
'@panva/hkdf': 1.2.1
jose: 6.1.3
@@ -8002,9 +8148,9 @@ snapshots:
'@date-fns/tz@1.4.1': {}
- '@emnapi/core@1.8.1':
+ '@emnapi/core@1.10.0':
dependencies:
- '@emnapi/wasi-threads': 1.1.0
+ '@emnapi/wasi-threads': 1.2.1
tslib: 2.8.1
optional: true
@@ -8013,7 +8159,7 @@ snapshots:
tslib: 2.8.1
optional: true
- '@emnapi/wasi-threads@1.1.0':
+ '@emnapi/wasi-threads@1.2.1':
dependencies:
tslib: 2.8.1
optional: true
@@ -8096,9 +8242,9 @@ snapshots:
'@esbuild/win32-x64@0.28.1':
optional: true
- '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))':
+ '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.7.0))':
dependencies:
- eslint: 9.39.2(jiti@2.6.1)
+ eslint: 9.39.2(jiti@2.7.0)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {}
@@ -8575,48 +8721,48 @@ snapshots:
outvariant: 1.4.3
strict-event-emitter: 0.5.1
- '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.8.1)(@emnapi/runtime@1.10.0)':
+ '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
- '@emnapi/core': 1.8.1
+ '@emnapi/core': 1.10.0
'@emnapi/runtime': 1.10.0
'@tybys/wasm-util': 0.10.1
optional: true
'@ndaidong/bellajs@12.0.1': {}
- '@next/env@16.2.9': {}
+ '@next/env@16.2.11': {}
'@next/eslint-plugin-next@16.2.9':
dependencies:
fast-glob: 3.3.1
- '@next/swc-darwin-arm64@16.2.9':
+ '@next/swc-darwin-arm64@16.2.11':
optional: true
- '@next/swc-darwin-x64@16.2.9':
+ '@next/swc-darwin-x64@16.2.11':
optional: true
- '@next/swc-linux-arm64-gnu@16.2.9':
+ '@next/swc-linux-arm64-gnu@16.2.11':
optional: true
- '@next/swc-linux-arm64-musl@16.2.9':
+ '@next/swc-linux-arm64-musl@16.2.11':
optional: true
- '@next/swc-linux-x64-gnu@16.2.9':
+ '@next/swc-linux-x64-gnu@16.2.11':
optional: true
- '@next/swc-linux-x64-musl@16.2.9':
+ '@next/swc-linux-x64-musl@16.2.11':
optional: true
- '@next/swc-win32-arm64-msvc@16.2.9':
+ '@next/swc-win32-arm64-msvc@16.2.11':
optional: true
- '@next/swc-win32-x64-msvc@16.2.9':
+ '@next/swc-win32-x64-msvc@16.2.11':
optional: true
- '@next/third-parties@16.2.9(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)':
+ '@next/third-parties@16.2.9(next@16.2.11(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)':
dependencies:
- next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ next: 16.2.11(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react: 19.2.7
third-party-capital: 1.0.20
@@ -8736,9 +8882,9 @@ snapshots:
'@oxc-parser/binding-openharmony-arm64@0.121.0':
optional: true
- '@oxc-parser/binding-wasm32-wasi@0.121.0(@emnapi/core@1.8.1)(@emnapi/runtime@1.10.0)':
+ '@oxc-parser/binding-wasm32-wasi@0.121.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
- '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.8.1)(@emnapi/runtime@1.10.0)
+ '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
@@ -8803,9 +8949,9 @@ snapshots:
'@oxc-resolver/binding-openharmony-arm64@11.19.1':
optional: true
- '@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.8.1)(@emnapi/runtime@1.10.0)':
+ '@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
- '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.8.1)(@emnapi/runtime@1.10.0)
+ '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
@@ -8820,9 +8966,9 @@ snapshots:
'@oxc-resolver/binding-win32-x64-msvc@11.19.1':
optional: true
- '@panva/hkdf@1.2.1': {}
+ '@package-json/types@0.0.12': {}
- '@pkgr/core@0.2.9': {}
+ '@panva/hkdf@1.2.1': {}
'@playwright/test@1.56.1':
dependencies:
@@ -9583,10 +9729,10 @@ snapshots:
'@rollup/pluginutils': 5.3.0(rollup@4.59.0)
commondir: 1.0.1
estree-walker: 2.0.2
- fdir: 6.5.0(picomatch@4.0.4)
+ fdir: 6.5.0(picomatch@4.0.5)
is-reference: 1.2.1
magic-string: 0.30.21
- picomatch: 4.0.4
+ picomatch: 4.0.5
optionalDependencies:
rollup: 4.59.0
@@ -9594,7 +9740,7 @@ snapshots:
dependencies:
'@types/estree': 1.0.9
estree-walker: 2.0.2
- picomatch: 4.0.4
+ picomatch: 4.0.5
optionalDependencies:
rollup: 4.59.0
@@ -9772,7 +9918,7 @@ snapshots:
dependencies:
'@sentry/core': 10.65.0
- '@sentry/nextjs@10.65.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(webpack@5.104.1(lightningcss@1.30.2)(postcss@8.5.14))':
+ '@sentry/nextjs@10.65.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))(next@16.2.11(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(webpack@5.104.1(lightningcss@1.30.2)(postcss@8.5.14))':
dependencies:
'@opentelemetry/api': 1.9.1
'@rollup/plugin-commonjs': 28.0.1(rollup@4.59.0)
@@ -9785,7 +9931,7 @@ snapshots:
'@sentry/react': 10.65.0(react@19.2.7)
'@sentry/vercel-edge': 10.65.0
'@sentry/webpack-plugin': 5.4.0(rollup@4.59.0)(webpack@5.104.1(lightningcss@1.30.2)(postcss@8.5.14))
- next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ next: 16.2.11(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
rollup: 4.59.0
stacktrace-parser: 0.1.11
transitivePeerDependencies:
@@ -10469,96 +10615,98 @@ snapshots:
'@types/unist@3.0.3': {}
- '@typescript-eslint/eslint-plugin@8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4))(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4)':
+ '@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4))(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4)
- '@typescript-eslint/scope-manager': 8.53.0
- '@typescript-eslint/type-utils': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4)
- '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4)
- '@typescript-eslint/visitor-keys': 8.53.0
- eslint: 9.39.2(jiti@2.6.1)
+ '@typescript-eslint/parser': 8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4)
+ '@typescript-eslint/scope-manager': 8.59.3
+ '@typescript-eslint/type-utils': 8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4)
+ '@typescript-eslint/utils': 8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4)
+ '@typescript-eslint/visitor-keys': 8.59.3
+ eslint: 9.39.2(jiti@2.7.0)
ignore: 7.0.5
natural-compare: 1.4.0
- ts-api-utils: 2.4.0(typescript@5.5.4)
+ ts-api-utils: 2.5.0(typescript@5.5.4)
typescript: 5.5.4
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4)':
+ '@typescript-eslint/parser@8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4)':
dependencies:
- '@typescript-eslint/scope-manager': 8.53.0
- '@typescript-eslint/types': 8.53.0
- '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.5.4)
- '@typescript-eslint/visitor-keys': 8.53.0
+ '@typescript-eslint/scope-manager': 8.59.3
+ '@typescript-eslint/types': 8.59.3
+ '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.5.4)
+ '@typescript-eslint/visitor-keys': 8.59.3
debug: 4.4.3
- eslint: 9.39.2(jiti@2.6.1)
+ eslint: 9.39.2(jiti@2.7.0)
typescript: 5.5.4
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/project-service@8.53.0(typescript@5.5.4)':
+ '@typescript-eslint/project-service@8.59.3(typescript@5.5.4)':
dependencies:
- '@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.5.4)
- '@typescript-eslint/types': 8.53.0
+ '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@5.5.4)
+ '@typescript-eslint/types': 8.59.3
debug: 4.4.3
typescript: 5.5.4
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/scope-manager@8.53.0':
+ '@typescript-eslint/scope-manager@8.59.3':
dependencies:
- '@typescript-eslint/types': 8.53.0
- '@typescript-eslint/visitor-keys': 8.53.0
+ '@typescript-eslint/types': 8.59.3
+ '@typescript-eslint/visitor-keys': 8.59.3
- '@typescript-eslint/tsconfig-utils@8.53.0(typescript@5.5.4)':
+ '@typescript-eslint/tsconfig-utils@8.59.3(typescript@5.5.4)':
dependencies:
typescript: 5.5.4
- '@typescript-eslint/type-utils@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4)':
+ '@typescript-eslint/type-utils@8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4)':
dependencies:
- '@typescript-eslint/types': 8.53.0
- '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.5.4)
- '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4)
+ '@typescript-eslint/types': 8.59.3
+ '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.5.4)
+ '@typescript-eslint/utils': 8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4)
debug: 4.4.3
- eslint: 9.39.2(jiti@2.6.1)
- ts-api-utils: 2.4.0(typescript@5.5.4)
+ eslint: 9.39.2(jiti@2.7.0)
+ ts-api-utils: 2.5.0(typescript@5.5.4)
typescript: 5.5.4
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/types@8.53.0': {}
+ '@typescript-eslint/types@8.59.3': {}
- '@typescript-eslint/typescript-estree@8.53.0(typescript@5.5.4)':
+ '@typescript-eslint/types@8.64.0': {}
+
+ '@typescript-eslint/typescript-estree@8.59.3(typescript@5.5.4)':
dependencies:
- '@typescript-eslint/project-service': 8.53.0(typescript@5.5.4)
- '@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.5.4)
- '@typescript-eslint/types': 8.53.0
- '@typescript-eslint/visitor-keys': 8.53.0
+ '@typescript-eslint/project-service': 8.59.3(typescript@5.5.4)
+ '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@5.5.4)
+ '@typescript-eslint/types': 8.59.3
+ '@typescript-eslint/visitor-keys': 8.59.3
debug: 4.4.3
- minimatch: 9.0.7
+ minimatch: 10.2.3
semver: 7.8.0
- tinyglobby: 0.2.16
- ts-api-utils: 2.4.0(typescript@5.5.4)
+ tinyglobby: 0.2.17
+ ts-api-utils: 2.5.0(typescript@5.5.4)
typescript: 5.5.4
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4)':
+ '@typescript-eslint/utils@8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4)':
dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
- '@typescript-eslint/scope-manager': 8.53.0
- '@typescript-eslint/types': 8.53.0
- '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.5.4)
- eslint: 9.39.2(jiti@2.6.1)
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.7.0))
+ '@typescript-eslint/scope-manager': 8.59.3
+ '@typescript-eslint/types': 8.59.3
+ '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.5.4)
+ eslint: 9.39.2(jiti@2.7.0)
typescript: 5.5.4
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/visitor-keys@8.53.0':
+ '@typescript-eslint/visitor-keys@8.59.3':
dependencies:
- '@typescript-eslint/types': 8.53.0
- eslint-visitor-keys: 4.2.1
+ '@typescript-eslint/types': 8.59.3
+ eslint-visitor-keys: 5.0.1
'@uiw/codemirror-extensions-basic-setup@4.25.8(@codemirror/autocomplete@6.20.1)(@codemirror/commands@6.10.3)(@codemirror/language@6.12.2)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)':
dependencies:
@@ -10589,6 +10737,76 @@ snapshots:
'@ungap/structured-clone@1.3.0': {}
+ '@unrs/resolver-binding-android-arm-eabi@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-android-arm64@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-darwin-arm64@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-darwin-x64@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-freebsd-x64@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-arm64-gnu@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-arm64-musl@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-loong64-gnu@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-loong64-musl@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-riscv64-musl@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-s390x-gnu@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-x64-gnu@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-x64-musl@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-openharmony-arm64@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-wasm32-wasi@1.12.2':
+ dependencies:
+ '@emnapi/core': 1.10.0
+ '@emnapi/runtime': 1.10.0
+ '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
+ optional: true
+
+ '@unrs/resolver-binding-win32-arm64-msvc@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-win32-ia32-msvc@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-win32-x64-msvc@1.12.2':
+ optional: true
+
'@upsetjs/venn.js@2.0.0':
optionalDependencies:
d3-selection: 3.0.0
@@ -10596,7 +10814,7 @@ snapshots:
'@vercel/oidc@3.2.0': {}
- '@vitejs/plugin-react@5.1.2(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))':
+ '@vitejs/plugin-react@5.1.2(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))':
dependencies:
'@babel/core': 7.29.7
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.7)
@@ -10604,33 +10822,33 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.53
'@types/babel__core': 7.20.5
react-refresh: 0.18.0
- vite: 7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)
+ vite: 7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)
transitivePeerDependencies:
- supports-color
- '@vitest/browser-playwright@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.8)':
+ '@vitest/browser-playwright@4.1.10(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.10)':
dependencies:
- '@vitest/browser': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.8)
- '@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
+ '@vitest/browser': 4.1.10(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.10)
+ '@vitest/mocker': 4.1.10(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
playwright: 1.56.1
tinyrainbow: 3.1.0
- vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
+ vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
transitivePeerDependencies:
- bufferutil
- msw
- utf-8-validate
- vite
- '@vitest/browser@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.8)':
+ '@vitest/browser@4.1.10(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.10)':
dependencies:
'@blazediff/core': 1.9.1
- '@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
- '@vitest/utils': 4.1.8
+ '@vitest/mocker': 4.1.10(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
+ '@vitest/utils': 4.1.10
magic-string: 0.30.21
pngjs: 7.0.0
sirv: 3.0.2
tinyrainbow: 3.1.0
- vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
+ vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
ws: 8.21.0
transitivePeerDependencies:
- bufferutil
@@ -10638,10 +10856,10 @@ snapshots:
- utf-8-validate
- vite
- '@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)':
+ '@vitest/coverage-v8@4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
- '@vitest/utils': 4.1.8
+ '@vitest/utils': 4.1.10
ast-v8-to-istanbul: 1.0.3
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
@@ -10650,49 +10868,49 @@ snapshots:
obug: 2.1.1
std-env: 4.1.0
tinyrainbow: 3.1.0
- vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
+ vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
optionalDependencies:
- '@vitest/browser': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.8)
+ '@vitest/browser': 4.1.10(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.10)
- '@vitest/expect@4.1.8':
+ '@vitest/expect@4.1.10':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
- '@vitest/spy': 4.1.8
- '@vitest/utils': 4.1.8
+ '@vitest/spy': 4.1.10
+ '@vitest/utils': 4.1.10
chai: 6.2.2
tinyrainbow: 3.1.0
- '@vitest/mocker@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))':
+ '@vitest/mocker@4.1.10(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))':
dependencies:
- '@vitest/spy': 4.1.8
+ '@vitest/spy': 4.1.10
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.13.4(@types/node@24.10.8)(typescript@5.5.4)
- vite: 7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)
+ vite: 7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)
- '@vitest/pretty-format@4.1.8':
+ '@vitest/pretty-format@4.1.10':
dependencies:
tinyrainbow: 3.1.0
- '@vitest/runner@4.1.8':
+ '@vitest/runner@4.1.10':
dependencies:
- '@vitest/utils': 4.1.8
+ '@vitest/utils': 4.1.10
pathe: 2.0.3
- '@vitest/snapshot@4.1.8':
+ '@vitest/snapshot@4.1.10':
dependencies:
- '@vitest/pretty-format': 4.1.8
- '@vitest/utils': 4.1.8
+ '@vitest/pretty-format': 4.1.10
+ '@vitest/utils': 4.1.10
magic-string: 0.30.21
pathe: 2.0.3
- '@vitest/spy@4.1.8': {}
+ '@vitest/spy@4.1.10': {}
- '@vitest/utils@4.1.8':
+ '@vitest/utils@4.1.10':
dependencies:
- '@vitest/pretty-format': 4.1.8
+ '@vitest/pretty-format': 4.1.10
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
@@ -11125,6 +11343,8 @@ snapshots:
commander@8.3.0: {}
+ comment-parser@1.4.7: {}
+
commondir@1.0.1: {}
concat-map@0.0.1: {}
@@ -11650,11 +11870,51 @@ snapshots:
escape-string-regexp@5.0.0: {}
- eslint-config-prettier@10.1.5(eslint@9.39.2(jiti@2.6.1)):
+ eslint-config-prettier@10.1.5(eslint@9.39.2(jiti@2.7.0)):
dependencies:
- eslint: 9.39.2(jiti@2.6.1)
+ eslint: 9.39.2(jiti@2.7.0)
- eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.2(jiti@2.6.1)):
+ eslint-import-context@0.1.9(unrs-resolver@1.12.2):
+ dependencies:
+ get-tsconfig: 4.13.7
+ stable-hash-x: 0.2.0
+ optionalDependencies:
+ unrs-resolver: 1.12.2
+
+ eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4))(eslint@9.39.2(jiti@2.7.0)))(eslint@9.39.2(jiti@2.7.0)):
+ dependencies:
+ debug: 4.4.3
+ eslint: 9.39.2(jiti@2.7.0)
+ eslint-import-context: 0.1.9(unrs-resolver@1.12.2)
+ get-tsconfig: 4.13.7
+ is-bun-module: 2.0.0
+ stable-hash-x: 0.2.0
+ tinyglobby: 0.2.17
+ unrs-resolver: 1.12.2
+ optionalDependencies:
+ eslint-plugin-import-x: 4.16.2(@typescript-eslint/utils@8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4))(eslint@9.39.2(jiti@2.7.0))
+ transitivePeerDependencies:
+ - supports-color
+
+ eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4))(eslint@9.39.2(jiti@2.7.0)):
+ dependencies:
+ '@package-json/types': 0.0.12
+ '@typescript-eslint/types': 8.64.0
+ comment-parser: 1.4.7
+ debug: 4.4.3
+ eslint: 9.39.2(jiti@2.7.0)
+ eslint-import-context: 0.1.9(unrs-resolver@1.12.2)
+ is-glob: 4.0.3
+ minimatch: 9.0.7
+ semver: 7.8.0
+ stable-hash-x: 0.2.0
+ unrs-resolver: 1.12.2
+ optionalDependencies:
+ '@typescript-eslint/utils': 8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4)
+ transitivePeerDependencies:
+ - supports-color
+
+ eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.2(jiti@2.7.0)):
dependencies:
aria-query: 5.3.2
array-includes: 3.1.9
@@ -11664,7 +11924,7 @@ snapshots:
axobject-query: 4.1.0
damerau-levenshtein: 1.0.8
emoji-regex: 9.2.2
- eslint: 9.39.2(jiti@2.6.1)
+ eslint: 9.39.2(jiti@2.7.0)
hasown: 2.0.2
jsx-ast-utils: 3.3.5
language-tags: 1.0.9
@@ -11673,28 +11933,18 @@ snapshots:
safe-regex-test: 1.1.0
string.prototype.includes: 2.0.1
- eslint-plugin-prettier@5.5.1(@types/eslint@9.6.1)(eslint-config-prettier@10.1.5(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.6.2):
- dependencies:
- eslint: 9.39.2(jiti@2.6.1)
- prettier: 3.6.2
- prettier-linter-helpers: 1.0.1
- synckit: 0.11.12
- optionalDependencies:
- '@types/eslint': 9.6.1
- eslint-config-prettier: 10.1.5(eslint@9.39.2(jiti@2.6.1))
-
- eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@2.6.1)):
+ eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@2.7.0)):
dependencies:
'@babel/core': 7.29.7
'@babel/parser': 7.29.7
- eslint: 9.39.2(jiti@2.6.1)
+ eslint: 9.39.2(jiti@2.7.0)
hermes-parser: 0.25.1
zod: 4.4.3
zod-validation-error: 4.0.2(zod@4.4.3)
transitivePeerDependencies:
- supports-color
- eslint-plugin-react@7.37.5(eslint@9.39.2(jiti@2.6.1)):
+ eslint-plugin-react@7.37.5(eslint@9.39.2(jiti@2.7.0)):
dependencies:
array-includes: 3.1.9
array.prototype.findlast: 1.2.5
@@ -11702,7 +11952,7 @@ snapshots:
array.prototype.tosorted: 1.1.4
doctrine: 2.1.0
es-iterator-helpers: 1.2.2
- eslint: 9.39.2(jiti@2.6.1)
+ eslint: 9.39.2(jiti@2.7.0)
estraverse: 5.3.0
hasown: 2.0.2
jsx-ast-utils: 3.3.5
@@ -11720,16 +11970,6 @@ snapshots:
dependencies:
safe-regex: 2.1.1
- eslint-plugin-simple-import-sort@12.1.1(eslint@9.39.2(jiti@2.6.1)):
- dependencies:
- eslint: 9.39.2(jiti@2.6.1)
-
- eslint-plugin-unused-imports@4.3.0(@typescript-eslint/eslint-plugin@8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4))(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4))(eslint@9.39.2(jiti@2.6.1)):
- dependencies:
- eslint: 9.39.2(jiti@2.6.1)
- optionalDependencies:
- '@typescript-eslint/eslint-plugin': 8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4))(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4)
-
eslint-scope@5.1.1:
dependencies:
esrecurse: 4.3.0
@@ -11744,9 +11984,11 @@ snapshots:
eslint-visitor-keys@4.2.1: {}
- eslint@9.39.2(jiti@2.6.1):
+ eslint-visitor-keys@5.0.1: {}
+
+ eslint@9.39.2(jiti@2.7.0):
dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.7.0))
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.21.1
'@eslint/config-helpers': 0.4.2
@@ -11781,7 +12023,7 @@ snapshots:
natural-compare: 1.4.0
optionator: 0.9.4
optionalDependencies:
- jiti: 2.6.1
+ jiti: 2.7.0
transitivePeerDependencies:
- supports-color
@@ -11874,8 +12116,6 @@ snapshots:
fast-deep-equal@3.1.3: {}
- fast-diff@1.3.0: {}
-
fast-equals@5.4.0: {}
fast-glob@3.3.1:
@@ -11931,10 +12171,6 @@ snapshots:
dependencies:
walk-up-path: 4.0.0
- fdir@6.5.0(picomatch@4.0.4):
- optionalDependencies:
- picomatch: 4.0.4
-
fdir@6.5.0(picomatch@4.0.5):
optionalDependencies:
picomatch: 4.0.5
@@ -12360,6 +12596,10 @@ snapshots:
call-bound: 1.0.4
has-tostringtag: 1.0.2
+ is-bun-module@2.0.0:
+ dependencies:
+ semver: 7.8.0
+
is-callable@1.2.7: {}
is-core-module@2.16.1:
@@ -12499,6 +12739,8 @@ snapshots:
jiti@2.6.1: {}
+ jiti@2.7.0: {}
+
jose@6.1.3: {}
js-tiktoken@1.0.21:
@@ -12578,7 +12820,7 @@ snapshots:
khroma@2.1.0: {}
- knip@6.3.1(@emnapi/core@1.8.1)(@emnapi/runtime@1.10.0):
+ knip@6.3.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0):
dependencies:
'@nodelib/fs.walk': 1.2.8
fast-glob: 3.3.3
@@ -12586,8 +12828,8 @@ snapshots:
get-tsconfig: 4.13.7
jiti: 2.6.1
minimist: 1.2.8
- oxc-parser: 0.121.0(@emnapi/core@1.8.1)(@emnapi/runtime@1.10.0)
- oxc-resolver: 11.19.1(@emnapi/core@1.8.1)(@emnapi/runtime@1.10.0)
+ oxc-parser: 0.121.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
+ oxc-resolver: 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
picocolors: 1.1.1
picomatch: 4.0.4
smol-toml: 1.6.1
@@ -13274,27 +13516,29 @@ snapshots:
nanoid@5.1.6: {}
+ napi-postinstall@0.3.4: {}
+
natural-compare@1.4.0: {}
negotiator@1.0.0: {}
neo-async@2.6.2: {}
- next-auth@5.0.0-beta.30(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7):
+ next-auth@5.0.0-beta.32(next@16.2.11(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7):
dependencies:
- '@auth/core': 0.41.0
- next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@auth/core': 0.41.3
+ next: 16.2.11(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react: 19.2.7
- next-themes@0.2.1(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
+ next-themes@0.2.1(next@16.2.11(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
- next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ next: 16.2.11(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
- next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
+ next@16.2.11(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
- '@next/env': 16.2.9
+ '@next/env': 16.2.11
'@swc/helpers': 0.5.15
baseline-browser-mapping: 2.10.29
caniuse-lite: 1.0.30001792
@@ -13303,14 +13547,14 @@ snapshots:
react-dom: 19.2.7(react@19.2.7)
styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.7)
optionalDependencies:
- '@next/swc-darwin-arm64': 16.2.9
- '@next/swc-darwin-x64': 16.2.9
- '@next/swc-linux-arm64-gnu': 16.2.9
- '@next/swc-linux-arm64-musl': 16.2.9
- '@next/swc-linux-x64-gnu': 16.2.9
- '@next/swc-linux-x64-musl': 16.2.9
- '@next/swc-win32-arm64-msvc': 16.2.9
- '@next/swc-win32-x64-msvc': 16.2.9
+ '@next/swc-darwin-arm64': 16.2.11
+ '@next/swc-darwin-x64': 16.2.11
+ '@next/swc-linux-arm64-gnu': 16.2.11
+ '@next/swc-linux-arm64-musl': 16.2.11
+ '@next/swc-linux-x64-gnu': 16.2.11
+ '@next/swc-linux-x64-musl': 16.2.11
+ '@next/swc-win32-arm64-msvc': 16.2.11
+ '@next/swc-win32-x64-msvc': 16.2.11
'@opentelemetry/api': 1.9.1
'@playwright/test': 1.56.1
babel-plugin-react-compiler: 1.0.0
@@ -13403,7 +13647,7 @@ snapshots:
object-keys: 1.1.1
safe-push-apply: 1.0.0
- oxc-parser@0.121.0(@emnapi/core@1.8.1)(@emnapi/runtime@1.10.0):
+ oxc-parser@0.121.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0):
dependencies:
'@oxc-project/types': 0.121.0
optionalDependencies:
@@ -13423,7 +13667,7 @@ snapshots:
'@oxc-parser/binding-linux-x64-gnu': 0.121.0
'@oxc-parser/binding-linux-x64-musl': 0.121.0
'@oxc-parser/binding-openharmony-arm64': 0.121.0
- '@oxc-parser/binding-wasm32-wasi': 0.121.0(@emnapi/core@1.8.1)(@emnapi/runtime@1.10.0)
+ '@oxc-parser/binding-wasm32-wasi': 0.121.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
'@oxc-parser/binding-win32-arm64-msvc': 0.121.0
'@oxc-parser/binding-win32-ia32-msvc': 0.121.0
'@oxc-parser/binding-win32-x64-msvc': 0.121.0
@@ -13431,7 +13675,7 @@ snapshots:
- '@emnapi/core'
- '@emnapi/runtime'
- oxc-resolver@11.19.1(@emnapi/core@1.8.1)(@emnapi/runtime@1.10.0):
+ oxc-resolver@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0):
optionalDependencies:
'@oxc-resolver/binding-android-arm-eabi': 11.19.1
'@oxc-resolver/binding-android-arm64': 11.19.1
@@ -13449,7 +13693,7 @@ snapshots:
'@oxc-resolver/binding-linux-x64-gnu': 11.19.1
'@oxc-resolver/binding-linux-x64-musl': 11.19.1
'@oxc-resolver/binding-openharmony-arm64': 11.19.1
- '@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.8.1)(@emnapi/runtime@1.10.0)
+ '@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
'@oxc-resolver/binding-win32-arm64-msvc': 11.19.1
'@oxc-resolver/binding-win32-ia32-msvc': 11.19.1
'@oxc-resolver/binding-win32-x64-msvc': 11.19.1
@@ -13588,10 +13832,6 @@ snapshots:
prelude-ls@1.2.1: {}
- prettier-linter-helpers@1.0.1:
- dependencies:
- fast-diff: 1.3.0
-
prettier-plugin-packagejson@2.5.22(prettier@3.6.2):
dependencies:
sort-package-json: 3.6.0
@@ -14178,7 +14418,7 @@ snapshots:
is-plain-obj: 4.1.0
semver: 7.8.0
sort-object-keys: 2.1.0
- tinyglobby: 0.2.16
+ tinyglobby: 0.2.17
source-map-js@1.2.1: {}
@@ -14191,6 +14431,8 @@ snapshots:
space-separated-tokens@2.0.2: {}
+ stable-hash-x@0.2.0: {}
+
stackback@0.0.2: {}
stacktrace-parser@0.1.11:
@@ -14351,10 +14593,6 @@ snapshots:
symbol-tree@3.2.4: {}
- synckit@0.11.12:
- dependencies:
- '@pkgr/core': 0.2.9
-
tagged-tag@1.0.0: {}
tailwind-merge@3.3.1: {}
@@ -14395,11 +14633,6 @@ snapshots:
tinyexec@1.1.2: {}
- tinyglobby@0.2.16:
- dependencies:
- fdir: 6.5.0(picomatch@4.0.4)
- picomatch: 4.0.4
-
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.5)
@@ -14439,7 +14672,7 @@ snapshots:
trough@2.2.0: {}
- ts-api-utils@2.4.0(typescript@5.5.4):
+ ts-api-utils@2.5.0(typescript@5.5.4):
dependencies:
typescript: 5.5.4
@@ -14496,6 +14729,17 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
+ typescript-eslint@8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4):
+ dependencies:
+ '@typescript-eslint/eslint-plugin': 8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4))(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4)
+ '@typescript-eslint/parser': 8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4)
+ '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.5.4)
+ '@typescript-eslint/utils': 8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4)
+ eslint: 9.39.2(jiti@2.7.0)
+ typescript: 5.5.4
+ transitivePeerDependencies:
+ - supports-color
+
typescript@5.5.4: {}
ufo@1.6.3: {}
@@ -14556,6 +14800,33 @@ snapshots:
unpipe@1.0.0: {}
+ unrs-resolver@1.12.2:
+ dependencies:
+ napi-postinstall: 0.3.4
+ optionalDependencies:
+ '@unrs/resolver-binding-android-arm-eabi': 1.12.2
+ '@unrs/resolver-binding-android-arm64': 1.12.2
+ '@unrs/resolver-binding-darwin-arm64': 1.12.2
+ '@unrs/resolver-binding-darwin-x64': 1.12.2
+ '@unrs/resolver-binding-freebsd-x64': 1.12.2
+ '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2
+ '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2
+ '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2
+ '@unrs/resolver-binding-linux-arm64-musl': 1.12.2
+ '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2
+ '@unrs/resolver-binding-linux-loong64-musl': 1.12.2
+ '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2
+ '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2
+ '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2
+ '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2
+ '@unrs/resolver-binding-linux-x64-gnu': 1.12.2
+ '@unrs/resolver-binding-linux-x64-musl': 1.12.2
+ '@unrs/resolver-binding-openharmony-arm64': 1.12.2
+ '@unrs/resolver-binding-wasm32-wasi': 1.12.2
+ '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2
+ '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2
+ '@unrs/resolver-binding-win32-x64-msvc': 1.12.2
+
until-async@3.0.2: {}
update-browserslist-db@1.2.3(browserslist@4.28.2):
@@ -14629,7 +14900,7 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
- vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0):
+ vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0):
dependencies:
esbuild: 0.28.1
fdir: 6.5.0(picomatch@4.0.5)
@@ -14640,47 +14911,47 @@ snapshots:
optionalDependencies:
'@types/node': 24.10.8
fsevents: 2.3.3
- jiti: 2.6.1
+ jiti: 2.7.0
lightningcss: 1.30.2
terser: 5.49.0
yaml: 2.9.0
- vitest-browser-react@2.0.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.8):
+ vitest-browser-react@2.0.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.10):
dependencies:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
- vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
+ vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
- vitest@4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)):
+ vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)):
dependencies:
- '@vitest/expect': 4.1.8
- '@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
- '@vitest/pretty-format': 4.1.8
- '@vitest/runner': 4.1.8
- '@vitest/snapshot': 4.1.8
- '@vitest/spy': 4.1.8
- '@vitest/utils': 4.1.8
+ '@vitest/expect': 4.1.10
+ '@vitest/mocker': 4.1.10(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
+ '@vitest/pretty-format': 4.1.10
+ '@vitest/runner': 4.1.10
+ '@vitest/snapshot': 4.1.10
+ '@vitest/spy': 4.1.10
+ '@vitest/utils': 4.1.10
es-module-lexer: 2.3.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
- picomatch: 4.0.4
+ picomatch: 4.0.5
std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.1.2
- tinyglobby: 0.2.16
+ tinyglobby: 0.2.17
tinyrainbow: 3.1.0
- vite: 7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)
+ vite: 7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@types/node': 24.10.8
- '@vitest/browser-playwright': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.8)
- '@vitest/coverage-v8': 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)
+ '@vitest/browser-playwright': 4.1.10(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.10)
+ '@vitest/coverage-v8': 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10)
jsdom: 27.4.0
transitivePeerDependencies:
- msw
diff --git a/ui/pnpm-workspace.yaml b/ui/pnpm-workspace.yaml
index 0a942af944..94016ac93d 100644
--- a/ui/pnpm-workspace.yaml
+++ b/ui/pnpm-workspace.yaml
@@ -109,7 +109,7 @@ trustPolicy: no-downgrade
trustPolicyExclude:
# next-auth: only one one-off manual test release (`0.0.0-manual.2824fa11`) has
# provenance; real beta/stable releases don't. Scoped to current beta line.
- - "next-auth@5.0.0-beta.30"
+ - "next-auth@5.0.0-beta.32"
# semver: legacy major 6.x never had provenance (added in 7.5.1+). Pinned
# to the exact 6.x version pulled transitively (via @babel/helper-compilation-targets).
- "semver@6.3.1"
diff --git a/ui/proxy.ts b/ui/proxy.ts
index 179caa1ba3..6e954295aa 100644
--- a/ui/proxy.ts
+++ b/ui/proxy.ts
@@ -3,6 +3,7 @@ import type { NextAuthRequest } from "next-auth";
import { auth } from "@/auth.config";
import { readEnv } from "@/lib/runtime-env";
+import { isCloud } from "@/lib/shared/env";
const publicRoutes = [
"/sign-in",
@@ -43,7 +44,9 @@ export default auth((req: NextAuthRequest) => {
if (
pathname.startsWith("/billing") &&
- (!cloudBillingEnabled || user?.permissions?.manage_billing !== true)
+ (!isCloud() ||
+ !cloudBillingEnabled ||
+ user?.permissions?.manage_billing !== true)
) {
return NextResponse.redirect(new URL("/profile", req.url));
}
diff --git a/ui/store/cloud-upgrade/store.test.ts b/ui/store/cloud-upgrade/store.test.ts
new file mode 100644
index 0000000000..0d1da3f382
--- /dev/null
+++ b/ui/store/cloud-upgrade/store.test.ts
@@ -0,0 +1,51 @@
+import { beforeEach, describe, expect, it } from "vitest";
+
+import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
+
+import { useCloudUpgradeStore } from "./store";
+
+describe("useCloudUpgradeStore", () => {
+ beforeEach(() => {
+ useCloudUpgradeStore.setState({
+ activeFeature: null,
+ retainedFeature: CLOUD_UPGRADE_FEATURE.GENERAL,
+ returnFocusElement: null,
+ });
+ });
+
+ it("retains the opened feature when the modal closes", () => {
+ // Given
+ useCloudUpgradeStore
+ .getState()
+ .openCloudUpgrade(CLOUD_UPGRADE_FEATURE.ALERTS);
+
+ // When
+ useCloudUpgradeStore.getState().closeCloudUpgrade();
+
+ // Then
+ expect(useCloudUpgradeStore.getState().activeFeature).toBeNull();
+ expect(useCloudUpgradeStore.getState().retainedFeature).toBe(
+ CLOUD_UPGRADE_FEATURE.ALERTS,
+ );
+ });
+
+ it("updates the retained feature when another upgrade opens", () => {
+ // Given
+ useCloudUpgradeStore
+ .getState()
+ .openCloudUpgrade(CLOUD_UPGRADE_FEATURE.ALERTS);
+
+ // When
+ useCloudUpgradeStore
+ .getState()
+ .openCloudUpgrade(CLOUD_UPGRADE_FEATURE.AWS_ORGANIZATIONS);
+
+ // Then
+ expect(useCloudUpgradeStore.getState().activeFeature).toBe(
+ CLOUD_UPGRADE_FEATURE.AWS_ORGANIZATIONS,
+ );
+ expect(useCloudUpgradeStore.getState().retainedFeature).toBe(
+ CLOUD_UPGRADE_FEATURE.AWS_ORGANIZATIONS,
+ );
+ });
+});
diff --git a/ui/store/cloud-upgrade/store.ts b/ui/store/cloud-upgrade/store.ts
index 8136dbb8b4..21e421ccb0 100644
--- a/ui/store/cloud-upgrade/store.ts
+++ b/ui/store/cloud-upgrade/store.ts
@@ -1,9 +1,13 @@
import { create } from "zustand";
-import type { CloudUpgradeFeature } from "@/types/cloud-upgrade";
+import {
+ CLOUD_UPGRADE_FEATURE,
+ type CloudUpgradeFeature,
+} from "@/types/cloud-upgrade";
interface CloudUpgradeStoreState {
activeFeature: CloudUpgradeFeature | null;
+ retainedFeature: CloudUpgradeFeature;
returnFocusElement: HTMLElement | null;
openCloudUpgrade: (
feature: CloudUpgradeFeature,
@@ -15,10 +19,12 @@ interface CloudUpgradeStoreState {
// Upgrade prompts are ephemeral and shared so only one modal can be open.
export const useCloudUpgradeStore = create((set) => ({
activeFeature: null,
+ retainedFeature: CLOUD_UPGRADE_FEATURE.GENERAL,
returnFocusElement: null,
openCloudUpgrade: (activeFeature, requestedReturnFocusElement) =>
set({
activeFeature,
+ retainedFeature: activeFeature,
returnFocusElement:
requestedReturnFocusElement ??
(document.activeElement instanceof HTMLElement
diff --git a/ui/store/index.ts b/ui/store/index.ts
index 1c181a35c7..572af57593 100644
--- a/ui/store/index.ts
+++ b/ui/store/index.ts
@@ -1,4 +1,5 @@
export * from "./cloud-upgrade/store";
+export * from "./jira-dispatch/store";
export * from "./organizations/store";
export * from "./provider-wizard/store";
export * from "./scans/store";
diff --git a/ui/store/jira-dispatch/store.ts b/ui/store/jira-dispatch/store.ts
new file mode 100644
index 0000000000..291ddd64cd
--- /dev/null
+++ b/ui/store/jira-dispatch/store.ts
@@ -0,0 +1,16 @@
+import { create } from "zustand";
+
+import type { JiraDispatchModalPayload } from "@/types/jira-dispatch";
+
+interface JiraDispatchStoreState {
+ activePayload: JiraDispatchModalPayload | null;
+ openJiraDispatch: (payload: JiraDispatchModalPayload) => void;
+ closeJiraDispatch: () => void;
+}
+
+// Jira dispatch is ephemeral and globally hosted so every action uses one modal.
+export const useJiraDispatchStore = create((set) => ({
+ activePayload: null,
+ openJiraDispatch: (activePayload) => set({ activePayload }),
+ closeJiraDispatch: () => set({ activePayload: null }),
+}));
diff --git a/ui/store/task-watcher/store.test.ts b/ui/store/task-watcher/store.test.ts
index d1fbdb6188..d260e35357 100644
--- a/ui/store/task-watcher/store.test.ts
+++ b/ui/store/task-watcher/store.test.ts
@@ -49,6 +49,37 @@ describe("task watcher store", () => {
expect(onError).not.toHaveBeenCalled();
});
+ it("returns and persists a completed task result while allowing the caller to own notifications", async () => {
+ // Given
+ pollMock.mockResolvedValue({
+ ok: true,
+ state: "completed",
+ result: { created_count: 1, failed_count: 1 },
+ });
+
+ // When
+ const result = await trackAndPollTask<{
+ created_count: number;
+ failed_count: number;
+ }>({
+ taskId: "jira-task",
+ kind: "test-kind",
+ meta: {},
+ notifyHandler: false,
+ });
+
+ // Then
+ expect(result).toEqual({
+ status: TASK_WATCHER_STATUS.READY,
+ result: { created_count: 1, failed_count: 1 },
+ });
+ expect(useTaskWatcherStore.getState().tasks["jira-task"]?.result).toEqual({
+ created_count: 1,
+ failed_count: 1,
+ });
+ expect(onReady).not.toHaveBeenCalled();
+ });
+
it("replaces settled results of the same kind when tracking new work", async () => {
// Given
pollMock.mockResolvedValue({ ok: true, state: "completed" });
@@ -201,6 +232,39 @@ describe("task watcher store", () => {
expect(onReady).toHaveBeenCalledTimes(1);
});
+ it("passes a persisted task result to its handler after resuming", async () => {
+ // Given
+ const taskResult = {
+ created_count: 1,
+ failed_count: 1,
+ failed_finding_ids: ["finding-2"],
+ };
+ pollMock.mockResolvedValue({
+ ok: true,
+ state: "completed",
+ result: taskResult,
+ });
+ useTaskWatcherStore.setState({
+ tasks: {
+ "resumed-jira-task": {
+ taskId: "resumed-jira-task",
+ kind: "test-kind",
+ status: TASK_WATCHER_STATUS.PENDING,
+ meta: {},
+ startedAt: Date.now(),
+ },
+ },
+ });
+
+ // When
+ await resumePendingTasks();
+
+ // Then
+ expect(onReady).toHaveBeenCalledWith(
+ expect.objectContaining({ result: taskResult }),
+ );
+ });
+
it("discards settled tasks before resuming persisted work", async () => {
// Given
pollMock.mockResolvedValue({ ok: true, state: "completed" });
diff --git a/ui/store/task-watcher/store.ts b/ui/store/task-watcher/store.ts
index b979907456..e95389872e 100644
--- a/ui/store/task-watcher/store.ts
+++ b/ui/store/task-watcher/store.ts
@@ -28,6 +28,9 @@ export interface WatchedTask {
meta: Record;
startedAt: number;
error?: string;
+ /** Serializable task result. Persisted so another tab or a reload can
+ * finish feature-specific handling without polling the task again. */
+ result?: unknown;
}
export interface TaskKindHandler {
@@ -35,6 +38,21 @@ export interface TaskKindHandler {
onError: (task: WatchedTask) => void;
}
+export interface TaskTrackingResult {
+ status: TaskWatcherStatus;
+ error?: string;
+ result?: R;
+}
+
+export interface TrackAndPollTaskInput {
+ taskId: string;
+ kind: string;
+ meta: Record;
+ /** Let the awaiting caller aggregate notifications. If this tab reloads,
+ * the persisted task resumes with its registered handler as usual. */
+ notifyHandler?: boolean;
+}
+
interface TaskWatcherState {
tasks: Record;
upsertTask: (task: WatchedTask) => void;
@@ -42,6 +60,7 @@ interface TaskWatcherState {
taskId: string,
status: TaskWatcherStatus,
error?: string,
+ result?: unknown,
) => void;
dismissTask: (taskId: string) => void;
}
@@ -69,12 +88,15 @@ export const useTaskWatcherStore = create()(
tasks: {},
upsertTask: (task) =>
set((state) => ({ tasks: { ...state.tasks, [task.taskId]: task } })),
- resolveTask: (taskId, status, error) =>
+ resolveTask: (taskId, status, error, result) =>
set((state) => {
const task = state.tasks[taskId];
if (!task) return state;
return {
- tasks: { ...state.tasks, [taskId]: { ...task, status, error } },
+ tasks: {
+ ...state.tasks,
+ [taskId]: { ...task, status, error, result },
+ },
};
}),
dismissTask: (taskId) =>
@@ -92,25 +114,35 @@ export const useTaskWatcherStore = create()(
// In-memory only: poll loops alive in THIS tab. Never persisted, so a reload
// naturally re-enters through resumePendingTasks without double-polling.
-const activePolls = new Set();
+const activePolls = new Map>>();
+const suppressedHandlers = new Set();
const settleTask = (
taskId: string,
status: TaskWatcherStatus,
error?: string,
-) => {
+ result?: unknown,
+): TaskTrackingResult => {
const store = useTaskWatcherStore.getState();
const currentTask = store.tasks[taskId];
if (!currentTask || currentTask.status !== TASK_WATCHER_STATUS.PENDING) {
- return;
+ return {
+ status: currentTask?.status ?? TASK_WATCHER_STATUS.ERROR,
+ ...(currentTask?.error ? { error: currentTask.error } : {}),
+ ...(currentTask?.result !== undefined
+ ? { result: currentTask.result }
+ : {}),
+ };
}
- store.resolveTask(taskId, status, error);
+ store.resolveTask(taskId, status, error, result);
const task = useTaskWatcherStore.getState().tasks[taskId];
- if (!task) return;
+ if (!task) {
+ return { status: TASK_WATCHER_STATUS.ERROR, error: "Task unavailable." };
+ }
const handler = handlers.get(task.kind);
- if (handler) {
+ if (handler && !suppressedHandlers.has(taskId)) {
if (status === TASK_WATCHER_STATUS.READY) handler.onReady(task);
else handler.onError(task);
}
@@ -120,107 +152,141 @@ const settleTask = (
if (status === TASK_WATCHER_STATUS.ERROR) {
store.dismissTask(taskId);
}
+
+ return {
+ status,
+ ...(error ? { error } : {}),
+ ...(result !== undefined ? { result } : {}),
+ };
};
-const runPollLoop = async (taskId: string): Promise => {
+const runPollLoop = async (
+ taskId: string,
+): Promise> => {
for (let round = 0; round < MAX_POLL_ROUNDS; round++) {
- const result = await pollTaskUntilSettled(taskId);
+ const result = await pollTaskUntilSettled(taskId);
if (result.ok) {
if (result.state === "completed") {
- settleTask(taskId, TASK_WATCHER_STATUS.READY);
+ return settleTask(
+ taskId,
+ TASK_WATCHER_STATUS.READY,
+ undefined,
+ result.result,
+ ) as TaskTrackingResult;
} else {
- settleTask(
+ return settleTask(
taskId,
TASK_WATCHER_STATUS.ERROR,
`Task ended in state "${result.state}".`,
- );
+ result.result,
+ ) as TaskTrackingResult;
}
- return;
}
// "Task timeout" just means this server round expired while the task
// is still running — keep polling. Real errors settle immediately.
if (result.error !== "Task timeout") {
- settleTask(taskId, TASK_WATCHER_STATUS.ERROR, result.error);
- return;
+ return settleTask(
+ taskId,
+ TASK_WATCHER_STATUS.ERROR,
+ result.error,
+ result.result,
+ ) as TaskTrackingResult;
}
}
- settleTask(
+ return settleTask(
taskId,
TASK_WATCHER_STATUS.ERROR,
"The task is taking too long. Try again later.",
- );
+ ) as TaskTrackingResult;
};
-const pollUntilDone = async (taskId: string): Promise => {
- if (activePolls.has(taskId)) return;
- activePolls.add(taskId);
+const pollUntilDone = (taskId: string): Promise> => {
+ const existingPoll = activePolls.get(taskId);
+ if (existingPoll) return existingPoll as Promise>;
- try {
- const runIfPending = async () => {
- // A different tab may have completed the task while this one waited for
- // the cross-tab lock. Refresh persisted state before polling or notifying.
- await useTaskWatcherStore.persist.rehydrate();
- const task = useTaskWatcherStore.getState().tasks[taskId];
- if (task?.status !== TASK_WATCHER_STATUS.PENDING) return;
- await runPollLoop(taskId);
- };
+ const pollPromise = (async (): Promise> => {
+ try {
+ const runIfPending = async (): Promise> => {
+ // A different tab may have completed the task while this one waited for
+ // the cross-tab lock. Refresh persisted state before polling or notifying.
+ await useTaskWatcherStore.persist.rehydrate();
+ const task = useTaskWatcherStore.getState().tasks[taskId];
+ if (task?.status !== TASK_WATCHER_STATUS.PENDING) {
+ return {
+ status: task?.status ?? TASK_WATCHER_STATUS.ERROR,
+ ...(task?.error ? { error: task.error } : {}),
+ ...(task?.result !== undefined ? { result: task.result as R } : {}),
+ };
+ }
+ return runPollLoop(taskId);
+ };
- if (typeof navigator !== "undefined" && navigator.locks) {
- await navigator.locks.request(`task-watcher:${taskId}`, runIfPending);
- } else {
- await runIfPending();
+ if (typeof navigator !== "undefined" && navigator.locks) {
+ return await navigator.locks.request(
+ `task-watcher:${taskId}`,
+ runIfPending,
+ );
+ }
+
+ return await runIfPending();
+ } catch {
+ // A thrown poll (e.g. the server-action RPC failing on a network drop)
+ // must still settle the task, or it stays PENDING in the persisted
+ // store and blocks the UI until the staleness ceiling.
+ return settleTask(
+ taskId,
+ TASK_WATCHER_STATUS.ERROR,
+ "Tracking the task failed unexpectedly. Try again later.",
+ ) as TaskTrackingResult;
+ } finally {
+ activePolls.delete(taskId);
}
- } catch {
- // A thrown poll (e.g. the server-action RPC failing on a network drop)
- // must still settle the task, or it stays PENDING in the persisted
- // store and blocks the UI until the staleness ceiling.
- settleTask(
- taskId,
- TASK_WATCHER_STATUS.ERROR,
- "Tracking the task failed unexpectedly. Try again later.",
- );
- } finally {
- activePolls.delete(taskId);
- }
+ })();
+
+ activePolls.set(taskId, pollPromise);
+ return pollPromise;
};
/** Track a freshly dispatched backend task and poll it to completion. The
* poll loop lives at module scope (fired from the click handler), so it
* survives client-side navigation without any effect subscriptions. */
-export const trackAndPollTask = async ({
+export const trackAndPollTask = async ({
taskId,
kind,
meta,
-}: {
- taskId: string;
- kind: string;
- meta: Record;
-}): Promise => {
+ notifyHandler = true,
+}: TrackAndPollTaskInput): Promise> => {
+ if (!notifyHandler) suppressedHandlers.add(taskId);
+
const existing = useTaskWatcherStore.getState().tasks[taskId];
- if (existing?.status === TASK_WATCHER_STATUS.PENDING) {
- return pollUntilDone(taskId);
+ try {
+ if (existing?.status === TASK_WATCHER_STATUS.PENDING) {
+ return await pollUntilDone(taskId);
+ }
+
+ const store = useTaskWatcherStore.getState();
+ Object.values(store.tasks)
+ .filter(
+ (task) =>
+ task.kind === kind && task.status !== TASK_WATCHER_STATUS.PENDING,
+ )
+ .forEach((task) => store.dismissTask(task.taskId));
+
+ store.upsertTask({
+ taskId,
+ kind,
+ status: TASK_WATCHER_STATUS.PENDING,
+ meta,
+ startedAt: Date.now(),
+ });
+
+ return await pollUntilDone(taskId);
+ } finally {
+ suppressedHandlers.delete(taskId);
}
-
- const store = useTaskWatcherStore.getState();
- Object.values(store.tasks)
- .filter(
- (task) =>
- task.kind === kind && task.status !== TASK_WATCHER_STATUS.PENDING,
- )
- .forEach((task) => store.dismissTask(task.taskId));
-
- store.upsertTask({
- taskId,
- kind,
- status: TASK_WATCHER_STATUS.PENDING,
- meta,
- startedAt: Date.now(),
- });
-
- return pollUntilDone(taskId);
};
/** Resume polling every persisted pending task after a hard reload; tasks
diff --git a/ui/styles/globals.css b/ui/styles/globals.css
index 2b4b4abfc6..abcd7d1c16 100644
--- a/ui/styles/globals.css
+++ b/ui/styles/globals.css
@@ -581,8 +581,20 @@
bottom: 3rem !important;
}
- /* Lighthouse overview banner animated gradient layers */
- .lighthouse-banner-gradient-neutral {
+ /* Overview banner animated gradient layers */
+ .overview-banner-gradient {
+ --overview-banner-gradient-primary: var(--bg-button-primary);
+ --overview-banner-gradient-primary-hover: var(--bg-button-primary-hover);
+ --overview-banner-gradient-primary-press: var(--bg-button-primary-press);
+ }
+
+ .overview-banner-gradient-agents {
+ --overview-banner-gradient-primary: var(--color-violet-400);
+ --overview-banner-gradient-primary-hover: var(--color-fuchsia-300);
+ --overview-banner-gradient-primary-press: var(--color-indigo-500);
+ }
+
+ .overview-banner-gradient-neutral {
background: radial-gradient(
circle at center,
var(--bg-neutral-tertiary) 0,
@@ -591,28 +603,28 @@
no-repeat;
}
- .lighthouse-banner-gradient-primary {
+ .overview-banner-gradient-primary {
background: radial-gradient(
circle at center,
- var(--bg-button-primary) 0,
+ var(--overview-banner-gradient-primary) 0,
transparent 50%
)
no-repeat;
}
- .lighthouse-banner-gradient-primary-hover {
+ .overview-banner-gradient-primary-hover {
background: radial-gradient(
circle at center,
- var(--bg-button-primary-hover) 0,
+ var(--overview-banner-gradient-primary-hover) 0,
transparent 50%
)
no-repeat;
}
- .lighthouse-banner-gradient-primary-press {
+ .overview-banner-gradient-primary-press {
background: radial-gradient(
circle at center,
- var(--bg-button-primary-press) 0,
+ var(--overview-banner-gradient-primary-press) 0,
transparent 50%
)
no-repeat;
diff --git a/ui/tests/invitations/invitations.spec.ts b/ui/tests/invitations/invitations.spec.ts
index cf19f5181a..d583851e1c 100644
--- a/ui/tests/invitations/invitations.spec.ts
+++ b/ui/tests/invitations/invitations.spec.ts
@@ -4,8 +4,9 @@ import { makeSuffix } from "../helpers";
import { SignUpPage } from "../sign-up/sign-up-page";
import { SignInPage } from "../sign-in-base/sign-in-base-page";
import { UserProfilePage } from "../profile/profile-page";
+import { isCloud } from "@/lib/shared/env";
-const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
+const isCloudEnv = isCloud();
test.describe("New user invitation", () => {
let invitationsPage: InvitationsPage;
diff --git a/ui/tests/providers/providers.spec.ts b/ui/tests/providers/providers.spec.ts
index b461808c65..8900f3fd5f 100644
--- a/ui/tests/providers/providers.spec.ts
+++ b/ui/tests/providers/providers.spec.ts
@@ -1,4 +1,6 @@
import { test } from "@playwright/test";
+
+import { isCloud } from "@/lib/shared/env";
import {
ProvidersPage,
AWSProviderData,
@@ -281,7 +283,7 @@ test.describe("Add Provider", () => {
// so this test must never run in the OSS CI. Gate explicitly on the
// Cloud env flag instead of relying on the org env vars being absent.
test.skip(
- process.env.NEXT_PUBLIC_IS_CLOUD_ENV !== "true",
+ !isCloud(),
"AWS Organizations multi-account onboarding is a Cloud-only feature",
);
diff --git a/ui/tests/runtime-config/runtime-config-page.ts b/ui/tests/runtime-config/runtime-config-page.ts
index 430033eba4..a9e5242b92 100644
--- a/ui/tests/runtime-config/runtime-config-page.ts
+++ b/ui/tests/runtime-config/runtime-config-page.ts
@@ -20,6 +20,7 @@ export const RUNTIME_CONFIG_KEYS = [
"posthogHost",
"reoDevClientId",
"cloudBillingEnabled",
+ "cloudEnabled",
"stripePublishableKey",
"stripePublishableKeyV2",
] as const satisfies ReadonlyArray;
diff --git a/ui/tsconfig.json b/ui/tsconfig.json
index 40977d892c..b95dc8c77a 100644
--- a/ui/tsconfig.json
+++ b/ui/tsconfig.json
@@ -24,7 +24,7 @@
"strict": true,
"target": "es5"
},
- "exclude": ["node_modules", "vitest.config.ts"],
+ "exclude": ["node_modules", "vitest.config.ts", "eslint.config.ts"],
"include": [
"next-env.d.ts",
"**/*.ts",
diff --git a/ui/types/attack-paths.ts b/ui/types/attack-paths.ts
index e1378a1b1c..28201b2367 100644
--- a/ui/types/attack-paths.ts
+++ b/ui/types/attack-paths.ts
@@ -129,6 +129,20 @@ export interface AttackPathQueryDocumentationLink {
link: string;
}
+export const ATTACK_PATH_QUERY_STATUSES = {
+ OK: "ok",
+ ERROR: "error",
+} as const;
+
+export type AttackPathQueryStatus =
+ (typeof ATTACK_PATH_QUERY_STATUSES)[keyof typeof ATTACK_PATH_QUERY_STATUSES];
+
+// Cloud-only precomputed query summary. Missing/null means no definitive empty verdict.
+export interface AttackPathQueryResultSummary {
+ status: AttackPathQueryStatus;
+ has_data: boolean | null;
+}
+
export interface AttackPathQueryAttributes {
name: string;
short_description: string;
@@ -137,6 +151,7 @@ export interface AttackPathQueryAttributes {
parameters: AttackPathQueryParameter[];
attribution: AttackPathQueryAttribution | null;
documentation_link?: AttackPathQueryDocumentationLink | null;
+ result_summary?: AttackPathQueryResultSummary | null;
}
export interface AttackPathQuery {
diff --git a/ui/types/authFormSchema.ts b/ui/types/authFormSchema.ts
index 42e1d1d594..1d2c1074f9 100644
--- a/ui/types/authFormSchema.ts
+++ b/ui/types/authFormSchema.ts
@@ -1,5 +1,6 @@
import { z } from "zod";
+import { isCloud } from "@/lib/shared/env";
import { SPECIAL_CHARACTERS } from "@/lib/utils";
export type AuthSocialProvider = "google" | "github";
@@ -104,12 +105,12 @@ export const signUpSchema = baseAuthSchema
}),
company: z.string().optional(),
invitationToken: z.string().optional(),
- termsAndConditions:
- process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"
- ? z.boolean().refine((value) => value === true, {
- message: "You must accept the terms and conditions.",
- })
- : z.boolean().optional(),
+ termsAndConditions: z
+ .boolean()
+ .optional()
+ .refine((value) => !isCloud() || value === true, {
+ error: "You must accept the terms and conditions.",
+ }),
})
.refine(
(data) => {
diff --git a/ui/types/cloud-upgrade.ts b/ui/types/cloud-upgrade.ts
index 8da89babf8..39c56d269b 100644
--- a/ui/types/cloud-upgrade.ts
+++ b/ui/types/cloud-upgrade.ts
@@ -5,6 +5,7 @@ export const CLOUD_UPGRADE_FEATURE = {
CLI_IMPORT: "cli_import",
CROSS_PROVIDER_COMPLIANCE: "cross_provider_compliance",
FINDING_TRIAGE: "finding_triage",
+ JIRA_DISPATCH: "jira_dispatch",
LIGHTHOUSE_AI: "lighthouse_ai",
GENERAL: "general",
SCAN_CONFIGURATION: "scan_configuration",
diff --git a/ui/types/env.d.ts b/ui/types/env.d.ts
index c4ec4d85a3..8b5a1ce039 100644
--- a/ui/types/env.d.ts
+++ b/ui/types/env.d.ts
@@ -28,6 +28,9 @@ declare global {
NEXT_PUBLIC_SENTRY_ENVIRONMENT?: string;
UI_SENTRY_ENVIRONMENT?: string;
+ // Prowler Cloud deployment flag — runtime read (server env, client island).
+ UI_CLOUD_ENABLED?: "true" | "false";
+
CLOUD_BILLING_ENABLED?: "legacy" | "metronome" | "false";
// Cloud-only Stripe publishable keys (public; shipped to the browser).
@@ -40,7 +43,6 @@ declare global {
UI_CLOUD_STRIPE_PUBLISHABLE_KEY_V2?: string;
// Build-time public config
- NEXT_PUBLIC_IS_CLOUD_ENV?: "true" | "false";
NEXT_PUBLIC_PROWLER_RELEASE_VERSION?: string;
// Auth (NextAuth)
diff --git a/ui/types/formSchemas.test.ts b/ui/types/formSchemas.test.ts
index 211ee35e55..6909c6f60a 100644
--- a/ui/types/formSchemas.test.ts
+++ b/ui/types/formSchemas.test.ts
@@ -6,6 +6,7 @@ import {
addCredentialsFormSchema,
addCredentialsRoleFormSchema,
addProviderFormSchema,
+ KUBECONFIG_UNSUPPORTED_COMMAND_AUTHENTICATION_ERROR,
} from "./formSchemas";
const BASE_AWS_ROLE_VALUES = {
@@ -194,10 +195,57 @@ users:
expect(result.error.issues).toContainEqual(
expect.objectContaining({
path: [ProviderCredentialFields.KUBECONFIG_CONTENT],
+ message: KUBECONFIG_UNSUPPORTED_COMMAND_AUTHENTICATION_ERROR,
}),
);
});
+ it("reports kubeconfig auth-provider cmd-path on kubeconfig_content field", () => {
+ const schema = addCredentialsFormSchema("kubernetes");
+
+ const result = schema.safeParse({
+ ...BASE_KUBERNETES_VALUES,
+ [ProviderCredentialFields.KUBECONFIG_CONTENT]: `apiVersion: v1
+kind: Config
+users:
+ - name: test-user
+ user:
+ auth-provider:
+ name: gcp
+ config:
+ cmd-path: /bin/sh`,
+ });
+
+ expect(result.success).toBe(false);
+ if (result.success) return;
+
+ expect(result.error.issues).toContainEqual(
+ expect.objectContaining({
+ path: [ProviderCredentialFields.KUBECONFIG_CONTENT],
+ message: KUBECONFIG_UNSUPPORTED_COMMAND_AUTHENTICATION_ERROR,
+ }),
+ );
+ });
+
+ it("accepts kubeconfig auth-provider without cmd-path", () => {
+ const schema = addCredentialsFormSchema("kubernetes");
+
+ const result = schema.safeParse({
+ ...BASE_KUBERNETES_VALUES,
+ [ProviderCredentialFields.KUBECONFIG_CONTENT]: `apiVersion: v1
+kind: Config
+users:
+ - name: test-user
+ user:
+ auth-provider:
+ name: oidc
+ config:
+ client-id: prowler`,
+ });
+
+ expect(result.success).toBe(true);
+ });
+
it("accepts malformed kubeconfig content for backend validation", () => {
const schema = addCredentialsFormSchema("kubernetes");
diff --git a/ui/types/formSchemas.ts b/ui/types/formSchemas.ts
index 0e13f67814..a535821682 100644
--- a/ui/types/formSchemas.ts
+++ b/ui/types/formSchemas.ts
@@ -6,14 +6,14 @@ import { validateMutelistYaml, validateYaml } from "@/lib/yaml";
import { PROVIDER_TYPES, ProviderType } from "./providers";
-export const KUBECONFIG_EXEC_AUTHENTICATION_ERROR =
- "Kubernetes kubeconfig exec authentication is not supported in Prowler Cloud for security reasons.";
+export const KUBECONFIG_UNSUPPORTED_COMMAND_AUTHENTICATION_ERROR =
+ "Kubernetes kubeconfig command-based authentication is not supported in Prowler Cloud for security reasons.";
const isRecord = (value: unknown): value is Record => {
return typeof value === "object" && value !== null && !Array.isArray(value);
};
-export const kubeconfigContainsExecAuthentication = (
+export const kubeconfigContainsUnsupportedCommandAuthentication = (
value: string,
): boolean => {
try {
@@ -28,7 +28,16 @@ export const kubeconfigContainsExecAuthentication = (
return false;
}
- return "exec" in userEntry.user;
+ if ("exec" in userEntry.user) {
+ return true;
+ }
+
+ const authProvider = userEntry.user["auth-provider"];
+ if (!isRecord(authProvider) || !isRecord(authProvider.config)) {
+ return false;
+ }
+
+ return "cmd-path" in authProvider.config;
});
} catch {
return false;
@@ -229,9 +238,13 @@ export const addCredentialsFormSchema = (
.string()
.min(1, "Kubeconfig Content is required")
.refine(
- (value) => !kubeconfigContainsExecAuthentication(value),
+ (value) =>
+ !kubeconfigContainsUnsupportedCommandAuthentication(
+ value,
+ ),
{
- error: KUBECONFIG_EXEC_AUTHENTICATION_ERROR,
+ error:
+ KUBECONFIG_UNSUPPORTED_COMMAND_AUTHENTICATION_ERROR,
},
),
}
diff --git a/ui/types/integrations.ts b/ui/types/integrations.ts
index b84869af1c..77ee623ad3 100644
--- a/ui/types/integrations.ts
+++ b/ui/types/integrations.ts
@@ -4,6 +4,63 @@ import type { TaskState } from "@/types/tasks";
export type IntegrationType = "amazon_s3" | "aws_security_hub" | "jira";
+export const JIRA_DISPATCH_MODE = {
+ INDIVIDUAL: "individual",
+ GROUPED: "grouped",
+} as const;
+
+export type JiraDispatchMode =
+ (typeof JIRA_DISPATCH_MODE)[keyof typeof JIRA_DISPATCH_MODE];
+
+export const JIRA_DISPATCH_TARGET = {
+ CHECK_ID: "check_id",
+ FINDING_ID: "finding_id",
+} as const;
+
+export type JiraDispatchTarget =
+ (typeof JIRA_DISPATCH_TARGET)[keyof typeof JIRA_DISPATCH_TARGET];
+
+export const JIRA_TARGET_SELECTION_KIND = {
+ SINGLE: "single",
+ TARGET_LIST: "target-list",
+ BATCHES: "batches",
+} as const;
+
+export type JiraTargetSelectionKind =
+ (typeof JIRA_TARGET_SELECTION_KIND)[keyof typeof JIRA_TARGET_SELECTION_KIND];
+
+export type NonEmptyStringArray = [string, ...string[]];
+
+export interface JiraDispatchTargetBatch {
+ targetIds: NonEmptyStringArray;
+ targetType: JiraDispatchTarget;
+ dispatchMode?: JiraDispatchMode;
+}
+
+export interface JiraSingleTargetSelection {
+ kind: typeof JIRA_TARGET_SELECTION_KIND.SINGLE;
+ targetId: string;
+ targetType: JiraDispatchTarget;
+}
+
+export interface JiraTargetListSelection {
+ kind: typeof JIRA_TARGET_SELECTION_KIND.TARGET_LIST;
+ targetIds: NonEmptyStringArray;
+ targetType: JiraDispatchTarget;
+}
+
+export interface JiraBatchSelection {
+ kind: typeof JIRA_TARGET_SELECTION_KIND.BATCHES;
+ batches: [JiraDispatchTargetBatch, ...JiraDispatchTargetBatch[]];
+}
+
+export type JiraSelection =
+ | JiraSingleTargetSelection
+ | JiraTargetListSelection
+ | JiraBatchSelection;
+
+export const JIRA_DISPATCH_TASK_KIND = "jira-dispatch";
+
export interface IntegrationProps {
type: "integrations";
id: string;
@@ -45,6 +102,7 @@ export interface JiraDispatchRequest {
attributes: {
project_key: string;
issue_type: string;
+ dispatch_mode?: JiraDispatchMode;
};
};
}
@@ -58,21 +116,30 @@ export interface JiraDispatchResponse {
completed_at: string | null;
name: string;
state: TaskState;
- result: {
- success?: boolean;
- error?: string;
- message?: string;
- issue_url?: string;
- issue_key?: string;
- created_count?: number;
- failed_count?: number;
- } | null;
+ result: JiraDispatchTaskResult | null;
task_args: Record | null;
metadata: Record | null;
};
};
}
+export interface JiraDispatchTaskResult {
+ success?: boolean;
+ error?: string;
+ message?: string;
+ successful_count?: number;
+ created_count?: number;
+ updated_count?: number;
+ failed_count?: number;
+ created_issues?: unknown[];
+ updated_issues?: unknown[];
+ failed_groups?: unknown[];
+ failed_batches?: unknown[];
+ failed_finding_ids?: string[];
+ issue_url?: string;
+ issue_key?: string;
+}
+
// Shared AWS credential fields schema
const awsCredentialFields = {
credentials_type: z.enum(["aws-sdk-default", "access-secret-key"]),
diff --git a/ui/types/jira-dispatch.ts b/ui/types/jira-dispatch.ts
new file mode 100644
index 0000000000..0823073d4b
--- /dev/null
+++ b/ui/types/jira-dispatch.ts
@@ -0,0 +1,9 @@
+import type { JiraSelection } from "@/types/integrations";
+
+export interface JiraDispatchModalPayload {
+ selection: JiraSelection;
+ findingTitle?: string;
+ selectedResourceCount?: number;
+ isFindingGroupSelection?: boolean;
+ description?: string;
+}
diff --git a/ui/vitest.setup.ts b/ui/vitest.setup.ts
index 796e3ea900..2cc2aa8a8a 100644
--- a/ui/vitest.setup.ts
+++ b/ui/vitest.setup.ts
@@ -1,5 +1,9 @@
import "@testing-library/jest-dom/vitest";
+// An ambient UI_CLOUD_ENABLED in a developer's shell would silently flip
+// every test that relies on the OSS default — clear it.
+delete process.env.UI_CLOUD_ENABLED;
+
class MockStorage implements Storage {
private readonly store = new Map();
diff --git a/uv.lock b/uv.lock
index 0167a7264f..ebed613212 100644
--- a/uv.lock
+++ b/uv.lock
@@ -95,6 +95,17 @@ constraints = [
{ name = "httpcore", specifier = "==1.0.9" },
{ name = "httplib2", specifier = "==0.31.2" },
{ name = "httpx", specifier = "==0.28.1" },
+ { name = "huaweicloudsdkcore", specifier = "==3.1.204" },
+ { name = "huaweicloudsdkcts", specifier = "==3.1.204" },
+ { name = "huaweicloudsdkecs", specifier = "==3.1.204" },
+ { name = "huaweicloudsdkelb", specifier = "==3.1.204" },
+ { name = "huaweicloudsdkevs", specifier = "==3.1.204" },
+ { name = "huaweicloudsdkiam", specifier = "==3.1.204" },
+ { name = "huaweicloudsdkkms", specifier = "==3.1.204" },
+ { name = "huaweicloudsdkobs", specifier = "==3.1.204" },
+ { name = "huaweicloudsdkrds", specifier = "==3.1.204" },
+ { name = "huaweicloudsdkvpc", specifier = "==3.1.204" },
+ { name = "huaweicloudsdkwaf", specifier = "==3.1.204" },
{ name = "hyperframe", specifier = "==6.1.0" },
{ name = "iamdata", specifier = "==0.1.202605131" },
{ name = "idna", specifier = "==3.15" },
@@ -2237,6 +2248,134 @@ http2 = [
{ name = "h2" },
]
+[[package]]
+name = "huaweicloudsdkcore"
+version = "3.1.204"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "defusedxml" },
+ { name = "pyasn1" },
+ { name = "pymongo" },
+ { name = "pyyaml" },
+ { name = "requests-toolbelt" },
+ { name = "simplejson" },
+ { name = "six" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e4/f5/65e90764ea3bbfef50fb68cd5e12340acf1f51e9276b11745fbf5feb7e0e/huaweicloudsdkcore-3.1.204-py3-none-any.whl", hash = "sha256:9ae17744795ebdc8ce9291373a3a27bf72e90aa98677cfce0ea9394376875a95", size = 69578, upload-time = "2026-07-09T09:01:59.715Z" },
+]
+
+[[package]]
+name = "huaweicloudsdkcts"
+version = "3.1.204"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "huaweicloudsdkcore" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/58/32/d06328e35375d4aa606719a27856cdf57b1f7fb0c49d4dfd22a9609dba19/huaweicloudsdkcts-3.1.204-py3-none-any.whl", hash = "sha256:9def561aa784a6ee13b46bfc96888cd1df5bfc42f8a89e60b42c91c608bf6d60", size = 121768, upload-time = "2026-07-09T09:02:08.16Z" },
+]
+
+[[package]]
+name = "huaweicloudsdkecs"
+version = "3.1.204"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "huaweicloudsdkcore" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/66/f8e4a3b9ca70d3ea79c4d200f928ed9ffdf4910ac01be4864967408c8f18/huaweicloudsdkecs-3.1.204-py3-none-any.whl", hash = "sha256:dc5715d782c0260b901c793d009d5e632257acb04257b6f2c6631e415c589343", size = 765699, upload-time = "2026-07-09T09:02:39.272Z" },
+]
+
+[[package]]
+name = "huaweicloudsdkelb"
+version = "3.1.204"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "huaweicloudsdkcore" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/34/4b/9bdc7e2066419d967e9b812cfacfb9c4996a8e977dc39853309a3c1ac9e2/huaweicloudsdkelb-3.1.204-py3-none-any.whl", hash = "sha256:620247c2b2a7f20e7da8b18fe9c64e29972055f015bc35270fb5b43243dc4830", size = 1292397, upload-time = "2026-07-09T09:02:45.656Z" },
+]
+
+[[package]]
+name = "huaweicloudsdkevs"
+version = "3.1.204"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "huaweicloudsdkcore" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f7/cf/531dc55fd9d0f3bbd3eef24c7e4d78c6a1ba8eb80fa506e3574d72bcc98a/huaweicloudsdkevs-3.1.204-py3-none-any.whl", hash = "sha256:9118ac4c576e54aa7eaa926949e2b6824c5f038a2274b51d9a304d37fc0d7e2f", size = 251404, upload-time = "2026-07-09T09:02:50.05Z" },
+]
+
+[[package]]
+name = "huaweicloudsdkiam"
+version = "3.1.204"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "huaweicloudsdkcore" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f7/9a/7da0fbe9b83bc7a7f6d586366b81e829ed355a57419d2184b7dd51f8c2a3/huaweicloudsdkiam-3.1.204-py3-none-any.whl", hash = "sha256:0021e204f81ceef2640017e517adb72ba56c9ced03f071a0265b10bc9759badf", size = 1251350, upload-time = "2026-07-09T09:03:05.467Z" },
+]
+
+[[package]]
+name = "huaweicloudsdkkms"
+version = "3.1.204"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "huaweicloudsdkcore" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/28/3a/7392617d585cb2005f7d9ade0b0e0e493a7a88daf56e8dc39e4e219cc5d0/huaweicloudsdkkms-3.1.204-py3-none-any.whl", hash = "sha256:378986f33113ce99f445ef318d1c7dda89e361d16c008e5ef9793981d8385376", size = 275690, upload-time = "2026-07-09T09:03:29.833Z" },
+]
+
+[[package]]
+name = "huaweicloudsdkobs"
+version = "3.1.204"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "huaweicloudsdkcore" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/af/49/28a09e1e33d1c039be22ee4171efaa739351653c7aa88d3a2f7a78d90217/huaweicloudsdkobs-3.1.204-py3-none-any.whl", hash = "sha256:8c5830fa30293185964d98e524887fc510c8e17ca2fadb4563dad10910f37b13", size = 235360, upload-time = "2026-07-09T09:03:52.171Z" },
+]
+
+[[package]]
+name = "huaweicloudsdkrds"
+version = "3.1.204"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "huaweicloudsdkcore" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/7d/721f162c46e3de604a73674223bf6c6bc6cf7ade25b3751a71288f4dd122/huaweicloudsdkrds-3.1.204-py3-none-any.whl", hash = "sha256:a790b5b3c457a608e5679c101f463b4d037dd9a8a66f6e46144a9e5a4b37780f", size = 1626906, upload-time = "2026-07-09T09:04:06.936Z" },
+]
+
+[[package]]
+name = "huaweicloudsdkvpc"
+version = "3.1.204"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "huaweicloudsdkcore" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/b5/4baa27c3a275ea92806068e35e06f249a30add8dd57c777bb45841f63406/huaweicloudsdkvpc-3.1.204-py3-none-any.whl", hash = "sha256:c57d6b6d2f70deca91e86f7956b33fc9ac4991b431f0d608c3632231119f8970", size = 1124332, upload-time = "2026-07-09T09:04:39.797Z" },
+]
+
+[[package]]
+name = "huaweicloudsdkwaf"
+version = "3.1.204"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "huaweicloudsdkcore" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/21/01590dce200be487756451f5e9efb99da7810688062d177e4b58d6062465/huaweicloudsdkwaf-3.1.204-py3-none-any.whl", hash = "sha256:b2355276e0029808f45e2d1bd3eb14b37d2da9417e61e642ffe0e2b748ca8283", size = 1337762, upload-time = "2026-07-09T09:04:43.688Z" },
+]
+
[[package]]
name = "hyperframe"
version = "6.1.0"
@@ -3553,7 +3692,7 @@ wheels = [
[[package]]
name = "prowler"
-version = "5.36.0"
+version = "5.37.0"
source = { editable = "." }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },
@@ -3609,6 +3748,17 @@ dependencies = [
{ name = "google-api-python-client" },
{ name = "google-auth-httplib2" },
{ name = "h2" },
+ { name = "huaweicloudsdkcore" },
+ { name = "huaweicloudsdkcts" },
+ { name = "huaweicloudsdkecs" },
+ { name = "huaweicloudsdkelb" },
+ { name = "huaweicloudsdkevs" },
+ { name = "huaweicloudsdkiam" },
+ { name = "huaweicloudsdkkms" },
+ { name = "huaweicloudsdkobs" },
+ { name = "huaweicloudsdkrds" },
+ { name = "huaweicloudsdkvpc" },
+ { name = "huaweicloudsdkwaf" },
{ name = "jsonschema" },
{ name = "kingfisher-bin" },
{ name = "kubernetes" },
@@ -3718,6 +3868,17 @@ requires-dist = [
{ name = "google-api-python-client", specifier = "==2.163.0" },
{ name = "google-auth-httplib2", specifier = "==0.2.0" },
{ name = "h2", specifier = "==4.3.0" },
+ { name = "huaweicloudsdkcore", specifier = "==3.1.204" },
+ { name = "huaweicloudsdkcts", specifier = "==3.1.204" },
+ { name = "huaweicloudsdkecs", specifier = "==3.1.204" },
+ { name = "huaweicloudsdkelb", specifier = "==3.1.204" },
+ { name = "huaweicloudsdkevs", specifier = "==3.1.204" },
+ { name = "huaweicloudsdkiam", specifier = "==3.1.204" },
+ { name = "huaweicloudsdkkms", specifier = "==3.1.204" },
+ { name = "huaweicloudsdkobs", specifier = "==3.1.204" },
+ { name = "huaweicloudsdkrds", specifier = "==3.1.204" },
+ { name = "huaweicloudsdkvpc", specifier = "==3.1.204" },
+ { name = "huaweicloudsdkwaf", specifier = "==3.1.204" },
{ name = "jsonschema", specifier = "==4.23.0" },
{ name = "kingfisher-bin", specifier = "==1.104.0" },
{ name = "kubernetes", specifier = "==32.0.1" },
@@ -4090,6 +4251,67 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/8b/eef15df5f4e7aa393de31feb96ca9a3d6639669bd59d589d0685d5ef4e62/pylint-3.3.4-py3-none-any.whl", hash = "sha256:289e6a1eb27b453b08436478391a48cd53bb0efb824873f949e709350f3de018", size = 522280, upload-time = "2025-01-28T13:28:18.044Z" },
]
+[[package]]
+name = "pymongo"
+version = "4.15.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "dnspython" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/22/f5/c0c6732fbd358b75a07e17d7e588fd23d481b9812ca96ceeff90bbf879fc/pymongo-4.15.1.tar.gz", hash = "sha256:b9f379a4333dc3779a6bf7adfd077d4387404ed1561472743486a9c58286f705", size = 2470613, upload-time = "2025-09-16T16:39:47.24Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/29/19/2de6086e3974f3a95a1fc41fd082bc4a58dc9b70268cbfd7c84067d184f2/pymongo-4.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:97ccf8222abd5b79daa29811f64ef8b6bb678b9c9a1c1a2cfa0a277f89facd1d", size = 811020, upload-time = "2025-09-16T16:37:57.329Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/a4/a340dde32818dd5c95b1c373bc4a27cef5863009faa328388ddc899527fe/pymongo-4.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f130b3d7540749a8788a254ceb199a03ede4ee080061bfa5e20e28237c87f2d7", size = 811313, upload-time = "2025-09-16T16:37:59.312Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/d9/7d64fdc9e87ec38bd36395bc730848ef56e1cd4bd29ab065d53c27559ace/pymongo-4.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fbe6a044a306ed974bd1788f3ceffc2f5e13f81fdb786a28c948c047f4cea38", size = 1188666, upload-time = "2025-09-16T16:38:00.896Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/d9/47cc69d3b22c9d971b1486e3a80d6a5d0bbf2dec6c9c4d5e39a129ee8125/pymongo-4.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b96768741e0e03451ef7b07c4857490cc43999e01c7f8da704fe00b3fe5d4d3", size = 1222891, upload-time = "2025-09-16T16:38:02.574Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/73/a57594c956bf276069a438056330a346871b2f5e3cae4e3bcc257cffc788/pymongo-4.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d50b18ad6e4a55a75c30f0e669bd15ed1ceb18f9994d6835b4f5d5218592b4a0", size = 1205824, upload-time = "2025-09-16T16:38:04.277Z" },
+ { url = "https://files.pythonhosted.org/packages/37/d5/1ae77ddcc376ebce0139614d51ec1fd0ba666d7cc1f198ec88272cfdac36/pymongo-4.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e8e2a33613b2880d516d9c8616b64d27957c488de2f8e591945cf12094336a5", size = 1191838, upload-time = "2025-09-16T16:38:05.728Z" },
+ { url = "https://files.pythonhosted.org/packages/35/07/ae3fc20a809066b35bbf470bda79d34a72948603d9f29a425bf1d0ef2cb7/pymongo-4.15.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a2a439395f3d4c9d3dc33ba4575d52b6dd285d57db54e32062ae8ef557cab10", size = 1170996, upload-time = "2025-09-16T16:38:09.084Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/0f/eb654cea7586588704151ac4894cd3fb2582c0db458cd615cad1c7fe4c59/pymongo-4.15.1-cp310-cp310-win32.whl", hash = "sha256:142abf2fbd4667a3c8f4ce2e30fdbd287c015f52a838f4845d7476a45340208d", size = 798249, upload-time = "2025-09-16T16:38:11.11Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/6b/38184382c32695f914a5474d8de0c9f3714b7d8f4c66f090b3836d70273d/pymongo-4.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:8baf46384c97f774bc84178662e1fc6e32a2755fbc8e259f424780c2a11a3566", size = 807990, upload-time = "2025-09-16T16:38:12.525Z" },
+ { url = "https://files.pythonhosted.org/packages/38/eb/77a4d37b2a0673c010dd97b9911438f17bb05f407235cc9f02074175855d/pymongo-4.15.1-cp310-cp310-win_arm64.whl", hash = "sha256:b5b837df8e414e2a173722395107da981d178ba7e648f612fa49b7ab4e240852", size = 800875, upload-time = "2025-09-16T16:38:14.532Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/da/89066930a70b4299844f1155fc23baaa7e30e77c8a0cbf62a2ae06ee34a5/pymongo-4.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:363445cc0e899b9e55ac9904a868c8a16a6c81f71c48dbadfd78c98e0b54de27", size = 865410, upload-time = "2025-09-16T16:38:16.279Z" },
+ { url = "https://files.pythonhosted.org/packages/99/8f/a1d0402d52e5ebd14283718abefdc0c16f308cf10bee56cdff04b1f5119b/pymongo-4.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:da0a13f345f4b101776dbab92cec66f0b75015df0b007b47bd73bfd0305cc56a", size = 865695, upload-time = "2025-09-16T16:38:18.015Z" },
+ { url = "https://files.pythonhosted.org/packages/53/38/d1ef69028923f86fd00638d9eb16400d4e60a89eabd2011fe631fd3186cf/pymongo-4.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9481a492851e432122a83755d4e69c06aeb087bbf8370bac9f96d112ac1303fd", size = 1434758, upload-time = "2025-09-16T16:38:20.141Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/eb/a8d5dff748a2dd333610b2e4c8120b623e38ea2b5e30ad190d0ce2803840/pymongo-4.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:625dec3e9cd7c3d336285a20728c01bfc56d37230a99ec537a6a8625af783a43", size = 1485716, upload-time = "2025-09-16T16:38:21.607Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/d4/17ba457a828b733182ddc01a202872fef3006eed6b54450b20dc95a2f77d/pymongo-4.15.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26a31af455bffcc64537a7f67e2f84833a57855a82d05a085a1030c471138990", size = 1460160, upload-time = "2025-09-16T16:38:23.509Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/25/42b8662c09f5ca9c81d18d160f48e58842e0fa4c314ea02613c5e5d54542/pymongo-4.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea4415970d2a074d5890696af10e174d84cb735f1fa7673020c7538431e1cb6e", size = 1439284, upload-time = "2025-09-16T16:38:25.248Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/bb/46b9d978161828eb91973bd441a3f05f73c789203e976332a8de2832d5db/pymongo-4.15.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:51ee050a2e026e2b224d2ed382830194be20a81c78e1ef98f467e469071df3ac", size = 1407933, upload-time = "2025-09-16T16:38:27.045Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/55/bd5af98f675001f4b06f7314b3918e45809424a7ad3510f823f6703cd8f2/pymongo-4.15.1-cp311-cp311-win32.whl", hash = "sha256:9aef07d33839f6429dc24f2ef36e4ec906979cb4f628c57a1c2676cc66625711", size = 844328, upload-time = "2025-09-16T16:38:28.513Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/78/90989a290dd458ed43a8a04fa561ac9c7b3391f395cdacd42e21f0f22ce4/pymongo-4.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ea6e5ff4d6747e7b64966629a964db3089e9c1e0206d8f9cc8720c90f5a7af1", size = 858951, upload-time = "2025-09-16T16:38:30.074Z" },
+ { url = "https://files.pythonhosted.org/packages/de/bb/d4d23f06e166cd773f2324cff73841a62d78a1ad16fb799cf7c5490ce32c/pymongo-4.15.1-cp311-cp311-win_arm64.whl", hash = "sha256:bb783d9001b464a6ef3ee76c30ebbb6f977caee7bbc3a9bb1bd2ff596e818c46", size = 848290, upload-time = "2025-09-16T16:38:31.741Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/31/bc4525312083706a59fffe6e8de868054472308230fdee8db0c452c2b831/pymongo-4.15.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bab357c5ff36ba2340dfc94f3338ef399032089d35c3d257ce0c48630b7848b2", size = 920261, upload-time = "2025-09-16T16:38:33.614Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/55/4d99aec625494f21151b8b31e12e06b8ccd3b9dcff609b0dd1acf9bbbc0e/pymongo-4.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46d1af3eb2c274f07815372b5a68f99ecd48750e8ab54d5c3ff36a280fb41c8e", size = 919956, upload-time = "2025-09-16T16:38:35.121Z" },
+ { url = "https://files.pythonhosted.org/packages/be/60/8f1afa41521df950e13f6490ecdef48155fc63b78f926e7649045e07afd1/pymongo-4.15.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7dc31357379318881186213dc5fc49b62601c955504f65c8e72032b5048950a1", size = 1698596, upload-time = "2025-09-16T16:38:36.586Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/3f/e48d50ee8d6aa0a4cda7889dd73076ec2ab79a232716a5eb0b9df070ffcf/pymongo-4.15.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12140d29da1ecbaefee2a9e65433ef15d6c2c38f97bc6dab0ff246a96f9d20cd", size = 1762833, upload-time = "2025-09-16T16:38:38.09Z" },
+ { url = "https://files.pythonhosted.org/packages/63/87/db976859efc617f608754e051e1468459d9a818fe1ad5d0862e8af57720b/pymongo-4.15.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf193d2dcd91fa1d1dfa1fd036a3b54f792915a4842d323c0548d23d30461b59", size = 1731875, upload-time = "2025-09-16T16:38:39.742Z" },
+ { url = "https://files.pythonhosted.org/packages/18/59/3643ad52a5064ad3ef8c32910de6da28eb658234c25f2db5366f16bffbfb/pymongo-4.15.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2c0bdcf4d57e4861ed323ba430b585ad98c010a83e46cb8aa3b29c248a82be1", size = 1701853, upload-time = "2025-09-16T16:38:41.333Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/96/441c190823f855fc6445ea574b39dca41156acf723c5e6a69ee718421700/pymongo-4.15.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43fcfc19446e0706bbfe86f683a477d1e699b02369dd9c114ec17c7182d1fe2b", size = 1660978, upload-time = "2025-09-16T16:38:42.877Z" },
+ { url = "https://files.pythonhosted.org/packages/47/49/bd7e783fb78aaf9bdaa3f88cc238449be5bc5546e930ec98845ef235f809/pymongo-4.15.1-cp312-cp312-win32.whl", hash = "sha256:e5fedea0e7b3747da836cd5f88b0fa3e2ec5a394371f9b6a6b15927cfeb5455d", size = 891175, upload-time = "2025-09-16T16:38:44.658Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/28/7de5858bdeaa07ea4b277f9eb06123ea358003659fe55e72e4e7c898b321/pymongo-4.15.1-cp312-cp312-win_amd64.whl", hash = "sha256:330a17c1c89e2c3bf03ed391108f928d5881298c17692199d3e0cdf097a20082", size = 910619, upload-time = "2025-09-16T16:38:46.124Z" },
+ { url = "https://files.pythonhosted.org/packages/17/87/c39f4f8415e7c65f8b66413f53a9272211ff7dfe78a5128b27027bf88864/pymongo-4.15.1-cp312-cp312-win_arm64.whl", hash = "sha256:756b7a2a80ec3dd5b89cd62e9d13c573afd456452a53d05663e8ad0c5ff6632b", size = 896229, upload-time = "2025-09-16T16:38:48.563Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/22/02ac885d8accb4c86ae92e99681a09f3fd310c431843fc850e141b42ab17/pymongo-4.15.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:622957eed757e44d9605c43b576ef90affb61176d9e8be7356c1a2948812cb84", size = 974492, upload-time = "2025-09-16T16:38:50.437Z" },
+ { url = "https://files.pythonhosted.org/packages/56/bf/71685b6b2d085dbaadf029b1ea4a1bc7a1bc483452513dea283b47a5f7c0/pymongo-4.15.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c5283dffcf601b793a57bb86819a467473bbb1bf21cd170c0b9648f933f22131", size = 974191, upload-time = "2025-09-16T16:38:52.725Z" },
+ { url = "https://files.pythonhosted.org/packages/df/98/141edc92fa97af96b4c691e10a7225ac3e552914e88b7a8d439bd6bc9fcc/pymongo-4.15.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:def51dea1f8e336aed807eb5d2f2a416c5613e97ec64f07479681d05044c217c", size = 1962311, upload-time = "2025-09-16T16:38:54.319Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/a9/601b91607af1dec8035b46ba67a5a023c819ccedd40d6f6232e15bf76030/pymongo-4.15.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24171b2015052b2f0a3f8cbfa38b973fa87f6474e88236a4dfeb735983f9f49e", size = 2039667, upload-time = "2025-09-16T16:38:55.958Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/71/02e9a5248e0a9dfc371fd7350f8b11eac03d9eb3662328978f37613d319a/pymongo-4.15.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64b60ed7220c52f8c78c7af8d2c58f7e415732e21b3ff7e642169efa6e0b11e7", size = 2003579, upload-time = "2025-09-16T16:38:57.576Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/d1/b1a9520b33e022ed1c0d2d43e8805ba18d3d686fc9c9d89a507593f6dd86/pymongo-4.15.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58236ce5ba3a79748c1813221b07b411847fd8849ff34c2891ba56f807cce3e5", size = 1964307, upload-time = "2025-09-16T16:38:59.219Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/d1/1d205a762020f056c05899a912364c48bac0f3438502b36d057aa1da3ca5/pymongo-4.15.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7461e777b3da96568c1f077b1fbf9e0c15667ac4d8b9a1cf90d80a69fe3be609", size = 1913879, upload-time = "2025-09-16T16:39:01.266Z" },
+ { url = "https://files.pythonhosted.org/packages/44/d1/0a3ab2440ea00b6423f33c84e6433022fd51f3561dede9346f54f39cf4dd/pymongo-4.15.1-cp313-cp313-win32.whl", hash = "sha256:45f0a2fb09704ca5e0df08a794076d21cbe5521d3a8ceb8ad6d51cef12f5f4e7", size = 938007, upload-time = "2025-09-16T16:39:03.427Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/61/e9ea839af2caadfde91774549a6f72450b72efdc92117995e7117d4b1270/pymongo-4.15.1-cp313-cp313-win_amd64.whl", hash = "sha256:b70201a6dbe19d0d10a886989d3ba4b857ea6ef402a22a61c8ca387b937cc065", size = 962236, upload-time = "2025-09-16T16:39:05.018Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/f8/0a92a72993b2e1c110ee532650624ca7ae15c5e45906dbae4f063a2fd32a/pymongo-4.15.1-cp313-cp313-win_arm64.whl", hash = "sha256:6892ebf8b2bc345cacfe1301724195d87162f02d01c417175e9f27d276a2f198", size = 944138, upload-time = "2025-09-16T16:39:07.035Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/eb/2ba257482844bb2e3c82c6b266d6e811bc610fa80408133e352cc1afb3c9/pymongo-4.15.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:db439288516514713c8ee09c9baaf66bc4b0188fbe4cd578ef3433ee27699aab", size = 1030987, upload-time = "2025-09-16T16:39:08.914Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/86/8c6eab3767251ba77a3604d3b6b0826d0af246bd04b2d16aced3a54f08b0/pymongo-4.15.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:234c80a5f21c8854cc5d6c2f5541ff17dd645b99643587c5e7ed1e21d42003b6", size = 1030996, upload-time = "2025-09-16T16:39:10.429Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/26/c1bc0bcb64f39b9891b8b537f21cc37d668edd8b93f47ed930af7f95649c/pymongo-4.15.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b570dc8179dcab980259b885116b14462bcf39170e30d8cbcce6f17f28a2ac5b", size = 2290670, upload-time = "2025-09-16T16:39:12.348Z" },
+ { url = "https://files.pythonhosted.org/packages/82/af/f5e8b6c404a3678a99bf0b704f7b19fa14a71edb42d724eb09147aa1d3be/pymongo-4.15.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cb6321bde02308d4d313b487d19bfae62ea4d37749fc2325b1c12388e05e4c31", size = 2377711, upload-time = "2025-09-16T16:39:13.992Z" },
+ { url = "https://files.pythonhosted.org/packages/af/f4/63bcc1760bf3e0925cb6cb91b2b3ba756c113b1674a14b41efe7e3738b8d/pymongo-4.15.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc808588289f693aba80fae8272af4582a7d6edc4e95fb8fbf65fe6f634116ce", size = 2337097, upload-time = "2025-09-16T16:39:15.717Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/dc/0cfada0426556b4b04144fb00ce6a1e7535ab49623d4d9dd052d27ea46c0/pymongo-4.15.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99236fd0e0cf6b048a4370d0df6820963dc94f935ad55a2e29af752272abd6c9", size = 2288295, upload-time = "2025-09-16T16:39:17.385Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/a8/081a80f60042d2b8cd6a1c091ecaa186f1ef216b587d06acd0743e1016c6/pymongo-4.15.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2277548bb093424742325b2a88861d913d8990f358fc71fd26004d1b87029bb8", size = 2227616, upload-time = "2025-09-16T16:39:19.025Z" },
+ { url = "https://files.pythonhosted.org/packages/56/d0/a6007e0c3c5727391ac5ea40e93a1e7d14146c65ac4ca731c0680962eb48/pymongo-4.15.1-cp313-cp313t-win32.whl", hash = "sha256:754a5d75c33d49691e2b09a4e0dc75959e271a38cbfd92c6b36f7e4eafc4608e", size = 987225, upload-time = "2025-09-16T16:39:20.663Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/97/c9bf6dcd647a8cf7abbad5814dfb7d8a16e6ab92a3e56343b3bcb454a6d3/pymongo-4.15.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8d62e68ad21661e536555d0683087a14bf5c74b242a4446c602d16080eb9e293", size = 1017521, upload-time = "2025-09-16T16:39:22.319Z" },
+ { url = "https://files.pythonhosted.org/packages/31/ea/102f7c9477302fa05e5303dd504781ac82400e01aab91bfba9c290253bd6/pymongo-4.15.1-cp313-cp313t-win_arm64.whl", hash = "sha256:56bbfb79b51e95f4b1324a5a7665f3629f4d27c18e2002cfaa60c907cc5369d9", size = 992963, upload-time = "2025-09-16T16:39:23.957Z" },
+]
+
[[package]]
name = "pynacl"
version = "1.6.2"
@@ -4462,6 +4684,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" },
]
+[[package]]
+name = "requests-toolbelt"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" },
+]
+
[[package]]
name = "requestsexceptions"
version = "1.4.0"
@@ -4685,6 +4919,59 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/c5/06/c6dcc975a1e7d89bc764fd271da8138b318e18080b48e7f1acd2ab63df28/shodan-1.31.0.tar.gz", hash = "sha256:c73275386ea02390e196c35c660706a28dd4d537c5a21eb387ab6236fac251f6", size = 57939, upload-time = "2023-12-17T01:42:02.426Z" }
+[[package]]
+name = "simplejson"
+version = "4.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0e/2a/54837395a3487c725669428d513293612a48d82b95a0642c936932e5d898/simplejson-4.1.1.tar.gz", hash = "sha256:c08eb9f7a90f77ae470e19a07472e9a79ebc0d1c2315d86a72767665bd5ba79f", size = 118860, upload-time = "2026-04-24T19:24:59.819Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/47/da/3ba5e87e917094961e7b51b541c88f735f1ca37d580ac78a9302b468f64e/simplejson-4.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7f61eefab86235c800e7f4e37d977080ec424bb2bf0b74e95a2d17ecb48eac0a", size = 111675, upload-time = "2026-04-24T19:22:30.344Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/8a/d0c08f4b8934b64469a63d461a68a01d5cc32faf313400dda2bdc1075a29/simplejson-4.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4484960512db9c8124bfa91e0d8a9f9c302338f1c5454e74c21d7d022df10f46", size = 90544, upload-time = "2026-04-24T19:22:32.095Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/2d/7832ed91cf4900f86c783d589bfac53358abfccb278f1c8b55eec167b395/simplejson-4.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b75c7ef874dbb350f41827cdf3cee23f5257bdcb0df46d4c01b34badb62dcfe8", size = 90895, upload-time = "2026-04-24T19:22:34.412Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/d6/a2a7a482fa43aaeaefc001491d381960f5e685ee4645343e0e037cebb57c/simplejson-4.1.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c7494c75b95171194f965ea609e97081837a26494d91dcc046ad27dd9c3503e2", size = 168660, upload-time = "2026-04-24T19:22:35.717Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/06/7a6482f336338dbdb6ca6d3099b2fdc1c74c47eea3c6511975751e9198df/simplejson-4.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1778e09a6e4bb4ef304627915dc4a838569d9e6b737c787925b4e98244bbbc16", size = 167264, upload-time = "2026-04-24T19:22:37.415Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/43/039982e956b06c6b019d48bdf9d4ec06f298adf6136552ad1979b94be0fd/simplejson-4.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:67e43e7c0555e10de6d83e1408035652fad28c983516e38c4e3a9a748c9af129", size = 176909, upload-time = "2026-04-24T19:22:38.872Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/f5/e3ad592d089922abce2c2ea377548953ac55ffcbe061d600f01b9db2e6b6/simplejson-4.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:93bf6653420258372444de90194dab8de8ff13d74b5d4263a5fefbbe8b8d2060", size = 165930, upload-time = "2026-04-24T19:22:40.575Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/b9/f830b648ae04601e6813306535d8e0a4c178d6453cec539b85dafdac80ed/simplejson-4.1.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0662cfe0482c9796bd097213b27f006815bfdc9b671264c3c0b7fc0e72b71d00", size = 174710, upload-time = "2026-04-24T19:22:42.437Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/3e/82c8997c4ef2ef6c832fbfc3bb2ed14a212616a284100af03b552ea7e072/simplejson-4.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a9ab55d2459f6d0fdf9984a7a0fb0280dae12979f4fcc3171f5096a4fcf5fafe", size = 167685, upload-time = "2026-04-24T19:22:44.023Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/03/80e67a6c63fe812094c681917a5c5d403e34904d200570416863fe2e8328/simplejson-4.1.1-cp310-cp310-win32.whl", hash = "sha256:dfb84ace97acbdf1916c5a675387493fc5a7f67c2e15d4a7687143f8c73024d4", size = 88317, upload-time = "2026-04-24T19:22:45.547Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/05/d4fa2c024d566bddff732a2aa437faa4cbee15ee277e2a855faf91a9d906/simplejson-4.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:8eb821ef27f688f59ed4a93b17a666a7ebacf8dd65fecaa2b3c531a3aea62eaf", size = 90461, upload-time = "2026-04-24T19:22:47.447Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/25/39013ffe279d90093ec1c848565b3683c586906c10fa55d9000ec29d046b/simplejson-4.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2867c64d92abd1992c15666fae198203093f593e43d6b81adf176bae530d493a", size = 111538, upload-time = "2026-04-24T19:22:49.051Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/ae/2c272971c8a87e2539c54a98eb6ff037bee1e2e93943c3986cf7500a4f3a/simplejson-4.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c47c46e16c8ea9e4850061e6ed5aa2b9cd2074cb2274bfd9c138cba15ce7453", size = 90594, upload-time = "2026-04-24T19:22:50.408Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/a2/6eebfb99dedc139f549200f61ade6d1890ac5707c5d427bdfa6fe39c9313/simplejson-4.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e294e33dbf316a9bbdd4030d46503c9b0f19470ae7ad6af5bae6c426bc2e869f", size = 90718, upload-time = "2026-04-24T19:22:51.694Z" },
+ { url = "https://files.pythonhosted.org/packages/80/7e/c9e6c0c4ad8415e64dad0c47f619b556b02680a41631b4dbc281d55dc54d/simplejson-4.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7ce252b28fddbdd83db5bd7d93dad2a8a591d7ada098afec9c1b23d6b722a7a4", size = 180901, upload-time = "2026-04-24T19:22:53.025Z" },
+ { url = "https://files.pythonhosted.org/packages/34/09/69e331e3994b1ed9be6ce9ace4ade704e7ed503edf869929ca7bb404eda8/simplejson-4.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c44ef6b02a4eb67ed17a72342341792149b3ff46f15426c26e970e49addf327", size = 178133, upload-time = "2026-04-24T19:22:54.574Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/40/ed806f24afef295c1032448f5ff6f6f2979392d5645ddb9f4fed7f38194d/simplejson-4.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82bfca2b85a34178c25829c703f0a9e9f113a5af7539285bd3efb583a0bf1ba3", size = 188155, upload-time = "2026-04-24T19:22:56.044Z" },
+ { url = "https://files.pythonhosted.org/packages/38/94/8d6f515b827b0f7881a49c8c1ac6920b7ae9428939ef04238c973278b42a/simplejson-4.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0e4b23f71dd781f8830f1663dc01a4944d3dbf87a1f93d78fba1cf64722d0ccf", size = 176225, upload-time = "2026-04-24T19:22:57.981Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/fd/6dffb4956563d48bbe46b91ff341adae34920e94008fd6b8d728072abfc7/simplejson-4.1.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:82fee635d7b73ad801030b05a75fbd34a098da0c2ecf600667a03636d09e1e42", size = 185535, upload-time = "2026-04-24T19:22:59.618Z" },
+ { url = "https://files.pythonhosted.org/packages/de/d2/a509ee37763e79aec75d68f8521db1440306edeba3b8b4064ab4ee8bf1d9/simplejson-4.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:68e62eda21192c5ea9bb92d571ca46a4477fef48762f50d433de2b4253051551", size = 179302, upload-time = "2026-04-24T19:23:01.324Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/23/5b343bfd2a79d3b6818e4db3586c405a001a090d4c89d336e31273ce7177/simplejson-4.1.1-cp311-cp311-win32.whl", hash = "sha256:ffd3d82294b47f5ec64050021ace95fd62628a0c1cc8bbf4d06d2d1fb697e055", size = 88408, upload-time = "2026-04-24T19:23:02.808Z" },
+ { url = "https://files.pythonhosted.org/packages/38/04/df9b37aedbd524dca20840d25ebe01d6ae486b89792aeff5d15b9c4114f7/simplejson-4.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:78a3fe0995be42bed62a26aa78e0e0b4d87c6545785346b9cc898f3389569a35", size = 90526, upload-time = "2026-04-24T19:23:04.408Z" },
+ { url = "https://files.pythonhosted.org/packages/60/25/e90998fe8e480eb43b966c09e835379887d427567ebd496563d3b1e16b19/simplejson-4.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:19040a17154dc03d289bab68d73ce0a6a0be01de30c584bbdd93490bead14b22", size = 112414, upload-time = "2026-04-24T19:23:06.084Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/a0/abd4785f36c3400f1fbb21f517be39295a750a714f04b7ee175adf6ef580/simplejson-4.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a94ebaecdbaa80d9551a3ec6bf0c9302fc8b53ab6c1b2bfd498a1df4cb28158d", size = 91120, upload-time = "2026-04-24T19:23:07.877Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/78/fc060d2e3b13c6ec59288574b8efac64075e316b2afba4396a56b2422f78/simplejson-4.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:67341c95c0a168ab4a6d1e807e50463f1c8da932c3286d81e201266c427061fa", size = 91055, upload-time = "2026-04-24T19:23:09.264Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/b6/156a8de1e1b47694f0e7de6675866936608d45dc68388fd017d36f8693be/simplejson-4.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:45ec18e337fec538b7e902d489505c450b2454653d1290f3f50385e6fd8aa607", size = 190297, upload-time = "2026-04-24T19:23:11.226Z" },
+ { url = "https://files.pythonhosted.org/packages/86/1c/e4d0eab695be3eb21d0f46bce820752031f03e7113f9c80a9b3c73ee7157/simplejson-4.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:820c69a4710400e9b248d5670647d60be58824369282d3925e516b3ff1a7cd82", size = 187002, upload-time = "2026-04-24T19:23:12.982Z" },
+ { url = "https://files.pythonhosted.org/packages/76/0e/7f5a59d29426b062d5928fb88b403c3f797129d53be7102f955dbe51aa44/simplejson-4.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e708d373a10e4378ef2d59f8361850c7150fd907ed49efe49bc5492160476d1", size = 195146, upload-time = "2026-04-24T19:23:14.517Z" },
+ { url = "https://files.pythonhosted.org/packages/78/18/9943db224dd4d5fa3c090c3e56a94c37b254338c83995ec5680285111c40/simplejson-4.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:980fc33353f81fd12d8c49d44f8c2760d1dc8192285e627c5180d141035b228a", size = 183931, upload-time = "2026-04-24T19:23:16.742Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/08/9a690da9a766161c06c627d805362cf159f1abe480969372b2897649b955/simplejson-4.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:de2ed102fff88dacf543699f53ee3a533cc11539a39baa176b7e09dd783069d6", size = 192228, upload-time = "2026-04-24T19:23:18.33Z" },
+ { url = "https://files.pythonhosted.org/packages/05/88/bd8aad36b451ffb0e0a3f721d695a88befa6d1ac7d1e02ae788ca7ff4029/simplejson-4.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2785ff8edc0e28bf773a32543a6bbed46351453c997b3f6709c744e3c2f7eabb", size = 187808, upload-time = "2026-04-24T19:23:21.165Z" },
+ { url = "https://files.pythonhosted.org/packages/04/ee/14f91db0d1f481533b651dafbf8cd0da088d9817f7af30c68f7f19f9c847/simplejson-4.1.1-cp312-cp312-win32.whl", hash = "sha256:2e0d5ead6d14610467ec356ec1f6b5d8a56aa216abaad8d41c8b873b16cf313f", size = 88512, upload-time = "2026-04-24T19:23:22.764Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/c4/90de06b2d8737c68c05ff9274113f854dbf6a5f28b7a955212111672cb57/simplejson-4.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:63a5451f557d6be48a231bae932458655c620902b868170b2f1c8afed496f6b4", size = 90748, upload-time = "2026-04-24T19:23:24.494Z" },
+ { url = "https://files.pythonhosted.org/packages/37/a9/47b445eeb559c9593453a0648e0fd6d08e8adff64dd5e5ced66726da8a09/simplejson-4.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dff52fc7af272e84fc21cc5a06c927c823ca6ae00af14f3b0d7707b42775ed98", size = 113160, upload-time = "2026-04-24T19:23:26.033Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/65/cb72db31523c164dea5dc55b02dad065a40c478856bc7534b279d2b51906/simplejson-4.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:971aed0647ad6e840a3943bec812fcda5f2d26a5497a4981d1fb49aa4f9a396c", size = 91521, upload-time = "2026-04-24T19:23:27.572Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/e5/54cb7c50ad5fdc1e0a86b7df4b135c2cbd5c4623605aa94466659098e8da/simplejson-4.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:249e2e220aa6d9b9d936bde84eb7bf79d5b6c5a8273c6e411f8b1635a9073f2d", size = 91407, upload-time = "2026-04-24T19:23:28.991Z" },
+ { url = "https://files.pythonhosted.org/packages/38/2e/21a3ede87f0bf82d6c7bcb90480d50a6490eb974c6ab20881188e440957c/simplejson-4.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e5cdd6a5d52299f345c15ab5678cc4249e24f383f361d986afbc3c7072a6b6b", size = 192451, upload-time = "2026-04-24T19:23:30.56Z" },
+ { url = "https://files.pythonhosted.org/packages/59/df/9903edd3102bf0b5984edfcb90c88612330996efa3b4fbf8a971d6e17839/simplejson-4.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642cec364e0676e2d5a73fa4d31d0c7c55886997caa2fde24e8292ca44d32728", size = 189015, upload-time = "2026-04-24T19:23:32.647Z" },
+ { url = "https://files.pythonhosted.org/packages/98/cd/33230927a780e1398b857e3944abb914556994d252b1d765ae40d112cb25/simplejson-4.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:76fe296ca1df23d290033f10aaacf534fd1b3e3007e7f9ff8aa68b21413aaa78", size = 196658, upload-time = "2026-04-24T19:23:34.563Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/84/2c5a7444eb53e9a86d3738299bffddd9f53aeed799ded2f45368221fdb19/simplejson-4.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f0ad25b7dc4e0fb23858355819f2e994f1a5badcdcde8737eac7921c2f1ed2a", size = 185967, upload-time = "2026-04-24T19:23:36.191Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/68/454378e06d059cd412a7ed5d87fb6d29fd5b60f13a4d89fc1f764ff434df/simplejson-4.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a59ebd0533f03fd06ff0c42ba0f02d93cbcdd7944922bf3b93911327a95b901f", size = 193940, upload-time = "2026-04-24T19:23:38.151Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/d5/a15bf915f623a2c5a079d6e3be8256fdb8ef06f110669493a09b9d6933e0/simplejson-4.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bccbf4419676b517939852e5aeff2af6aee4dc046881c67a1581fa6f1cb01abd", size = 189795, upload-time = "2026-04-24T19:23:40.139Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/c9/37212ae7dc4b607f0978c408e8633f05c810884e054c33113184c6c2c8a2/simplejson-4.1.1-cp313-cp313-win32.whl", hash = "sha256:6c845363eb5fd166fb7c72243da38f4fcfde666ede7fdf2cc6fd7762894626f7", size = 88773, upload-time = "2026-04-24T19:23:41.754Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/a5/c7a0a47883a9015b54c9d8a4b62f2aba17bd4335b1787b9b8a0fc2fa6d52/simplejson-4.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:104d8324c34f25b4b90800bc5fa363780cbc3d8496aef061cba7ce1af9162270", size = 90888, upload-time = "2026-04-24T19:23:43.11Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/6a/8b74c52ffd33dbbde00fe7251fee6a0acdc8cea33f7a43805aed258fb79b/simplejson-4.1.1-py3-none-any.whl", hash = "sha256:2ce92b3748f02423e26d2bfb636fb9d7a8f67c8f5854dcae69d350d123b2eee2", size = 69195, upload-time = "2026-04-24T19:24:57.962Z" },
+]
+
[[package]]
name = "six"
version = "1.17.0"