mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-06-10 13:32:44 +00:00
Merge remote-tracking branch 'origin/PROWLER-1775-dynamic-credential-validation' into PROWLER-1777-dynamic-compliance-framework-discovery
This commit is contained in:
@@ -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
@@ -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,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;",
|
||||
),
|
||||
]
|
||||
@@ -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,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.30.0
|
||||
version: 1.31.0
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-3
@@ -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": {
|
||||
|
||||
+1
-2
@@ -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": {
|
||||
|
||||
+1
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+2
-2
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 +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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 />
|
||||
)}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user