Merge remote-tracking branch 'origin/PROWLER-1775-dynamic-credential-validation' into PROWLER-1777-dynamic-compliance-framework-discovery

This commit is contained in:
StylusFrost
2026-06-01 22:17:36 +02:00
104 changed files with 952 additions and 363 deletions
+1 -1
View File
@@ -145,7 +145,7 @@ SENTRY_RELEASE=local
NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT}
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.29.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.30.0
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
+1 -1
View File
@@ -68,7 +68,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.30.0"
version = "1.31.0"
[tool.uv]
# Transitive pins matching master to avoid silent drift; bump deliberately.
+1 -13
View File
@@ -1,5 +1,4 @@
from datetime import date, datetime, timedelta, timezone
from functools import lru_cache
from dateutil.parser import parse
from django.conf import settings
@@ -67,19 +66,8 @@ from api.uuid_utils import (
uuid7_range,
uuid7_start,
)
from api.provider_types import get_provider_type_choices
from api.v1.serializers import TaskBase
from prowler.providers.common.provider import Provider as SDKProvider
@lru_cache(maxsize=1)
def get_provider_type_choices():
"""Provider-type filter choices driven by the SDK's available providers
instead of a static enum, so filtering covers external providers too.
Cached because the installed providers are fixed for the process lifetime
and provider-type filters live on hot list endpoints.
"""
return [(name, name) for name in SDKProvider.get_available_providers()]
class CustomDjangoFilterBackend(DjangoFilterBackend):
@@ -1,31 +1,25 @@
from django.db import migrations
from tasks.tasks import backfill_provider_str_task
from api.db_router import MainRouter
def trigger_provider_str_backfill(apps, _schema_editor):
"""Dispatch a per-tenant Celery task to populate the transitional
`provider_str` shadow column for rows created before the sync trigger
from 0094 existed.
New writes are already covered by the trigger, so this only fills the gap
left by pre-existing rows. The work runs in the background, batched per
tenant, so the migration itself finishes in seconds regardless of table
size.
"""
Tenant = apps.get_model("api", "Tenant")
tenant_ids = Tenant.objects.using(MainRouter.admin_db).values_list("id", flat=True)
for tenant_id in tenant_ids:
backfill_provider_str_task.delay(tenant_id=str(tenant_id))
class Migration(migrations.Migration):
"""Synchronous backfill of the `provider_str` shadow column.
A single UPDATE fills rows that predate the 0094 trigger. The providers
table is small, so this is safe inline and guarantees the column is fully
populated before 0096 sets it NOT NULL (no race with an async backfill).
Runs on the migration connection, which is exempt from RLS.
"""
dependencies = [
("api", "0094_provider_str_shadow_column"),
]
operations = [
migrations.RunPython(trigger_provider_str_backfill, migrations.RunPython.noop),
migrations.RunSQL(
sql=(
"UPDATE providers SET provider_str = provider::text "
"WHERE provider_str IS NULL;"
),
reverse_sql=migrations.RunSQL.noop,
),
]
@@ -2,20 +2,14 @@ from django.db import migrations, models
class Migration(migrations.Migration):
"""Contract step of the zero-downtime migration of Provider.provider from a
native PostgreSQL enum to varchar.
"""Contract step: promote `provider_str` into `provider`.
The shadow column added in 0094 has been kept in sync by the trigger and
backfilled in 0095, so it now holds the value for every row. This migration
promotes it into place: drop the trigger and the enum column, rename the
shadow column to `provider`, and drop the orphaned enum type. The column
name is preserved throughout, and varchar accepts the same string values
the enum held, so app instances running the previous release keep working
against the swapped column.
The drop/rename runs in this migration's transaction so `provider` never
disappears for readers. The partial unique index is dropped here and
rebuilt concurrently in the next migration to avoid a long write lock.
Drops the trigger and enum column, renames the shadow column, sets it NOT
NULL, and drops the enum type. The unique index is dropped and recreated in
the same transaction, so there is no window for duplicate active providers;
recreated non-concurrently since the table is small, with a short
lock_timeout so the migration fails fast instead of queueing behind a
long-running transaction.
"""
dependencies = [
@@ -38,6 +32,7 @@ class Migration(migrations.Migration):
database_operations=[
migrations.RunSQL(
sql=(
"SET LOCAL lock_timeout = '10s';\n"
"DROP TRIGGER IF EXISTS providers_sync_provider_str ON providers;\n"
"DROP FUNCTION IF EXISTS sync_provider_str();\n"
"DROP INDEX IF EXISTS unique_provider_uids;\n"
@@ -45,7 +40,9 @@ class Migration(migrations.Migration):
"ALTER TABLE providers RENAME COLUMN provider_str TO provider;\n"
"ALTER TABLE providers ALTER COLUMN provider SET DEFAULT 'aws';\n"
"ALTER TABLE providers ALTER COLUMN provider SET NOT NULL;\n"
"DROP TYPE provider;"
"DROP TYPE provider;\n"
"CREATE UNIQUE INDEX unique_provider_uids ON providers "
"(tenant_id, provider, uid) WHERE NOT is_deleted;"
),
reverse_sql=migrations.RunSQL.noop,
),
@@ -1,28 +0,0 @@
from django.db import migrations
class Migration(migrations.Migration):
"""Rebuild the partial unique index on `providers` after the enum-to-varchar
contract in 0096 dropped it along with the old enum column.
Built with CREATE INDEX CONCURRENTLY (hence `atomic = False`) so the rebuild
holds no long write lock on a large table. The index keeps the name and
predicate Django expects for the existing `unique_provider_uids` constraint,
which stays in the model state untouched, so no state operation is needed.
"""
atomic = False
dependencies = [
("api", "0096_provider_enum_to_varchar_contract"),
]
operations = [
migrations.RunSQL(
sql=(
"CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS unique_provider_uids "
"ON providers (tenant_id, provider, uid) WHERE NOT is_deleted;"
),
reverse_sql="DROP INDEX CONCURRENTLY IF EXISTS unique_provider_uids;",
),
]
+15
View File
@@ -0,0 +1,15 @@
from functools import lru_cache
from prowler.providers.common.provider import Provider as SDKProvider
@lru_cache(maxsize=1)
def get_provider_type_choices():
"""Provider-type choices from the SDK's available providers, so they cover
external providers and not just a static enum.
Cached for the process lifetime; hot-installing a provider needs
coordinated cache invalidation (tracked separately) to show up here without
a restart. Shared by the filters and the provider serializer.
"""
return [(name, name) for name in SDKProvider.get_available_providers()]
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.30.0
version: 1.31.0
description: |-
Prowler API specification.
+48 -21
View File
@@ -13,45 +13,72 @@ from api.v1.serializers import (
class TestExternalProviderSecretValidation:
"""A non-built-in provider's secret is validated against the credential
schema it declares through the SDK contract, or accepted as-is when it
declares none (then validated by the provider's test_connection)."""
"""A non-built-in provider's secret is validated against the schema it
declares for the chosen secret type through the SDK contract, or accepted as
an object when it declares none (then validated by test_connection)."""
class _Credentials(BaseModel):
class _StaticCredentials(BaseModel):
api_url: str
api_key: str
def test_secret_validated_against_declared_schema(self):
class _RoleCredentials(BaseModel):
role_arn: str
def _patch(self, schemas):
provider_class = MagicMock()
provider_class.get_credentials_schema.return_value = [self._Credentials]
with patch(
provider_class.get_credentials_schema.return_value = schemas
return patch(
"api.v1.serializers.SDKProvider.get_class", return_value=provider_class
):
)
def test_secret_validated_against_its_type_schema(self):
with self._patch({"static": self._StaticCredentials}):
BaseWriteProviderSecretSerializer._validate_external_provider_secret(
"external-template", {"api_url": "u", "api_key": "k"}
"external-template", "static", {"api_url": "u", "api_key": "k"}
)
def test_secret_rejected_when_schema_violated(self):
provider_class = MagicMock()
provider_class.get_credentials_schema.return_value = [self._Credentials]
with patch(
"api.v1.serializers.SDKProvider.get_class", return_value=provider_class
):
with self._patch({"static": self._StaticCredentials}):
with pytest.raises(ValidationError):
BaseWriteProviderSecretSerializer._validate_external_provider_secret(
"external-template", {"api_url": "u"}
"external-template", "static", {"api_url": "u"}
)
def test_secret_must_match_its_type_not_another(self):
"""A secret is validated against the schema for its declared secret_type,
not "any declared schema": a role-shaped secret under secret_type=static
is rejected. See PR #11402 review (josema-xyz / Alan-TheGentleman)."""
schemas = {"static": self._StaticCredentials, "role": self._RoleCredentials}
with self._patch(schemas):
with pytest.raises(ValidationError):
BaseWriteProviderSecretSerializer._validate_external_provider_secret(
"external-template", "static", {"role_arn": "arn:aws:iam::x"}
)
def test_rejects_secret_type_not_declared_by_provider(self):
with self._patch({"static": self._StaticCredentials}):
with pytest.raises(ValidationError):
BaseWriteProviderSecretSerializer._validate_external_provider_secret(
"external-template", "role", {"role_arn": "arn"}
)
def test_secret_accepted_when_no_schema_declared(self):
provider_class = MagicMock()
provider_class.get_credentials_schema.return_value = []
with patch(
"api.v1.serializers.SDKProvider.get_class", return_value=provider_class
):
with self._patch({}):
BaseWriteProviderSecretSerializer._validate_external_provider_secret(
"external-template", {"anything": "goes"}
"external-template", "static", {"anything": "goes"}
)
@pytest.mark.parametrize("bad_secret", [["a", "b"], "a-string", None, 42])
def test_secret_rejected_when_not_a_json_object(self, bad_secret):
"""Even with no declared schema, a non-object secret must be rejected so
a list/string/null cannot be persisted and blow up later at
``{**secret}``. See PR #11402 review (Alan-TheGentleman)."""
with self._patch({}):
with pytest.raises(ValidationError):
BaseWriteProviderSecretSerializer._validate_external_provider_secret(
"external-template", "static", bad_secret
)
class TestProviderEnumSerializerField:
"""The provider field accepts whatever the SDK exposes (built-in or
+39 -30
View File
@@ -72,6 +72,7 @@ from api.v1.serializer_utils.lighthouse import (
OpenAICredentialsSerializer,
)
from api.v1.serializer_utils.processors import ProcessorConfigField
from api.provider_types import get_provider_type_choices
from api.v1.serializer_utils.providers import ProviderSecretField
from prowler.lib.mutelist.mutelist import Mutelist
from prowler.providers.common.provider import Provider as SDKProvider
@@ -856,12 +857,9 @@ class ProviderGroupMembershipSerializer(RLSSerializer, BaseWriteSerializer):
# Providers
class ProviderEnumSerializerField(serializers.ChoiceField):
def __init__(self, **kwargs):
# The SDK is the source of truth for which providers exist, so the
# accepted values track the installed providers (built-in or external)
# instead of a static enum.
kwargs["choices"] = [
(name, name) for name in SDKProvider.get_available_providers()
]
# Accepted values track the SDK's installed providers (built-in or
# external), shared with the filters via one cached source.
kwargs["choices"] = get_provider_type_choices()
super().__init__(**kwargs)
@@ -1546,34 +1544,45 @@ class FindingMetadataSerializer(BaseSerializerV1):
# Provider secrets
class BaseWriteProviderSecretSerializer(BaseWriteSerializer):
@staticmethod
def _validate_external_provider_secret(provider_type: str, secret: dict):
"""Validate a non-built-in provider's secret against the credential
schemas it declares through the SDK contract (one model per secret type;
the secret must match one).
def _validate_external_provider_secret(
provider_type: str, secret_type: str, secret: dict
):
"""Validate a non-built-in provider's secret against the schema it
declares for the given secret type through the SDK contract.
Providers that declare no schema have their secret accepted as-is; the
credentials are then validated by the provider's ``test_connection``.
The provider maps each secret type to one model, so the chosen
secret_type stays bound to the shape it claims. Providers that declare
no schema have their secret accepted as an object and validated by the
provider's ``test_connection``.
"""
if not isinstance(secret, dict):
raise serializers.ValidationError({"secret": ["Must be a JSON object."]})
schemas = SDKProvider.get_class(provider_type).get_credentials_schema()
if not schemas:
return
collected_errors = []
for schema in schemas:
try:
schema.model_validate(secret)
return
except PydanticValidationError as error:
collected_errors.append(error)
raise serializers.ValidationError(
{
"secret": [
f"{'/'.join(str(loc) for loc in item['loc']) or 'secret'}: "
f"{item['msg']}"
for error in collected_errors
for item in error.errors()
]
}
)
schema = schemas.get(secret_type)
if schema is None:
raise serializers.ValidationError(
{
"secret_type": [
f"'{secret_type}' is not supported by provider "
f"'{provider_type}'. Supported types: "
f"{', '.join(sorted(schemas))}."
]
}
)
try:
schema.model_validate(secret)
except PydanticValidationError as error:
raise serializers.ValidationError(
{
"secret": [
f"{'/'.join(str(loc) for loc in item['loc']) or 'secret'}: "
f"{item['msg']}"
for item in error.errors()
]
}
)
@staticmethod
def validate_secret_based_on_provider(
@@ -1583,7 +1592,7 @@ class BaseWriteProviderSecretSerializer(BaseWriteSerializer):
# SDK contract; built-in providers keep their explicit serializers below.
if not SDKProvider.is_builtin(provider_type):
BaseWriteProviderSecretSerializer._validate_external_provider_secret(
provider_type, secret
provider_type, secret_type, secret
)
return
-29
View File
@@ -40,35 +40,6 @@ from api.models import (
logger = get_task_logger(__name__)
def backfill_provider_str(tenant_id: str, batch_size: int = 1000):
"""Populate the transitional `provider_str` shadow column for rows that
predate the sync trigger, copying `provider::text` in bounded batches.
Each batch runs in its own RLS transaction so the lock is held only for the
rows in that batch, keeping the operation safe on a large table. Idempotent:
only rows where `provider_str IS NULL` are touched, so an interrupted run
resumes cleanly and a completed column is a no-op on retry.
"""
total_updated = 0
while True:
with rls_transaction(tenant_id) as cursor:
cursor.execute(
"UPDATE providers SET provider_str = provider::text "
"WHERE id IN ("
" SELECT id FROM providers WHERE provider_str IS NULL LIMIT %s"
")",
[batch_size],
)
updated = cursor.rowcount
total_updated += updated
if updated < batch_size:
break
logger.info(
"Backfilled provider_str for tenant %s: %d rows", tenant_id, total_updated
)
return {"tenant_id": tenant_id, "updated": total_updated}
def backfill_resource_scan_summaries(tenant_id: str, scan_id: str):
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
if ResourceScanSummary.objects.filter(
-7
View File
@@ -19,7 +19,6 @@ from tasks.jobs.backfill import (
backfill_daily_severity_summaries,
backfill_finding_group_summaries,
backfill_provider_compliance_scores,
backfill_provider_str,
backfill_resource_scan_summaries,
aggregate_scan_category_summaries,
aggregate_scan_resource_group_summaries,
@@ -723,12 +722,6 @@ def backfill_finding_group_summaries_task(tenant_id: str, days: int = None):
return backfill_finding_group_summaries(tenant_id=tenant_id, days=days)
@shared_task(name="backfill-provider-str", queue="backfill")
def backfill_provider_str_task(tenant_id: str):
"""Backfill the transitional provider_str shadow column for a tenant."""
return backfill_provider_str(tenant_id=tenant_id)
@shared_task(name="scan-category-summaries", queue="overview")
@handle_provider_deletion
def aggregate_scan_category_summaries_task(tenant_id: str, scan_id: str):
Generated
+1 -1
View File
@@ -4494,7 +4494,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.30.0"
version = "1.31.0"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
@@ -118,8 +118,8 @@ To update the environment file:
Edit the `.env` file and change version values:
```env
PROWLER_UI_VERSION="5.28.0"
PROWLER_API_VERSION="5.28.0"
PROWLER_UI_VERSION="5.29.0"
PROWLER_API_VERSION="5.29.0"
```
<Note>
@@ -40,12 +40,6 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i
pip install prowler
prowler -v
```
To upgrade Prowler to the latest version:
``` bash
pip install --upgrade prowler
```
</Tab>
<Tab title="Docker">
_Requirements_:
@@ -170,6 +164,68 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i
</Tab>
</Tabs>
## Updating Prowler CLI
Upgrade Prowler CLI to the latest release using the same method chosen for installation:
<Tabs>
<Tab title="pipx">
```bash
pipx upgrade prowler
prowler -v
```
</Tab>
<Tab title="pip">
```bash
pip install --upgrade prowler
prowler -v
```
</Tab>
<Tab title="Docker">
Pull the desired image tag to fetch the latest version:
```bash
docker pull toniblyx/prowler:latest
```
<Note>
Replace `latest` with a specific release tag (for example, `stable` or `<x.y.z>`) to pin a version. Refer to the [Container Versions](#container-versions) section for the full list of available tags.
</Note>
</Tab>
<Tab title="GitHub">
Pull the latest changes and sync the environment:
```bash
cd prowler
git pull
uv sync
uv run python prowler-cli.py -v
```
<Note>
To upgrade to a specific release, check out the corresponding tag before syncing: `git checkout <x.y.z>`.
</Note>
</Tab>
<Tab title="Brew">
```bash
brew upgrade prowler
prowler -v
```
</Tab>
<Tab title="CloudShell">
Both AWS CloudShell and Azure CloudShell install Prowler with `pipx`, so the upgrade command is the same:
```bash
pipx upgrade prowler
prowler -v
```
</Tab>
</Tabs>
<Note>
To install a specific version instead of the latest release, pin it explicitly. For example, with `pipx`: `pipx install prowler==<x.y.z>`, or with `pip`: `pip install prowler==<x.y.z>`. The available releases are listed in the [Releases GitHub section](https://github.com/prowler-cloud/prowler/releases).
</Note>
## Container Versions
The available versions of Prowler CLI are the following:
@@ -141,6 +141,45 @@ Choose one of the following installation methods:
---
## Updating Prowler MCP Server
When running Prowler MCP Server locally ("Option 2: Run Locally"), upgrade to the latest version using the same method chosen for installation. The hosted server (`https://mcp.prowler.com/mcp`) is always kept up to date by Prowler and requires no action.
<Tabs>
<Tab title="Docker">
Pull the latest image and restart the container:
```bash
docker pull prowlercloud/prowler-mcp
```
<Note>
Recreate any running container after pulling the new image so the updated version takes effect.
</Note>
</Tab>
<Tab title="From Source">
Pull the latest changes and sync the dependencies:
```bash
cd prowler/mcp_server
git pull
uv sync
uv run prowler-mcp --help
```
</Tab>
<Tab title="Build Docker Image">
Pull the latest source and rebuild the image:
```bash
cd prowler/mcp_server
git pull
docker build -t prowler-mcp .
```
</Tab>
</Tabs>
---
## Command Line Options
The Prowler MCP Server supports the following command-line arguments:
+1
View File
@@ -32,6 +32,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
### 🐞 Fixed
- Broken documentation URLs in Google Workspace check metadata [(#11405)](https://github.com/prowler-cloud/prowler/pull/11405)
- ENS RD 311/2022 (AWS) compliance mapping: `vpc_different_regions` was uncorrectly mapped under the `mp.com.4` family (Network segregation). That check is now mapped to a new `op.cont.2.aws.vpc.1` requirement under the Continuity of Service control [(#11372)](https://github.com/prowler-cloud/prowler/pull/11372)
- Compliance CSV row count now matches the UI per requirement by sourcing rows from the framework JSON's `requirement.Checks` instead of the stale `finding.compliance` snapshot [(#11370)](https://github.com/prowler-cloud/prowler/pull/11370)
- OpenStack provider exception codes moved from the `10000-10999` range, shared with the AlibabaCloud provider, to the free `17000-17999` range to keep error codes unambiguous [(#11382)](https://github.com/prowler-cloud/prowler/pull/11382)
+1 -1
View File
@@ -49,7 +49,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
prowler_version = "5.29.0"
prowler_version = "5.30.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"
+3 -1
View File
@@ -438,7 +438,9 @@ class Compliance(BaseModel):
# Built-in compliance from prowler/compliance/{provider}/
available_compliance_framework_modules = list_compliance_modules()
for compliance_framework in available_compliance_framework_modules:
if provider in compliance_framework.name:
# Match the provider segment exactly, not as a substring, so
# e.g. `cloud` does not capture `cloudflare`.
if compliance_framework.name.split(".")[-1] == provider:
compliance_specification_dir_path = (
f"{compliance_framework.module_finder.path}/{provider}"
)
+5
View File
@@ -13,6 +13,7 @@ and `prowler.providers.common.provider` without forming an import cycle.
import importlib.metadata
from prowler.lib.check.external_tool_providers import EXTERNAL_TOOL_PROVIDERS
from prowler.providers.common.builtin import is_builtin_provider
# Module-level cache for entry-point classes consulted by this helper.
# Independent of `Provider._ep_providers` to keep this module leaf — the cost
@@ -53,5 +54,9 @@ def is_tool_wrapper_provider(provider: str) -> bool:
"""
if provider in EXTERNAL_TOOL_PROVIDERS:
return True
# Built-in wins: short-circuit before ep.load() so a same-name plug-in
# cannot flip a built-in onto the tool-wrapper path or run its code.
if is_builtin_provider(provider):
return False
cls = _load_ep_class(provider)
return bool(cls and getattr(cls, "is_external_tool_provider", False))
+21 -26
View File
@@ -244,22 +244,21 @@ class Provider(ABC):
return {**secret}
@classmethod
def get_credentials_schema(cls) -> list:
"""Return the credential schemas this provider accepts — one pydantic
model per secret type.
def get_credentials_schema(cls) -> dict:
"""Return the provider's credential schemas keyed by secret type.
Each model documents, in a single declaration the API can consume for
both validation and OpenAPI generation:
* the secret type itself, via the model docstring (schema description);
* each field, via ``Field(description=...)``;
* whether each field is required (no default) or optional
(``Optional[...] = None`` / ``Field(default=...)``).
Maps each secret type the provider accepts (``"static"``, ``"role"`` or
``"service_account"``) to the pydantic model that validates a secret of
that type. The provider declares which type each schema belongs to, so
the API validates a secret against the model for the secret type it is
created with and the chosen type stays bound to the shape it claims.
The API validates a stored secret against these models (it must match
one). An empty list means no schema is declared: the credentials are
accepted as-is and validated by :meth:`test_connection`.
Each model documents each field via ``Field(description=...)`` and
whether it is required (no default) or optional. An empty dict means no
schema is declared: the secret is accepted as an object and validated by
:meth:`test_connection`.
"""
return []
return {}
def display_compliance_table(
self,
@@ -339,9 +338,11 @@ class Provider(ABC):
# plug-in is ignored. This lives here (not in get_class) so
# that `prowler --help` and API callers that resolve a class
# without initialising a global provider do not see spurious
# warnings.
if Provider.is_builtin(arguments.provider) and (
Provider._load_ep_provider(arguments.provider) is not None
# warnings. Match by name only — never ep.load() a shadowing
# plug-in, or its module code would run during a built-in run.
if Provider.is_builtin(arguments.provider) and any(
ep.name == arguments.provider
for ep in importlib.metadata.entry_points(group="prowler.providers")
):
logger.warning(
f"Plug-in provider '{arguments.provider}' registered "
@@ -350,13 +351,6 @@ class Provider(ABC):
f"it under a different name."
)
# Kept for downstream forks that may extend the dispatch below
# with their own custom built-in branches and reference this name.
# The upstream chain dispatches by `arguments.provider` directly.
provider_class_name = ( # noqa: F841
f"{arguments.provider.capitalize()}Provider"
)
fixer_config = load_and_validate_config_file(
arguments.provider, arguments.fixer_config
)
@@ -737,9 +731,10 @@ class Provider(ABC):
def get_class(provider: str) -> type:
"""Resolve the provider class for a name (built-in or entry-point).
Side-effect-free: no ``sys.exit``, no global state. Collision warnings
are emitted by ``init_global_provider``, not here. The caller handles
errors (CLI exits; the API can return HTTP 400).
Does not call ``sys.exit`` and does not initialize the global
provider (it may populate the ``_ep_providers`` memoization cache).
Collision warnings are emitted by ``init_global_provider``, not here.
The caller handles errors (CLI exits; the API can return HTTP 400).
Args:
provider: Provider name, e.g. ``"aws"`` or an external plug-in.
@@ -13,8 +13,8 @@
"Risk": "When external Google Groups access is enabled, users can access and participate in groups created **outside the organization**, potentially exposing them to **phishing, social engineering, or data leakage** through unmanaged external group communications.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/181865",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/users/advanced/turn-on-or-off-additional-google-services",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,9 +13,8 @@
"Risk": "Without external invitation warnings, users may unintentionally include **external guests** in internal meetings, exposing **confidential meeting details**, agendas, and internal attendee lists to unauthorized parties. This is a common vector for inadvertent data leakage through everyday calendar actions.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/6329284",
"https://knowledge.workspace.google.com/admin/calendar/set-google-calendar-sharing-options",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/calendar/allow-external-invitations-in-google-calendar-events",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,9 +13,8 @@
"Risk": "Overly permissive external sharing of primary calendars exposes **sensitive meeting metadata** — titles, attendees, locations, and descriptions — to users outside the organization. This increases the risk of **information disclosure**, **social engineering**, and **targeted phishing** based on insights into organizational activities.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/60765",
"https://knowledge.workspace.google.com/admin/calendar/set-google-calendar-sharing-options",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,9 +13,8 @@
"Risk": "Overly permissive external sharing of secondary calendars exposes **project-specific or team-specific event details** to users outside the organization. Because secondary calendars often hold more targeted activities (e.g., product launches, internal reviews), unrestricted external sharing increases the risk of **information disclosure** and **competitive intelligence leakage**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/60765",
"https://knowledge.workspace.google.com/admin/calendar/set-google-calendar-sharing-options",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Unrestricted Chat app installation allows **unvetted third-party applications** to access user data including conversation content and organizational information. An attacker could distribute a malicious Chat app to **exfiltrate confidential data** or establish **persistent access** to internal communications.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/6089179",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/apps/manage-the-marketplace-app-allowlist-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Enabled external file sharing allows users to send files containing **confidential information** to external parties through Chat. This creates a **data leakage** channel that bypasses DLP controls, particularly dangerous for organizations handling **regulated data** such as PII, PHI, or financial records.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/9540647",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/chat/set-up-chat-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Unrestricted external messaging allows users to communicate freely with **any external party**, increasing the risk of **data exfiltration** through conversation content and **social engineering attacks** from untrusted domains targeting internal users.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/9540647",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/chat/set-up-chat-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Unrestricted external spaces allow users to add **anyone from any domain** to persistent group conversations. This increases the risk of **confidential information exposure** in shared spaces and enables **unauthorized external access** to ongoing organizational discussions.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/9540647",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/chat/set-up-chat-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Exposed webhook URLs allow **unauthorized content injection** into Chat spaces. Attackers can send **fraudulent or misleading messages** that appear to come from trusted services, creating a vector for **social engineering** and **phishing** within internal communications.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/6089179",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/apps/manage-the-marketplace-app-allowlist-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Unrestricted internal file sharing in Chat allows files with **sensitive information** to be distributed freely without passing through approved channels. This undermines **data governance** and **audit trail** requirements, making it harder to track data movement within the organization.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/9540647",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/chat/set-up-chat-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/users/prebuilt-administrator-roles",
"https://support.google.com/a/answer/9011373"
"https://knowledge.workspace.google.com/admin/users/security-best-practices-for-administrator-accounts"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/users/prebuilt-administrator-roles",
"https://support.google.com/a/answer/9011373"
"https://knowledge.workspace.google.com/admin/users/security-best-practices-for-administrator-accounts"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "If Access Checker suggests broader audiences or public visibility, users may **inadvertently widen access** to a file beyond the people they intended to share with. This is a common cause of unintentional internal or external over-sharing.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/60781",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "When Drive for desktop is enabled, organizational files are **synchronized to local devices** and remain accessible if the device is lost, stolen, or compromised. Because Drive for desktop bypasses the central offline-access controls, this channel is a frequently overlooked path for sensitive data to leave organization-managed environments.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7491144",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/set-up-drive-for-desktop-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without external sharing warnings, users may unintentionally share **sensitive documents** with external recipients who are not entitled to the data. This is a common vector for inadvertent leakage of intellectual property, personally identifiable information, and confidential business data through routine Drive sharing.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/60781",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "If external users can move files from internal shared drives into shared drives owned by another organization, the organization **loses authoritative control** over its own data. This is a frequently overlooked path for unintentional or malicious data exfiltration through shared drive collaboration.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/60781",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Allowing users to publish Drive files to the web creates a path for **unbounded data exposure**. Sensitive documents, intellectual property, customer data, or internal communications can be made publicly accessible — and indexed by search engines — with a single click, often unintentionally.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/60781",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "When users cannot create shared drives, they store collaborative content in their personal **My Drive** instead. When that user account is deleted, the data is also deleted, leading to **unintentional data loss** of organizationally significant information. Allowing shared drive creation makes data survivable across account lifecycle events.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7212025",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://support.google.com/a/users/answer/7212025",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "When viewers and commenters can download, print, or copy shared drive files, they can **bulk-extract sensitive content** — including intellectual property, personally identifiable information, and confidential business documents — using nothing more than read access. This is one of the most direct paths to data exfiltration through Drive.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7662202",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/manage-shared-drives-as-an-admin",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "If shared drive managers can override organizational defaults, **unauthorized data exposure** can occur when a manager intentionally or accidentally weakens a shared drive's security posture (for example, allowing external members or enabling download for viewers).",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7662202",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/manage-shared-drives-as-an-admin",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "If non-members can be added to files inside a shared drive, the **drive's membership becomes meaningless** as a security control. Sensitive content scoped to a specific team can be silently extended to users who were never granted access to the drive itself, leading to unintended information disclosure.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7662202",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/manage-shared-drives-as-an-admin",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "When external sharing is unrestricted, users can share organizational content with **any external Google account**, including untrusted or unknown parties. Restricting sharing to allowlisted domains drastically reduces the surface area for accidental and malicious data exfiltration through Drive.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/60781",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Allowlisted domains are still external. Users may not realize that even an allowlisted recipient is outside the organization, leading to **unintentional disclosure of sensitive content** to legitimate but external collaborators. A warning prompt at share time mitigates that without preventing the sharing itself.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/60781",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without protection against anomalous attachment types, users may receive **emails with unusual file formats** that are designed to bypass standard security filters. Attackers may use **uncommon file extensions or MIME types** to deliver malware that evades signature-based detection.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7676854",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/set-up-rules-to-detect-harmful-attachments",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "With auto-forwarding enabled, an attacker who gains control of a user account can create **forwarding rules to exfiltrate** all incoming email to an external address. This can persist undetected and provide the attacker with continuous access to sensitive communications even after the account is recovered.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/2491924",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/let-users-automatically-forward-their-own-gmail-emails",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without comprehensive mail storage, messages sent through other Google services (Calendar, Drive, etc.) may not be stored in Gmail and therefore **not subject to Vault retention policies**. This creates gaps in **compliance coverage**, **eDiscovery**, and **audit trails** that could violate regulatory requirements.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/3547347",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/set-up-comprehensive-mail-storage",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without protection against domain spoofing based on similar domain names, users may receive **phishing emails from lookalike domains** (e.g., examp1e.com instead of example.com) that appear legitimate. This enables **credential theft, malware delivery, and business email compromise** attacks.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/9157861",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without protection against employee name spoofing, users may receive **emails that appear to come from colleagues or executives** but are actually from external attackers. This enables **business email compromise (BEC)**, **wire fraud**, and **social engineering attacks** that exploit trust relationships.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/9157861",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without protection against encrypted attachments from untrusted senders, users may receive **password-protected archives containing malware** that bypass standard content scanning. Attackers commonly use encrypted attachments to evade detection and deliver **ransomware, trojans, or other malicious payloads**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7676854",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/set-up-rules-to-detect-harmful-attachments",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without enhanced pre-delivery scanning, some **sophisticated phishing and malware** messages may pass through standard filters and be delivered to users. The additional scanning layer catches threats that the first-pass filters miss, reducing the organization's exposure to **zero-day phishing campaigns** and **targeted attacks**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7380368",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/security/help-prevent-phishing-with-pre-delivery-message-scanning",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without external image scanning, attackers can use **linked images to track email opens**, deliver **exploit payloads via image rendering vulnerabilities**, or use images as part of sophisticated **phishing schemes** that mimic legitimate communications.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7676854",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without protection of groups from domain-spoofing emails, attackers can send **spoofed messages to group mailboxes** that appear to originate from the organization. Since groups distribute to many recipients, a single spoofed email can enable **mass phishing, social engineering, or misinformation** campaigns across the organization.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/9157861",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without protection against inbound domain spoofing, users may receive **emails that appear to come from their own organization** but are sent by external attackers. This enables **internal impersonation**, **phishing**, and **business email compromise** attacks that exploit trust in internal communications.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/9157861",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "If users can delegate access to their mailbox, an attacker who compromises one account could silently delegate access to maintain persistent email surveillance. This also increases the risk of **insider threats** and **data exfiltration** through shared mailbox access.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7223765",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/let-users-delegate-access-to-a-gmail-account",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "With per-user outbound gateways enabled, users can route outbound email through **external SMTP servers**, bypassing organizational **email security controls**, **DLP policies**, and **audit logging**. This creates an unmonitored channel for data exfiltration and policy circumvention.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/176652",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/allow-per-user-outbound-gateways",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "With POP and IMAP enabled, users can access email through **legacy clients** that rely on simple password authentication, bypassing **multifactor authentication** and other modern security controls. This significantly increases the risk of **credential-based account compromise**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/105694",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/sync/turn-pop-and-imap-on-or-off-for-users",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without protection against script-bearing attachments from untrusted senders, users may receive **files containing malicious scripts** that can execute harmful code when opened. Attackers commonly use script attachments to deliver **malware, backdoors, or credential stealers**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7676854",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/set-up-rules-to-detect-harmful-attachments",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without shortened URL scanning, attackers can use **URL shortening services** to hide malicious destinations in phishing emails. Users cannot visually verify where the link leads, increasing the success rate of **phishing and credential harvesting** attacks.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7676854",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without protection against unauthenticated emails, users may receive **spoofed or forged messages** that fail SPF and DKIM checks but are still delivered normally. This enables **phishing**, **spam**, and **impersonation attacks** that exploit the lack of sender verification.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/9157861",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without untrusted link warnings, users may click on **phishing links** or links to **malware distribution sites** without any warning. This significantly increases the success rate of **social engineering attacks** targeting the organization.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7676854",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Allowing any user to create groups with external members or incoming email from outside increases the risk of **unauthorized data sharing**, **spam delivery**, and **shadow IT** groups that bypass organizational controls.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/10308022",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/groups/what-you-get-with-groups-for-business",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Allowing external access to groups exposes **group names, descriptions, and membership** to anyone outside the organization, increasing the risk of **information disclosure** and enabling external parties to identify targets for **social engineering attacks**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/10308022",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/groups/what-you-get-with-groups-for-business",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Allowing all organization users or anyone to view group conversations can lead to **information disclosure** of sensitive discussions, internal decisions, and confidential data shared within groups.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/10308022",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/groups/what-you-get-with-groups-for-business",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Allowing unrestricted Marketplace app installation exposes the organization to **unvetted third-party applications** that may request broad OAuth scopes, potentially gaining access to **sensitive organizational data** including emails, documents, and calendar events without proper security review.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/6089179",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/apps/manage-the-marketplace-app-allowlist-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/security/protect-your-business-with-2-step-verification",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/security/protect-your-business-with-2-step-verification",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/security/protect-users-with-the-advanced-protection-program",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/apps/control-which-apps-access-google-workspace-data",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/security/about-dlp",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/apps/control-which-apps-access-google-workspace-data",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/apps/control-access-to-less-secure-apps",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/security/protect-google-workspace-accounts-with-security-challenges",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/users/enforce-and-monitor-password-requirements-for-users",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/security/set-session-length-for-google-services",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/users/allow-super-administrators-to-recover-their-password",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/users/set-up-password-recovery-for-users",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "When Google Sites is enabled, users can create websites that may **inadvertently expose internal information** to external parties. These sites can be difficult to track and manage, creating potential **data leakage vectors** outside the organization's standard content management controls.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/182442",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/users/advanced/turn-a-service-on-or-off-for-google-workspace-users",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
+1 -1
View File
@@ -123,7 +123,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
name = "prowler"
readme = "README.md"
requires-python = ">=3.10,<3.13"
version = "5.29.0"
version = "5.30.0"
[project.scripts]
prowler = "prowler.__main__:prowler"
+3 -1
View File
@@ -540,7 +540,9 @@ class TestCompliance:
):
object = mock.Mock()
object.path = "/path/to/compliance"
object.name = "framework1_aws"
# list_compliance_modules yields dotted module names; get_bulk matches
# the last segment exactly against the provider.
object.name = "prowler.compliance.aws"
mock_list_modules.return_value = [object]
mock_listdir.return_value = ["framework1_aws.json"]
+14
View File
@@ -70,6 +70,20 @@ class TestIsToolWrapperProvider:
assert is_tool_wrapper_provider("does-not-exist") is False
@patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points")
def test_builtin_name_shortcircuits_before_loading_same_name_plugin(self, mock_eps):
"""A plug-in registered under a built-in's name cannot flip the
built-in onto the tool-wrapper path, and its module is never loaded."""
from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider
malicious = _make_entry_point("aws", MagicMock(is_external_tool_provider=True))
mock_eps.return_value = [malicious]
# `aws` is a built-in, so classification short-circuits to False...
assert is_tool_wrapper_provider("aws") is False
# ...and the shadowing plug-in's code is never executed via ep.load().
malicious.load.assert_not_called()
class TestLoadEpClass:
"""_load_ep_class: cache, broken plug-ins, no-match."""
+81 -15
View File
@@ -663,22 +663,25 @@ class TestProviderInitialization:
@patch("prowler.providers.common.provider.logger")
@patch("prowler.providers.common.provider.load_and_validate_config_file")
@patch("prowler.providers.common.provider.Provider._load_ep_provider")
@patch("prowler.providers.common.provider.importlib.metadata.entry_points")
@patch("prowler.providers.common.provider.import_module")
@patch("prowler.providers.common.provider.Provider.is_builtin")
def test_init_global_provider_warns_when_plugin_shadowed_by_builtin(
self, mock_is_builtin, mock_import, mock_load_ep, mock_config, mock_logger
self, mock_is_builtin, mock_import, mock_entry_points, mock_config, mock_logger
):
"""Regression guard: when a plug-in registers a provider name that
collides with a built-in, the BUILT-IN wins and a warning is emitted
naming the shadowed plug-in. Matches the precedence enforced by
`_resolve_check_module` and `CheckMetadata.get_bulk` for checks. See
PR #10700 review (HugoPBrito).
naming the shadowed plug-in. Shadow detection matches by entry-point
name only the plug-in is never `ep.load()`-ed just to warn, so its
module code cannot run during a built-in run. See PR #10700 review
(HugoPBrito, Alan-TheGentleman).
"""
# Simulate a built-in `aws` that exists, AND a plug-in registered
# under the same `aws` name via entry points.
mock_is_builtin.return_value = True
mock_load_ep.return_value = FakeExternalProvider # plug-in shadow
shadow_ep = MagicMock()
shadow_ep.name = "aws" # plug-in shadowing the built-in name
mock_entry_points.return_value = [shadow_ep]
mock_import.return_value = MagicMock(
AwsProvider=MagicMock(side_effect=lambda **_kw: None)
)
@@ -723,6 +726,8 @@ class TestProviderInitialization:
]
assert warning_msgs, "expected a warning about the shadowed plug-in 'aws'"
assert "IGNORED" in warning_msgs[0]
# Shadow detected by name only — plug-in code never executed to warn
shadow_ep.load.assert_not_called()
# ===========================================================================
@@ -1354,6 +1359,63 @@ class TestCompliance:
assert "dup_framework" in bulk
@pytest.mark.parametrize(
"provider, framework_segments",
[
# `cloud` is a substring of THREE built-in modules at once.
("cloud", ["alibabacloud", "cloudflare", "oraclecloud"]),
("git", ["github"]),
("work", ["googleworkspace"]),
("open", ["openstack"]),
],
)
@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
@patch("prowler.lib.check.compliance_models.list_compliance_modules")
def test_compliance_get_bulk_matches_provider_segment_exactly(
self, mock_list_modules, mock_ep, provider, framework_segments
):
"""Regression: a provider whose name is a substring of one or more
framework modules must NOT load them. The old `provider in name`
check captured overlapping built-ins (e.g. `cloud` matched
alibabacloud, cloudflare and oraclecloud). See PR #10700 review
(Alan-TheGentleman).
"""
import json
import os
import tempfile
from prowler.lib.check.compliance_models import Compliance
mock_ep.return_value = []
with tempfile.TemporaryDirectory() as tmpdir:
# The substring path the old code would have read from.
os.mkdir(os.path.join(tmpdir, provider))
json_data = {
"Framework": "Custom",
"Name": f"Should not load for '{provider}'",
"Version": "1.0",
"Provider": provider,
"Description": "Test",
"Requirements": [],
}
with open(os.path.join(tmpdir, provider, "wrong.json"), "w") as f:
json.dump(json_data, f)
modules = []
for segment in framework_segments:
module = MagicMock()
module.name = f"prowler.compliance.{segment}"
module.module_finder.path = tmpdir
modules.append(module)
mock_list_modules.return_value = modules
bulk = Compliance.get_bulk(provider)
# Exact-segment match: the provider is not any of these modules.
assert "wrong" not in bulk
assert bulk == {}
# ===========================================================================
# 7. Parser
@@ -1969,12 +2031,11 @@ class TestGetClass:
mock_is_builtin.return_value = False
mock_ep.return_value = []
with pytest.raises((ImportError, Exception)) as exc_info:
Provider.get_class("totally_unknown_xyz_provider")
# Must NOT be a SystemExit — that belongs in init_global_provider's
# Assert ImportError specifically to enforce the public API contract
# (not a broad Exception). SystemExit belongs in init_global_provider's
# wrapper, not in the pure resolver.
assert not isinstance(exc_info.value, SystemExit)
with pytest.raises(ImportError):
Provider.get_class("totally_unknown_xyz_provider")
# -----------------------------------------------------------------------
# T4: get_class is PURE for built-ins — no collision warning, no EP call
@@ -2104,11 +2165,11 @@ class TestGetClass:
# -----------------------------------------------------------------------
@patch("prowler.providers.common.provider.load_and_validate_config_file")
@patch("prowler.providers.common.provider.Provider._load_ep_provider")
@patch("prowler.providers.common.provider.importlib.metadata.entry_points")
@patch("prowler.providers.common.provider.import_module")
@patch("prowler.providers.common.provider.Provider.is_builtin")
def test_init_global_provider_emits_collision_warning_for_builtin_ep_shadow(
self, mock_is_builtin, mock_import, mock_load_ep, mock_config, caplog
self, mock_is_builtin, mock_import, mock_entry_points, mock_config, caplog
):
"""init_global_provider (not get_class) emits the collision warning
when a built-in provider has a same-named entry-point plug-in registered.
@@ -2117,13 +2178,16 @@ class TestGetClass:
the warning responsibility moved OUT of get_class and INTO
init_global_provider, so users still see the message on CLI invocation
but prowler --help and API calls (which never hit init_global_provider)
do not spuriously emit it.
do not spuriously emit it. The shadow is detected by entry-point name
only the plug-in is never loaded to warn.
"""
import logging
import types
mock_is_builtin.return_value = True
mock_load_ep.return_value = FakeExternalProvider # plug-in shadow
shadow_ep = MagicMock()
shadow_ep.name = "aws" # plug-in shadowing the built-in name
mock_entry_points.return_value = [shadow_ep]
fake_module = types.ModuleType("fake_builtin_module")
fake_module.AwsProvider = MagicMock(side_effect=lambda **_kw: None)
@@ -2169,3 +2233,5 @@ class TestGetClass:
"init_global_provider must emit the collision warning when a "
"same-named EP plug-in exists for a built-in provider"
)
# Shadow detected by name only — the plug-in is never loaded to warn.
shadow_ep.load.assert_not_called()
+9
View File
@@ -34,4 +34,13 @@ describe("providers page", () => {
expect(source).toContain("size: 160");
expect(source).toContain("size: 140");
});
it("keeps the CLI import banner gated by the Cloud environment", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const pagePath = path.join(currentDir, "page.tsx");
const source = readFileSync(pagePath, "utf8");
expect(source).toContain("NEXT_PUBLIC_IS_CLOUD_ENV");
expect(source).toContain("{isCloudEnvironment && <CliImportBanner");
});
});
+3
View File
@@ -2,6 +2,7 @@ import { Suspense } from "react";
import { ProvidersAccountsView } from "@/components/providers";
import { SkeletonTableProviders } from "@/components/providers/table";
import { CliImportBanner } from "@/components/scans";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { ContentLayout } from "@/components/ui";
import { FilterTransitionWrapper } from "@/contexts";
@@ -19,6 +20,7 @@ export default async function Providers({
}) {
const resolvedSearchParams = await searchParams;
const activeTab = getProviderTab(resolvedSearchParams.tab);
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
// Exclude `tab` from the Suspense key so switching tabs doesn't re-suspend
const { tab: _, ...paramsWithoutTab } = resolvedSearchParams || {};
@@ -26,6 +28,7 @@ export default async function Providers({
return (
<ContentLayout title="Providers" icon="lucide:cloud-cog">
{isCloudEnvironment && <CliImportBanner className="mb-6" />}
<FilterTransitionWrapper>
<ProviderPageTabs
activeTab={activeTab}
@@ -3,6 +3,7 @@ import type { ComponentProps } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { SCAN_JOBS_TAB } from "@/types";
import { LaunchStep } from "./launch-step";
@@ -81,5 +82,9 @@ describe("LaunchStep", () => {
title: "Scan Launched",
}),
);
const toastPayload = toastMock.mock.calls[0]?.[0];
expect(toastPayload.action.props.children.props.href).toBe(
`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`,
);
});
});
@@ -15,6 +15,7 @@ import { Spinner } from "@/components/shadcn/spinner/spinner";
import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon";
import { ToastAction, useToast } from "@/components/ui";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { SCAN_JOBS_TAB } from "@/types";
import { TREE_ITEM_STATUS } from "@/types/tree";
import {
@@ -81,7 +82,7 @@ export function LaunchStep({
: "Single scan launched successfully.",
action: (
<ToastAction altText="Go to scans" asChild>
<Link href="/scans">Go to scans</Link>
<Link href={`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`}>Go to scans</Link>
</ToastAction>
),
});
@@ -0,0 +1,89 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DOCS_URLS } from "@/lib/external-urls";
import { CliImportBanner } from "./cli-import-banner";
const STORAGE_KEY = "prowler:cli-import-banner-dismissed";
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value;
}),
removeItem: vi.fn((key: string) => {
delete store[key];
}),
clear: vi.fn(() => {
store = {};
}),
get length() {
return Object.keys(store).length;
},
key: vi.fn((index: number) => Object.keys(store)[index] ?? null),
};
})();
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
writable: true,
});
describe("CliImportBanner", () => {
beforeEach(() => {
localStorageMock.clear();
vi.clearAllMocks();
});
it("renders the banner when not dismissed", () => {
render(<CliImportBanner />);
expect(
screen.getByText(/Import findings from Prowler CLI/),
).toBeInTheDocument();
});
it("renders a link to the documentation", () => {
render(<CliImportBanner />);
const link = screen.getByRole("link", { name: "Learn more" });
expect(link).toHaveAttribute("href", DOCS_URLS.FINDINGS_INGESTION);
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
it("does not render when previously dismissed", () => {
localStorageMock.setItem(STORAGE_KEY, "true");
const { container } = render(<CliImportBanner />);
expect(container).toBeEmptyDOMElement();
});
it("dismisses the banner and persists to localStorage on close", async () => {
const user = userEvent.setup();
render(<CliImportBanner />);
const closeButton = screen.getByRole("button", { name: "Close" });
await user.click(closeButton);
expect(
screen.queryByText(/Import findings from Prowler CLI/),
).not.toBeInTheDocument();
expect(localStorageMock.setItem).toHaveBeenCalledWith(STORAGE_KEY, "true");
});
it("renders with role='alert'", () => {
render(<CliImportBanner />);
expect(screen.getByRole("alert")).toBeInTheDocument();
});
});
+49
View File
@@ -0,0 +1,49 @@
"use client";
import { Upload } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { Alert, AlertTitle } from "@/components/shadcn";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { DOCS_URLS } from "@/lib/external-urls";
import { cn } from "@/lib/utils";
const STORAGE_KEY = "prowler:cli-import-banner-dismissed";
export const CliImportBanner = ({ className }: { className?: string }) => {
const [isVisible, setIsVisible] = useState<boolean | null>(null);
useMountEffect(() => {
const isDismissed = localStorage.getItem(STORAGE_KEY) === "true";
setIsVisible(!isDismissed);
});
const handleClose = () => {
localStorage.setItem(STORAGE_KEY, "true");
setIsVisible(false);
};
if (isVisible === null || !isVisible) return null;
return (
<Alert
variant="info"
onClose={handleClose}
className={cn("animate-fade-in", className)}
>
<Upload />
<AlertTitle>
Import findings from Prowler CLI {" "}
<Link
href={DOCS_URLS.FINDINGS_INGESTION}
target="_blank"
rel="noopener noreferrer"
className="font-normal underline underline-offset-2"
>
Learn more
</Link>
</AlertTitle>
</Alert>
);
};
+1
View File
@@ -1 +1,2 @@
export * from "./auto-refresh";
export * from "./cli-import-banner";
@@ -0,0 +1,66 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { SCAN_JOBS_TAB } from "@/types";
import { ScansFilterBar } from "./scans-filter-bar";
vi.mock("@/components/filters/provider-account-selectors", () => ({
ProviderAccountSelectors: () => <div>Provider account selectors</div>,
}));
vi.mock("@/components/shadcn", () => ({
Select: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectItem: ({
children,
value,
}: {
children: React.ReactNode;
value: string;
}) => <div data-value={value}>{children}</div>,
SelectTrigger: ({ children, ...props }: React.ComponentProps<"button">) => (
<button {...props}>{children}</button>
),
SelectValue: ({ placeholder }: { placeholder: string }) => (
<span>{placeholder}</span>
),
}));
const defaultProps = {
providers: [],
scheduleType: "all",
scanStatus: "all",
showStatusFilter: false,
onScheduleTypeChange: vi.fn(),
onScanStatusChange: vi.fn(),
};
describe("ScansFilterBar", () => {
it("hides the type filter on the scheduled tab", () => {
// Given
render(
<ScansFilterBar {...defaultProps} activeTab={SCAN_JOBS_TAB.SCHEDULED} />,
);
// Then
expect(
screen.queryByRole("button", { name: /all types/i }),
).not.toBeInTheDocument();
expect(screen.getByText("Provider account selectors")).toBeInTheDocument();
});
it("shows the type filter outside the scheduled tab", () => {
// Given
render(
<ScansFilterBar {...defaultProps} activeTab={SCAN_JOBS_TAB.COMPLETED} />,
);
// Then
expect(screen.getByRole("button", { name: /all types/i })).toBeVisible();
});
});
+16 -13
View File
@@ -8,7 +8,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/shadcn";
import type { ScanJobsTab } from "@/types";
import { SCAN_JOBS_TAB, type ScanJobsTab } from "@/types";
import type { ProviderProps } from "@/types/providers";
import {
@@ -40,6 +40,7 @@ export function ScansFilterBar({
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const triggerFilterOptions = getScanTriggerFilterOptions(isCloudEnvironment);
const statusFilterOptions = getScanStatusFilterOptions(activeTab);
const showScheduleTypeFilter = activeTab !== SCAN_JOBS_TAB.SCHEDULED;
return (
<>
@@ -52,18 +53,20 @@ export function ScansFilterBar({
accountSelectorClassName={filterItemClass}
/>
<Select value={scheduleType} onValueChange={onScheduleTypeChange}>
<SelectTrigger aria-label="All Types" className={filterItemClass}>
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
{triggerFilterOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{showScheduleTypeFilter && (
<Select value={scheduleType} onValueChange={onScheduleTypeChange}>
<SelectTrigger aria-label="All Types" className={filterItemClass}>
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
{triggerFilterOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{showStatusFilter && (
<Select value={scanStatus} onValueChange={onScanStatusChange}>
@@ -16,6 +16,32 @@ const { scansFilterBarSpy } = vi.hoisted(() => ({
scansFilterBarSpy: vi.fn(),
}));
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value;
}),
removeItem: vi.fn((key: string) => {
delete store[key];
}),
clear: vi.fn(() => {
store = {};
}),
get length() {
return Object.keys(store).length;
},
key: vi.fn((index: number) => Object.keys(store)[index] ?? null),
};
})();
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
writable: true,
});
vi.mock("next/navigation", () => ({
usePathname: () => "/scans",
useRouter: () => ({
@@ -120,6 +146,7 @@ describe("ScansPageShell", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
localStorageMock.clear();
searchParamsValue.current = "";
useScansStore.getState().closeLaunchScanModal();
});
@@ -191,6 +218,36 @@ describe("ScansPageShell", () => {
expect(screen.getByRole("combobox", { name: /all types/i })).toBeVisible();
});
it("shows the CLI import banner in Cloud", () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
expect(screen.getByRole("alert")).toHaveTextContent(
/import findings from prowler cli/i,
);
expect(screen.getByRole("link", { name: /learn more/i })).toHaveAttribute(
"href",
"https://docs.prowler.com/user-guide/tutorials/prowler-app-import-findings",
);
});
it("hides the CLI import banner outside Cloud", () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("keeps launch scan with filters and mutelist with tabs", () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
@@ -332,4 +389,22 @@ describe("ScansPageShell", () => {
expect(calledUrl).toContain("tab=active");
expect(calledUrl).not.toContain("filter%5Bstate__in%5D");
});
it("clears type filter when switching to scheduled scans", async () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
searchParamsValue.current = "tab=completed&filter%5Btrigger%5D=manual";
const user = userEvent.setup();
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
await user.click(screen.getByRole("tab", { name: /scheduled/i }));
const calledUrl = pushMock.mock.calls.at(-1)?.[0] as string;
expect(calledUrl).toContain("tab=scheduled");
expect(calledUrl).not.toContain("filter%5Btrigger%5D");
});
});
+4
View File
@@ -19,6 +19,7 @@ import { useScansStore } from "@/store";
import { SCAN_JOBS_TAB, SCAN_TAB_LABELS, type ScanJobsTab } from "@/types";
import type { ProviderProps } from "@/types/providers";
import { CliImportBanner } from "./cli-import-banner";
import { LaunchScanModal } from "./launch-scan-modal";
import { ScansFilterBar } from "./scans-filter-bar";
import { useScansFilters } from "./use-scans-filters";
@@ -53,6 +54,7 @@ export function ScansPageShell({
const hasConnectedProviders = providers.some(
(provider) => provider.attributes.connection.connected === true,
);
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const launchDisabled = !hasManageScansPermission || !hasConnectedProviders;
const launchOpen = isLaunchScanModalOpen || urlLaunchOpen;
@@ -104,6 +106,8 @@ export function ScansPageShell({
</Button>
</div>
{isCloudEnvironment && <CliImportBanner />}
<Tabs
value={filters.activeTab}
onValueChange={filters.setTab}
@@ -1,9 +1,22 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ScansProvidersEmptyState } from "./scans-providers-empty-state";
const { replaceMock, searchParamsValue } = vi.hoisted(() => ({
replaceMock: vi.fn(),
searchParamsValue: { current: "" },
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/scans",
useRouter: () => ({
replace: replaceMock,
}),
useSearchParams: () => new URLSearchParams(searchParamsValue.current),
}));
vi.mock("@/components/providers/wizard", () => ({
ProviderWizardModal: ({ open }: { open: boolean }) =>
open ? <div role="dialog">Provider wizard</div> : null,
@@ -14,6 +27,11 @@ vi.mock("./no-providers-connected", () => ({
}));
describe("ScansProvidersEmptyState", () => {
afterEach(() => {
vi.clearAllMocks();
searchParamsValue.current = "";
});
it("shows the add provider message and opens the provider wizard", async () => {
const user = userEvent.setup();
@@ -28,6 +46,25 @@ describe("ScansProvidersEmptyState", () => {
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
});
it("clears the launch scan URL intent before opening the provider wizard", async () => {
// Given
searchParamsValue.current = "tab=completed&launchScan=true";
const user = userEvent.setup();
render(<ScansProvidersEmptyState thereIsNoProviders />);
// When
await user.click(
screen.getByRole("button", { name: /open add provider modal/i }),
);
// Then
expect(replaceMock).toHaveBeenCalledWith("/scans?tab=completed", {
scroll: false,
});
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
});
it("shows the no connected providers message", () => {
render(<ScansProvidersEmptyState thereIsNoProviders={false} />);
@@ -1,8 +1,10 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { ProviderWizardModal } from "@/components/providers/wizard";
import { LAUNCH_SCAN_SEARCH_PARAM } from "@/lib/scans-navigation";
import { NoProvidersAdded } from "./no-providers-added";
import { NoProvidersConnected } from "./no-providers-connected";
@@ -14,12 +16,28 @@ interface ScansProvidersEmptyStateProps {
export function ScansProvidersEmptyState({
thereIsNoProviders,
}: ScansProvidersEmptyStateProps) {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false);
const openProviderWizard = () => {
if (searchParams.has(LAUNCH_SCAN_SEARCH_PARAM)) {
const params = new URLSearchParams(searchParams.toString());
params.delete(LAUNCH_SCAN_SEARCH_PARAM);
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname, {
scroll: false,
});
}
setIsProviderWizardOpen(true);
};
return (
<>
{thereIsNoProviders ? (
<NoProvidersAdded onOpenWizard={() => setIsProviderWizardOpen(true)} />
<NoProvidersAdded onOpenWizard={openProviderWizard} />
) : (
<NoProvidersConnected />
)}
+12
View File
@@ -94,6 +94,18 @@ describe("scans.utils", () => {
});
});
it("excludes trigger filters from scheduled scans", () => {
expect(
getScanJobsUserFilters({
tab: "scheduled",
"filter[trigger]": "manual",
"filter[provider_uid]": "123456789012",
}),
).toEqual({
"filter[provider_uid]": "123456789012",
});
});
it("formats scan labels and durations for table display", () => {
expect(getScanAlias(makeScan(""))).toBe("-");
expect(getScanAlias(makeScan("Daily scheduled scan", "scheduled"))).toBe(
+6
View File
@@ -77,8 +77,14 @@ function isSearchParamValue(value: unknown): value is string | string[] {
export function getScanJobsUserFilters(
searchParams: SearchParamsProps,
): Record<string, string | string[]> {
const tab = getScanJobsTab(searchParams.tab);
return Object.entries(searchParams).reduce<Record<string, string | string[]>>(
(filters, [key, value]) => {
if (tab === SCAN_JOBS_TAB.SCHEDULED && key === "filter[trigger]") {
return filters;
}
if (
key.startsWith("filter[") &&
!isScanStateFilterKey(key) &&
@@ -81,6 +81,17 @@ const makeCompletedScan = (): ScanProps => ({
},
});
const makeScheduledScan = (): ScanProps => ({
...makeCompletedScan(),
attributes: {
...makeCompletedScan().attributes,
trigger: "scheduled",
state: "scheduled",
scheduled_at: "2026-01-01T10:00:00Z",
next_scan_at: "2026-01-02T10:00:00Z",
},
});
const renderCell = (
columnId: string,
scan: ScanProps,
@@ -137,7 +148,6 @@ describe("getScanJobsColumns", () => {
"account",
"scanInfo",
"scanSchedule",
"nextScan",
"actions",
]);
});
@@ -163,4 +173,19 @@ describe("getScanJobsColumns", () => {
expect(screen.getByText("1 min 13 sec")).toBeInTheDocument();
});
it("labels the completed scan schedule column as Type", () => {
renderHeader(SCAN_JOBS_TAB.COMPLETED, "scanSchedule");
expect(screen.getByText("Type")).toBeInTheDocument();
expect(screen.queryByText("Schedule")).not.toBeInTheDocument();
});
it("keeps the scheduled column without repeating the scheduled label in each row", () => {
renderHeader(SCAN_JOBS_TAB.SCHEDULED, "scanSchedule");
renderCell("scanSchedule", makeScheduledScan(), SCAN_JOBS_TAB.SCHEDULED);
expect(screen.getByText("Schedule")).toBeInTheDocument();
expect(screen.queryByText("Scheduled")).not.toBeInTheDocument();
});
});
+17 -19
View File
@@ -39,13 +39,25 @@ const scanInfoColumn: ColumnDef<ScanProps> = {
cell: ({ row }) => <ScanInfoCell scan={row.original} />,
};
const scanScheduleColumn: ColumnDef<ScanProps> = {
const getScanScheduleColumn = (title: string): ColumnDef<ScanProps> => ({
id: "scanSchedule",
accessorFn: (row) => row.attributes.trigger,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Schedule" param="trigger" />
<DataTableColumnHeader column={column} title={title} param="trigger" />
),
cell: ({ row }) => <ScheduleCell scan={row.original} />,
});
const scheduledScanScheduleColumn: ColumnDef<ScanProps> = {
id: "scanSchedule",
accessorFn: (row) => row.attributes.scheduled_at,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Schedule" />
),
cell: ({ row }) => (
<DateWithTime dateTime={row.original.attributes.scheduled_at} showTime />
),
enableSorting: false,
};
const resourcesColumn: ColumnDef<ScanProps> = {
@@ -86,7 +98,7 @@ const activeColumns = (): ColumnDef<ScanProps>[] => [
cell: ({ row }) => <ProgressCell scan={row.original} />,
enableSorting: false,
},
scanScheduleColumn,
getScanScheduleColumn("Schedule"),
{
id: "launched",
header: ({ column }) => (
@@ -118,7 +130,7 @@ const completedColumns = (): ColumnDef<ScanProps>[] => [
cell: ({ row }) => <StatusBadge status={row.original.attributes.state} />,
enableSorting: false,
},
scanScheduleColumn,
getScanScheduleColumn("Type"),
{
id: "scanDate",
accessorFn: (row) => row.attributes.completed_at,
@@ -139,7 +151,7 @@ const completedColumns = (): ColumnDef<ScanProps>[] => [
const scheduledColumns = (): ColumnDef<ScanProps>[] => [
accountColumn,
scanInfoColumn,
scanScheduleColumn,
scheduledScanScheduleColumn,
/*
* TODO: Restore this column when the API exposes the last completed scan date for this schedule.
* {
@@ -153,20 +165,6 @@ const scheduledColumns = (): ColumnDef<ScanProps>[] => [
* enableSorting: false,
* },
*/
{
id: "nextScan",
accessorFn: (row) => row.attributes.next_scan_at,
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Next Run"
param="next_scan_at"
/>
),
cell: ({ row }) => (
<DateWithTime dateTime={row.original.attributes.next_scan_at} />
),
},
actionsColumn,
];
+9 -3
View File
@@ -46,13 +46,19 @@ export function useScansFilters(): UseScansFiltersReturn {
router.push(`${pathname}?${params.toString()}`, { scroll: false });
};
const setTab = (tab: string) =>
updateParams({
const setTab = (tab: string) => {
const isScheduledTab = tab === SCAN_JOBS_TAB.SCHEDULED;
const updates: Record<string, string | null> = {
tab,
sort: null,
"filter[state]": null,
"filter[state__in]": null,
});
};
if (isScheduledTab) updates["filter[trigger]"] = null;
updateParams(updates);
};
const setScheduleType = (value: string) =>
updateParams({ "filter[trigger]": value });

Some files were not shown because too many files have changed in this diff Show More