mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-06-15 01:49:29 +00:00
Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ffb0f6f45 | |||
| 2b1c9c3381 | |||
| 983c2141ff | |||
| cdac1ce915 | |||
| 789dbfb620 | |||
| d7346a6e63 | |||
| 8efff5ccf8 | |||
| 900a668ddc | |||
| f34daf1e69 | |||
| 3c72e9d25e | |||
| 5392a87a30 | |||
| 40da359804 | |||
| 8a0d56786d | |||
| f729c5a9f0 | |||
| f9682c1354 | |||
| 29825f9a2f | |||
| 356e6e2bb4 | |||
| 8bc8b16a77 | |||
| efa3283a25 | |||
| 4775f11dbf | |||
| a471c82a7e | |||
| 849c399c93 | |||
| e25758ba8e | |||
| c4effd7a60 | |||
| 38788b7922 | |||
| 5daa39c4cb | |||
| 116fb7083d | |||
| 77b2ffeb54 | |||
| 21e63ebc7e | |||
| bcc697f42a | |||
| c94456c131 | |||
| 28433362c5 | |||
| 9c7b33157f | |||
| a111ae763c | |||
| ece6af5dd3 | |||
| b7b5565aeb | |||
| 9c7afd64c5 | |||
| 64e82682bd | |||
| 5070ce39c2 | |||
| b8d3312577 | |||
| 51581c35ec | |||
| cd15ed07eb | |||
| 30f8244ec1 | |||
| 37323e691a | |||
| f14778438e | |||
| 64fdea2954 | |||
| 7dc0895581 | |||
| 383e9c6bd8 | |||
| 459f986abe | |||
| 468234577c | |||
| fe821a41ea | |||
| c1e131766d | |||
| e1ade761b5 | |||
| 64907898f7 | |||
| a6ae4903b8 | |||
| dde265731c | |||
| 073dbb74f6 | |||
| 03cacb83d1 | |||
| b25a8e5b6e | |||
| b3c0f78801 | |||
| ca72922dca | |||
| b13baa9076 | |||
| 4fb14bbb21 | |||
| e5b9fee942 | |||
| 020388824e | |||
| cf99e02ceb | |||
| 9681901174 | |||
| bbe3a7dbf8 | |||
| 0672c80563 | |||
| 92d7ea2170 | |||
| c7aa536896 | |||
| e7f23bb13f | |||
| 82132a9341 | |||
| 5e876579f8 | |||
| be49fd8c4e | |||
| 15d8f1642e | |||
| 79f12f3617 | |||
| 6715361246 | |||
| 45e946cd87 | |||
| 7836905b82 | |||
| 52f6653ccf | |||
| a5de6608ae | |||
| 1cdce02397 | |||
| a31fe9b618 | |||
| 907166d88a | |||
| 0883baad78 | |||
| cf70d1f9f8 | |||
| 60e7657081 | |||
| e8487d0686 | |||
| 9c056beed1 | |||
| f60f7c61c7 | |||
| 3deb1359a5 | |||
| e2295bd086 | |||
| e27317437d | |||
| 6f6016d822 | |||
| 5f10e1c1b6 | |||
| 484211b465 | |||
| f8333baf24 |
@@ -540,7 +540,7 @@ jobs:
|
||||
with:
|
||||
flags: prowler-py${{ matrix.python-version }}-vercel
|
||||
files: ./vercel_coverage.xml
|
||||
|
||||
|
||||
# Scaleway Provider
|
||||
- name: Check if Scaleway files changed
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
@@ -588,7 +588,34 @@ jobs:
|
||||
with:
|
||||
flags: prowler-py${{ matrix.python-version }}-stackit
|
||||
files: ./stackit_coverage.xml
|
||||
|
||||
# External Provider (dynamic loading)
|
||||
- name: Check if External Provider files changed
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
id: changed-external
|
||||
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
|
||||
with:
|
||||
files: |
|
||||
./prowler/providers/common/**
|
||||
./prowler/config/**
|
||||
./prowler/lib/**
|
||||
./tests/providers/external/**
|
||||
./uv.lock
|
||||
|
||||
- name: Run External Provider tests
|
||||
if: steps.changed-external.outputs.any_changed == 'true'
|
||||
run: uv run pytest -n auto --cov=./prowler/providers/common --cov=./prowler/config --cov=./prowler/lib --cov-report=xml:external_coverage.xml tests/providers/external
|
||||
|
||||
- name: Upload External Provider coverage to Codecov
|
||||
if: steps.changed-external.outputs.any_changed == 'true'
|
||||
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
with:
|
||||
flags: prowler-py${{ matrix.python-version }}-external
|
||||
files: ./external_coverage.xml
|
||||
|
||||
# Lib
|
||||
- name: Check if Lib files changed
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
|
||||
@@ -14,6 +14,7 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
- Allowlisted idempotent background tasks are no longer lost when a worker is stopped or crashes mid-task; tasks with external side effects are marked terminal instead of blindly re-running [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
|
||||
- A recovered scan rewrites its findings, summaries, attack surface, and compliance data instead of appending to the previous run, so recovery never leaves stale or duplicate materialized rows [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
|
||||
- Provider type is validated against the SDK's available providers instead of a static enum, so the API accepts any installed provider (built-in or external); `Provider.provider` is stored as `varchar` and the native PostgreSQL enum is removed [(#11399)](https://github.com/prowler-cloud/prowler/pull/11399)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ from api.constants import SEVERITY_ORDER
|
||||
from api.db_utils import (
|
||||
FindingDeltaEnumField,
|
||||
InvitationStateEnumField,
|
||||
ProviderEnumField,
|
||||
SeverityEnumField,
|
||||
StatusEnumField,
|
||||
)
|
||||
@@ -67,6 +66,7 @@ from api.uuid_utils import (
|
||||
uuid7_range,
|
||||
uuid7_start,
|
||||
)
|
||||
from api.provider_types import get_provider_type_choices
|
||||
from api.v1.serializers import TaskBase
|
||||
|
||||
|
||||
@@ -109,11 +109,11 @@ class BaseProviderFilter(FilterSet):
|
||||
provider_id = UUIDFilter(field_name="provider__id", lookup_expr="exact")
|
||||
provider_id__in = UUIDInFilter(field_name="provider__id", lookup_expr="in")
|
||||
provider_type = ChoiceFilter(
|
||||
field_name="provider__provider", choices=Provider.ProviderChoices.choices
|
||||
field_name="provider__provider", choices=get_provider_type_choices()
|
||||
)
|
||||
provider_type__in = ChoiceInFilter(
|
||||
field_name="provider__provider",
|
||||
choices=Provider.ProviderChoices.choices,
|
||||
choices=get_provider_type_choices(),
|
||||
lookup_expr="in",
|
||||
)
|
||||
|
||||
@@ -133,11 +133,11 @@ class BaseScanProviderFilter(FilterSet):
|
||||
provider_id = UUIDFilter(field_name="scan__provider__id", lookup_expr="exact")
|
||||
provider_id__in = UUIDInFilter(field_name="scan__provider__id", lookup_expr="in")
|
||||
provider_type = ChoiceFilter(
|
||||
field_name="scan__provider__provider", choices=Provider.ProviderChoices.choices
|
||||
field_name="scan__provider__provider", choices=get_provider_type_choices()
|
||||
)
|
||||
provider_type__in = ChoiceInFilter(
|
||||
field_name="scan__provider__provider",
|
||||
choices=Provider.ProviderChoices.choices,
|
||||
choices=get_provider_type_choices(),
|
||||
lookup_expr="in",
|
||||
)
|
||||
|
||||
@@ -155,10 +155,10 @@ class CommonFindingFilters(FilterSet):
|
||||
provider_id = UUIDFilter(field_name="scan__provider__id", lookup_expr="exact")
|
||||
provider_id__in = UUIDInFilter(field_name="scan__provider__id", lookup_expr="in")
|
||||
provider_type = ChoiceFilter(
|
||||
choices=Provider.ProviderChoices.choices, field_name="scan__provider__provider"
|
||||
choices=get_provider_type_choices(), field_name="scan__provider__provider"
|
||||
)
|
||||
provider_type__in = ChoiceInFilter(
|
||||
choices=Provider.ProviderChoices.choices, field_name="scan__provider__provider"
|
||||
choices=get_provider_type_choices(), field_name="scan__provider__provider"
|
||||
)
|
||||
provider_uid = CharFilter(field_name="scan__provider__uid", lookup_expr="exact")
|
||||
provider_uid__in = CharInFilter(field_name="scan__provider__uid", lookup_expr="in")
|
||||
@@ -356,18 +356,18 @@ class ProviderFilter(FilterSet):
|
||||
included. Providers with no connection attempt (status is null) are
|
||||
excluded from this filter."""
|
||||
)
|
||||
provider = ChoiceFilter(choices=Provider.ProviderChoices.choices)
|
||||
provider = ChoiceFilter(choices=get_provider_type_choices())
|
||||
provider__in = ChoiceInFilter(
|
||||
field_name="provider",
|
||||
choices=Provider.ProviderChoices.choices,
|
||||
choices=get_provider_type_choices(),
|
||||
lookup_expr="in",
|
||||
)
|
||||
provider_type = ChoiceFilter(
|
||||
choices=Provider.ProviderChoices.choices, field_name="provider"
|
||||
choices=get_provider_type_choices(), field_name="provider"
|
||||
)
|
||||
provider_type__in = ChoiceInFilter(
|
||||
field_name="provider",
|
||||
choices=Provider.ProviderChoices.choices,
|
||||
choices=get_provider_type_choices(),
|
||||
lookup_expr="in",
|
||||
)
|
||||
|
||||
@@ -381,19 +381,14 @@ class ProviderFilter(FilterSet):
|
||||
"inserted_at": ["gte", "lte"],
|
||||
"updated_at": ["gte", "lte"],
|
||||
}
|
||||
filter_overrides = {
|
||||
ProviderEnumField: {
|
||||
"filter_class": CharFilter,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class ProviderRelationshipFilterSet(FilterSet):
|
||||
provider_type = ChoiceFilter(
|
||||
choices=Provider.ProviderChoices.choices, field_name="provider__provider"
|
||||
choices=get_provider_type_choices(), field_name="provider__provider"
|
||||
)
|
||||
provider_type__in = ChoiceInFilter(
|
||||
choices=Provider.ProviderChoices.choices, field_name="provider__provider"
|
||||
choices=get_provider_type_choices(), field_name="provider__provider"
|
||||
)
|
||||
provider_uid = CharFilter(field_name="provider__uid", lookup_expr="exact")
|
||||
provider_uid__in = CharInFilter(field_name="provider__uid", lookup_expr="in")
|
||||
@@ -998,7 +993,7 @@ class FindingGroupSummaryFilter(_CheckTitleToCheckIdMixin, FilterSet):
|
||||
provider_id = UUIDFilter(field_name="provider_id", lookup_expr="exact")
|
||||
provider_id__in = UUIDInFilter(field_name="provider_id", lookup_expr="in")
|
||||
provider_type = ChoiceFilter(
|
||||
field_name="provider__provider", choices=Provider.ProviderChoices.choices
|
||||
field_name="provider__provider", choices=get_provider_type_choices()
|
||||
)
|
||||
provider_type__in = CharInFilter(field_name="provider__provider", lookup_expr="in")
|
||||
|
||||
@@ -1098,7 +1093,7 @@ class LatestFindingGroupSummaryFilter(_CheckTitleToCheckIdMixin, FilterSet):
|
||||
provider_id = UUIDFilter(field_name="provider_id", lookup_expr="exact")
|
||||
provider_id__in = UUIDInFilter(field_name="provider_id", lookup_expr="in")
|
||||
provider_type = ChoiceFilter(
|
||||
field_name="provider__provider", choices=Provider.ProviderChoices.choices
|
||||
field_name="provider__provider", choices=get_provider_type_choices()
|
||||
)
|
||||
provider_type__in = CharInFilter(field_name="provider__provider", lookup_expr="in")
|
||||
|
||||
@@ -1301,10 +1296,10 @@ class ScanSummaryFilter(FilterSet):
|
||||
provider_id = UUIDFilter(field_name="scan__provider__id", lookup_expr="exact")
|
||||
provider_id__in = UUIDInFilter(field_name="scan__provider__id", lookup_expr="in")
|
||||
provider_type = ChoiceFilter(
|
||||
field_name="scan__provider__provider", choices=Provider.ProviderChoices.choices
|
||||
field_name="scan__provider__provider", choices=get_provider_type_choices()
|
||||
)
|
||||
provider_type__in = ChoiceInFilter(
|
||||
field_name="scan__provider__provider", choices=Provider.ProviderChoices.choices
|
||||
field_name="scan__provider__provider", choices=get_provider_type_choices()
|
||||
)
|
||||
region = CharFilter(field_name="region")
|
||||
|
||||
@@ -1324,10 +1319,10 @@ class DailySeveritySummaryFilter(FilterSet):
|
||||
provider_id = UUIDFilter(field_name="provider_id", lookup_expr="exact")
|
||||
provider_id__in = UUIDInFilter(field_name="provider_id", lookup_expr="in")
|
||||
provider_type = ChoiceFilter(
|
||||
field_name="provider__provider", choices=Provider.ProviderChoices.choices
|
||||
field_name="provider__provider", choices=get_provider_type_choices()
|
||||
)
|
||||
provider_type__in = ChoiceInFilter(
|
||||
field_name="provider__provider", choices=Provider.ProviderChoices.choices
|
||||
field_name="provider__provider", choices=get_provider_type_choices()
|
||||
)
|
||||
date_from = DateFilter(method="filter_noop")
|
||||
date_to = DateFilter(method="filter_noop")
|
||||
@@ -1578,11 +1573,11 @@ class ThreatScoreSnapshotFilter(FilterSet):
|
||||
provider_id = UUIDFilter(field_name="provider__id", lookup_expr="exact")
|
||||
provider_id__in = UUIDInFilter(field_name="provider__id", lookup_expr="in")
|
||||
provider_type = ChoiceFilter(
|
||||
field_name="provider__provider", choices=Provider.ProviderChoices.choices
|
||||
field_name="provider__provider", choices=get_provider_type_choices()
|
||||
)
|
||||
provider_type__in = ChoiceInFilter(
|
||||
field_name="provider__provider",
|
||||
choices=Provider.ProviderChoices.choices,
|
||||
choices=get_provider_type_choices(),
|
||||
lookup_expr="in",
|
||||
)
|
||||
compliance_id = CharFilter(field_name="compliance_id", lookup_expr="exact")
|
||||
@@ -1621,11 +1616,11 @@ class ResourceGroupOverviewFilter(FilterSet):
|
||||
provider_id = UUIDFilter(field_name="scan__provider__id", lookup_expr="exact")
|
||||
provider_id__in = UUIDInFilter(field_name="scan__provider__id", lookup_expr="in")
|
||||
provider_type = ChoiceFilter(
|
||||
field_name="scan__provider__provider", choices=Provider.ProviderChoices.choices
|
||||
field_name="scan__provider__provider", choices=get_provider_type_choices()
|
||||
)
|
||||
provider_type__in = ChoiceInFilter(
|
||||
field_name="scan__provider__provider",
|
||||
choices=Provider.ProviderChoices.choices,
|
||||
choices=get_provider_type_choices(),
|
||||
lookup_expr="in",
|
||||
)
|
||||
resource_group = CharFilter(field_name="resource_group", lookup_expr="exact")
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
"""Expand step of the zero-downtime migration of Provider.provider from a
|
||||
native PostgreSQL enum to varchar.
|
||||
|
||||
Adds a transitional varchar shadow column `provider_str` and a trigger that
|
||||
keeps it in sync with the `provider` enum column on every INSERT/UPDATE.
|
||||
Adding a nullable column is metadata-only (no table rewrite, no long lock).
|
||||
The trigger covers writes from now on; existing rows are populated by a
|
||||
later backfill. A subsequent migration drops the enum column and renames
|
||||
`provider_str` to take its place.
|
||||
"""
|
||||
|
||||
dependencies = [
|
||||
("api", "0096_jiraissuedispatch"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="provider",
|
||||
name="provider_str",
|
||||
field=models.CharField(blank=True, max_length=50, null=True),
|
||||
),
|
||||
migrations.RunSQL(
|
||||
sql=(
|
||||
"CREATE OR REPLACE FUNCTION sync_provider_str() RETURNS trigger AS $$\n"
|
||||
"BEGIN\n"
|
||||
" NEW.provider_str := NEW.provider::text;\n"
|
||||
" RETURN NEW;\n"
|
||||
"END;\n"
|
||||
"$$ LANGUAGE plpgsql;\n"
|
||||
"CREATE TRIGGER providers_sync_provider_str\n"
|
||||
" BEFORE INSERT OR UPDATE ON providers\n"
|
||||
" FOR EACH ROW EXECUTE FUNCTION sync_provider_str();"
|
||||
),
|
||||
reverse_sql=(
|
||||
"DROP TRIGGER IF EXISTS providers_sync_provider_str ON providers;\n"
|
||||
"DROP FUNCTION IF EXISTS sync_provider_str();"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
"""Synchronous backfill of the `provider_str` shadow column.
|
||||
|
||||
A single UPDATE fills rows that predate the 0097 trigger. The providers
|
||||
table is small, so this is safe inline and guarantees the column is fully
|
||||
populated before 0099 sets it NOT NULL (no race with an async backfill).
|
||||
Runs on the migration connection, which is exempt from RLS.
|
||||
"""
|
||||
|
||||
dependencies = [
|
||||
("api", "0097_provider_str_shadow_column"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunSQL(
|
||||
sql=(
|
||||
"UPDATE providers SET provider_str = provider::text "
|
||||
"WHERE provider_str IS NULL;"
|
||||
),
|
||||
reverse_sql=migrations.RunSQL.noop,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,51 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
"""Contract step: promote `provider_str` into `provider`.
|
||||
|
||||
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 = [
|
||||
("api", "0098_backfill_provider_str"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.SeparateDatabaseAndState(
|
||||
state_operations=[
|
||||
migrations.RemoveField(
|
||||
model_name="provider",
|
||||
name="provider_str",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="provider",
|
||||
name="provider",
|
||||
field=models.CharField(default="aws", max_length=50),
|
||||
),
|
||||
],
|
||||
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"
|
||||
"ALTER TABLE providers DROP COLUMN provider;\n"
|
||||
"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;\n"
|
||||
"CREATE UNIQUE INDEX unique_provider_uids ON providers "
|
||||
"(tenant_id, provider, uid) WHERE NOT is_deleted;"
|
||||
),
|
||||
reverse_sql=migrations.RunSQL.noop,
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -40,7 +40,6 @@ from api.db_utils import (
|
||||
InvitationStateEnumField,
|
||||
MemberRoleEnumField,
|
||||
ProcessorTypeEnumField,
|
||||
ProviderEnumField,
|
||||
ProviderSecretTypeEnumField,
|
||||
ScanTriggerEnumField,
|
||||
SeverityEnumField,
|
||||
@@ -59,6 +58,7 @@ from api.rls import (
|
||||
Tenant,
|
||||
)
|
||||
from prowler.lib.check.models import Severity
|
||||
from prowler.providers.common.provider import Provider as SDKProvider
|
||||
|
||||
fernet = Fernet(settings.SECRETS_ENCRYPTION_KEY.encode())
|
||||
|
||||
@@ -482,9 +482,10 @@ class Provider(RowLevelSecurityProtectedModel):
|
||||
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
|
||||
updated_at = models.DateTimeField(auto_now=True, editable=False)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
provider = ProviderEnumField(
|
||||
choices=ProviderChoices.choices, default=ProviderChoices.AWS
|
||||
)
|
||||
# Stored as a plain varchar: the SDK is the source of truth for which
|
||||
# providers are valid, so the column accepts any provider name without a
|
||||
# database-level enum to keep in sync.
|
||||
provider = models.CharField(max_length=50, default=ProviderChoices.AWS)
|
||||
uid = models.CharField(
|
||||
"Unique identifier for the provider, set by the provider",
|
||||
max_length=250,
|
||||
@@ -501,13 +502,24 @@ class Provider(RowLevelSecurityProtectedModel):
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.provider not in SDKProvider.get_available_providers():
|
||||
raise ModelValidationError(
|
||||
detail=f"{self.provider} is not a supported provider.",
|
||||
code="invalid",
|
||||
pointer="/data/attributes/provider",
|
||||
)
|
||||
if self.provider == self.ProviderChoices.OKTA and self.uid:
|
||||
# Mirror the SDK, which lowercases the org domain before connecting.
|
||||
# Without this the API would reject Acme.okta.com even though the
|
||||
# SDK would accept it, and stored uids could disagree with the
|
||||
# authenticated org domain.
|
||||
self.uid = self.uid.strip().lower()
|
||||
getattr(self, f"validate_{self.provider}_uid")(self.uid)
|
||||
# Providers the SDK exposes but the API has no specific uid rule for
|
||||
# (e.g. external providers) fall back to the field-level min-length
|
||||
# check only, instead of failing on a missing validator.
|
||||
uid_validator = getattr(self, f"validate_{self.provider}_uid", None)
|
||||
if uid_validator is not None:
|
||||
uid_validator(self.uid)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.full_clean()
|
||||
|
||||
@@ -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()]
|
||||
@@ -0,0 +1,23 @@
|
||||
from api.filters import get_provider_type_choices
|
||||
from prowler.providers.common.provider import Provider as SDKProvider
|
||||
|
||||
|
||||
class TestProviderTypeChoices:
|
||||
"""Provider-type filter choices are driven by the SDK's available providers
|
||||
so filtering covers external providers, not just a static enum."""
|
||||
|
||||
def test_choices_track_sdk_available_providers(self):
|
||||
available = set(SDKProvider.get_available_providers())
|
||||
choices = get_provider_type_choices()
|
||||
|
||||
assert {value for value, _ in choices} == available
|
||||
|
||||
def test_choices_include_provider_absent_from_legacy_enum(self):
|
||||
from api.models import Provider
|
||||
|
||||
legacy = {value for value, _ in Provider.ProviderChoices.choices}
|
||||
choice_values = {value for value, _ in get_provider_type_choices()}
|
||||
|
||||
# `llm` is exposed by the SDK but is not part of the legacy static enum.
|
||||
assert "llm" in choice_values
|
||||
assert "llm" not in legacy
|
||||
@@ -6,7 +6,9 @@ from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError
|
||||
|
||||
from api.db_router import MainRouter
|
||||
from api.exceptions import ModelValidationError
|
||||
from api.models import (
|
||||
Provider,
|
||||
ProviderComplianceScore,
|
||||
Resource,
|
||||
ResourceTag,
|
||||
@@ -525,3 +527,29 @@ class TestTenantComplianceSummaryModel:
|
||||
|
||||
assert summary1.id != summary2.id
|
||||
assert summary1.requirements_passed != summary2.requirements_passed
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProviderDynamicValidation:
|
||||
"""Provider validity is driven by the SDK's available providers, not a
|
||||
static enum. Providers the SDK exposes are accepted; for those without a
|
||||
`validate_<provider>_uid` method only the uid min-length floor applies."""
|
||||
|
||||
def test_accepts_provider_without_uid_validator(self, tenants_fixture):
|
||||
tenant = tenants_fixture[0]
|
||||
provider = Provider.objects.create(
|
||||
tenant_id=tenant.id, provider="llm", uid="my-llm-account"
|
||||
)
|
||||
assert provider.provider == "llm"
|
||||
|
||||
def test_rejects_provider_not_available_in_sdk(self, tenants_fixture):
|
||||
tenant = tenants_fixture[0]
|
||||
with pytest.raises(ModelValidationError):
|
||||
Provider.objects.create(
|
||||
tenant_id=tenant.id, provider="does-not-exist", uid="whatever"
|
||||
)
|
||||
|
||||
def test_uid_floor_still_enforced_for_external_provider(self, tenants_fixture):
|
||||
tenant = tenants_fixture[0]
|
||||
with pytest.raises(ValidationError):
|
||||
Provider.objects.create(tenant_id=tenant.id, provider="llm", uid="ab")
|
||||
|
||||
@@ -1,8 +1,103 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from api.v1.serializer_utils.integrations import S3ConfigSerializer
|
||||
from api.v1.serializers import ImageProviderSecret
|
||||
from api.v1.serializers import (
|
||||
BaseWriteProviderSecretSerializer,
|
||||
ImageProviderSecret,
|
||||
ProviderEnumSerializerField,
|
||||
)
|
||||
|
||||
|
||||
class TestExternalProviderSecretValidation:
|
||||
"""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 _StaticCredentials(BaseModel):
|
||||
api_url: str
|
||||
api_key: str
|
||||
|
||||
class _RoleCredentials(BaseModel):
|
||||
role_arn: str
|
||||
|
||||
def _patch(self, schemas):
|
||||
provider_class = MagicMock()
|
||||
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", "static", {"api_url": "u", "api_key": "k"}
|
||||
)
|
||||
|
||||
def test_secret_rejected_when_schema_violated(self):
|
||||
with self._patch({"static": self._StaticCredentials}):
|
||||
with pytest.raises(ValidationError):
|
||||
BaseWriteProviderSecretSerializer._validate_external_provider_secret(
|
||||
"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):
|
||||
with self._patch({}):
|
||||
BaseWriteProviderSecretSerializer._validate_external_provider_secret(
|
||||
"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
|
||||
external) and rejects anything else with `invalid_choice`."""
|
||||
|
||||
def test_accepts_sdk_available_provider(self):
|
||||
field = ProviderEnumSerializerField()
|
||||
assert field.run_validation("aws") == "aws"
|
||||
|
||||
def test_accepts_external_provider_absent_from_static_enum(self):
|
||||
field = ProviderEnumSerializerField()
|
||||
# `llm` is exposed by the SDK but is not part of the legacy static enum.
|
||||
assert field.run_validation("llm") == "llm"
|
||||
|
||||
def test_rejects_unknown_provider(self):
|
||||
field = ProviderEnumSerializerField()
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
field.run_validation("does-not-exist")
|
||||
assert exc.value.detail[0].code == "invalid_choice"
|
||||
|
||||
|
||||
class TestS3ConfigSerializer:
|
||||
|
||||
@@ -146,11 +146,25 @@ class TestReturnProwlerProvider:
|
||||
with pytest.raises(ValueError):
|
||||
return return_prowler_provider(provider)
|
||||
|
||||
def test_return_prowler_provider_external_resolves_via_get_class(self):
|
||||
"""An external provider name resolves through the SDK resolver, so any
|
||||
entry-point provider works without a hardcoded branch in the API."""
|
||||
external_class = type("ExternalProvider", (), {})
|
||||
provider = MagicMock()
|
||||
provider.provider = "external-template"
|
||||
with patch(
|
||||
"api.utils.SDKProvider.get_class", return_value=external_class
|
||||
) as mock_get_class:
|
||||
resolved = return_prowler_provider(provider)
|
||||
mock_get_class.assert_called_once_with("external-template")
|
||||
assert resolved is external_class
|
||||
|
||||
|
||||
class TestInitializeProwlerProvider:
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_initialize_prowler_provider(self, mock_return_prowler_provider):
|
||||
provider = MagicMock()
|
||||
provider.provider = "aws"
|
||||
provider.secret.secret = {"key": "value"}
|
||||
mock_return_prowler_provider.return_value = MagicMock()
|
||||
|
||||
@@ -162,6 +176,7 @@ class TestInitializeProwlerProvider:
|
||||
self, mock_return_prowler_provider
|
||||
):
|
||||
provider = MagicMock()
|
||||
provider.provider = "aws"
|
||||
provider.secret.secret = {"key": "value"}
|
||||
mutelist_processor = MagicMock()
|
||||
mutelist_processor.configuration = {"Mutelist": {"key": "value"}}
|
||||
@@ -177,6 +192,7 @@ class TestProwlerProviderConnectionTest:
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_prowler_provider_connection_test(self, mock_return_prowler_provider):
|
||||
provider = MagicMock()
|
||||
provider.provider = "aws"
|
||||
provider.uid = "1234567890"
|
||||
provider.secret.secret = {"key": "value"}
|
||||
mock_return_prowler_provider.return_value = MagicMock()
|
||||
@@ -186,6 +202,29 @@ class TestProwlerProviderConnectionTest:
|
||||
key="value", provider_id="1234567890", raise_on_exception=False
|
||||
)
|
||||
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_prowler_provider_connection_test_external_uses_contract(
|
||||
self, mock_return_prowler_provider
|
||||
):
|
||||
"""External providers build connection kwargs through the SDK contract
|
||||
(get_connection_arguments), with no provider_id forced by the API."""
|
||||
provider = MagicMock()
|
||||
provider.provider = "external-template"
|
||||
provider.uid = "acme-1"
|
||||
provider.secret.secret = {"api_key": "k"}
|
||||
mock_return_prowler_provider.return_value.get_connection_arguments.return_value = {
|
||||
"api_key": "k"
|
||||
}
|
||||
|
||||
prowler_provider_connection_test(provider)
|
||||
|
||||
mock_return_prowler_provider.return_value.get_connection_arguments.assert_called_once_with(
|
||||
"acme-1", {"api_key": "k"}
|
||||
)
|
||||
mock_return_prowler_provider.return_value.test_connection.assert_called_once_with(
|
||||
api_key="k", raise_on_exception=False
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_prowler_provider_connection_test_without_secret(
|
||||
@@ -560,7 +599,8 @@ class TestGetProwlerProviderKwargs:
|
||||
assert result == expected_result
|
||||
|
||||
def test_get_prowler_provider_kwargs_unsupported_provider(self):
|
||||
# Setup
|
||||
# A non-built-in provider is resolved dynamically; one that is neither a
|
||||
# built-in nor an installed entry-point provider cannot be resolved.
|
||||
provider_uid = "provider_uid"
|
||||
secret_dict = {"key": "value"}
|
||||
secret_mock = MagicMock()
|
||||
@@ -571,10 +611,30 @@ class TestGetProwlerProviderKwargs:
|
||||
provider.secret = secret_mock
|
||||
provider.uid = provider_uid
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
get_prowler_provider_kwargs(provider)
|
||||
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_get_prowler_provider_kwargs_external_uses_contract(
|
||||
self, mock_return_prowler_provider
|
||||
):
|
||||
"""External providers build constructor kwargs through the SDK contract
|
||||
(get_scan_arguments), not a hardcoded branch in the API."""
|
||||
provider = MagicMock()
|
||||
provider.provider = "external-template"
|
||||
provider.uid = "acme-1"
|
||||
provider.secret.secret = {"api_key": "k"}
|
||||
mock_return_prowler_provider.return_value.get_scan_arguments.return_value = {
|
||||
"api_key": "k",
|
||||
"custom": "acme-1",
|
||||
}
|
||||
|
||||
result = get_prowler_provider_kwargs(provider)
|
||||
|
||||
expected_result = secret_dict.copy()
|
||||
assert result == expected_result
|
||||
mock_return_prowler_provider.return_value.get_scan_arguments.assert_called_once_with(
|
||||
"acme-1", {"api_key": "k"}, None
|
||||
)
|
||||
assert result == {"api_key": "k", "custom": "acme-1"}
|
||||
|
||||
def test_get_prowler_provider_kwargs_no_secret(self):
|
||||
# Setup
|
||||
|
||||
+39
-149
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from allauth.socialaccount.providers.oauth2.client import OAuth2Client
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
@@ -17,30 +16,7 @@ from prowler.lib.outputs.jira.jira import Jira, JiraBasicAuthError
|
||||
from prowler.providers.aws.lib.s3.s3 import S3
|
||||
from prowler.providers.aws.lib.security_hub.security_hub import SecurityHub
|
||||
from prowler.providers.common.models import Connection
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from prowler.providers.alibabacloud.alibabacloud_provider import (
|
||||
AlibabacloudProvider,
|
||||
)
|
||||
from prowler.providers.aws.aws_provider import AwsProvider
|
||||
from prowler.providers.azure.azure_provider import AzureProvider
|
||||
from prowler.providers.cloudflare.cloudflare_provider import CloudflareProvider
|
||||
from prowler.providers.gcp.gcp_provider import GcpProvider
|
||||
from prowler.providers.github.github_provider import GithubProvider
|
||||
from prowler.providers.googleworkspace.googleworkspace_provider import (
|
||||
GoogleworkspaceProvider,
|
||||
)
|
||||
from prowler.providers.iac.iac_provider import IacProvider
|
||||
from prowler.providers.image.image_provider import ImageProvider
|
||||
from prowler.providers.kubernetes.kubernetes_provider import KubernetesProvider
|
||||
from prowler.providers.m365.m365_provider import M365Provider
|
||||
from prowler.providers.mongodbatlas.mongodbatlas_provider import (
|
||||
MongodbatlasProvider,
|
||||
)
|
||||
from prowler.providers.okta.okta_provider import OktaProvider
|
||||
from prowler.providers.openstack.openstack_provider import OpenstackProvider
|
||||
from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider
|
||||
from prowler.providers.vercel.vercel_provider import VercelProvider
|
||||
from prowler.providers.common.provider import Provider as SDKProvider
|
||||
|
||||
|
||||
class CustomOAuth2Client(OAuth2Client):
|
||||
@@ -79,117 +55,26 @@ def merge_dicts(default_dict: dict, replacement_dict: dict) -> dict:
|
||||
return result
|
||||
|
||||
|
||||
def return_prowler_provider(
|
||||
provider: Provider,
|
||||
) -> (
|
||||
AlibabacloudProvider
|
||||
| AwsProvider
|
||||
| AzureProvider
|
||||
| CloudflareProvider
|
||||
| GcpProvider
|
||||
| GithubProvider
|
||||
| GoogleworkspaceProvider
|
||||
| IacProvider
|
||||
| ImageProvider
|
||||
| KubernetesProvider
|
||||
| M365Provider
|
||||
| MongodbatlasProvider
|
||||
| OktaProvider
|
||||
| OpenstackProvider
|
||||
| OraclecloudProvider
|
||||
| VercelProvider
|
||||
):
|
||||
"""Return the Prowler provider class based on the given provider type.
|
||||
def return_prowler_provider(provider: Provider) -> type:
|
||||
"""Resolve the Prowler provider class for the given provider.
|
||||
|
||||
The class is resolved dynamically from the SDK, so any provider the SDK
|
||||
discovers — built-in or external entry-point — works without a per-provider
|
||||
branch in the API.
|
||||
|
||||
Args:
|
||||
provider (Provider): The provider object containing the provider type and associated secrets.
|
||||
provider (Provider): The provider whose `provider` type to resolve.
|
||||
|
||||
Returns:
|
||||
AlibabacloudProvider | AwsProvider | AzureProvider | CloudflareProvider | GcpProvider | GithubProvider | GoogleworkspaceProvider | IacProvider | ImageProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OpenstackProvider | OraclecloudProvider: The corresponding provider class.
|
||||
type: The Prowler provider class.
|
||||
|
||||
Raises:
|
||||
ValueError: If the provider type specified in `provider.provider` is not supported.
|
||||
ValueError: If the provider type is not available in the SDK.
|
||||
"""
|
||||
match provider.provider:
|
||||
case Provider.ProviderChoices.AWS.value:
|
||||
from prowler.providers.aws.aws_provider import AwsProvider
|
||||
|
||||
prowler_provider = AwsProvider
|
||||
case Provider.ProviderChoices.GCP.value:
|
||||
from prowler.providers.gcp.gcp_provider import GcpProvider
|
||||
|
||||
prowler_provider = GcpProvider
|
||||
case Provider.ProviderChoices.GOOGLEWORKSPACE.value:
|
||||
from prowler.providers.googleworkspace.googleworkspace_provider import (
|
||||
GoogleworkspaceProvider,
|
||||
)
|
||||
|
||||
prowler_provider = GoogleworkspaceProvider
|
||||
case Provider.ProviderChoices.AZURE.value:
|
||||
from prowler.providers.azure.azure_provider import AzureProvider
|
||||
|
||||
prowler_provider = AzureProvider
|
||||
case Provider.ProviderChoices.KUBERNETES.value:
|
||||
from prowler.providers.kubernetes.kubernetes_provider import (
|
||||
KubernetesProvider,
|
||||
)
|
||||
|
||||
prowler_provider = KubernetesProvider
|
||||
case Provider.ProviderChoices.M365.value:
|
||||
from prowler.providers.m365.m365_provider import M365Provider
|
||||
|
||||
prowler_provider = M365Provider
|
||||
case Provider.ProviderChoices.GITHUB.value:
|
||||
from prowler.providers.github.github_provider import GithubProvider
|
||||
|
||||
prowler_provider = GithubProvider
|
||||
case Provider.ProviderChoices.MONGODBATLAS.value:
|
||||
from prowler.providers.mongodbatlas.mongodbatlas_provider import (
|
||||
MongodbatlasProvider,
|
||||
)
|
||||
|
||||
prowler_provider = MongodbatlasProvider
|
||||
case Provider.ProviderChoices.IAC.value:
|
||||
from prowler.providers.iac.iac_provider import IacProvider
|
||||
|
||||
prowler_provider = IacProvider
|
||||
case Provider.ProviderChoices.ORACLECLOUD.value:
|
||||
from prowler.providers.oraclecloud.oraclecloud_provider import (
|
||||
OraclecloudProvider,
|
||||
)
|
||||
|
||||
prowler_provider = OraclecloudProvider
|
||||
case Provider.ProviderChoices.ALIBABACLOUD.value:
|
||||
from prowler.providers.alibabacloud.alibabacloud_provider import (
|
||||
AlibabacloudProvider,
|
||||
)
|
||||
|
||||
prowler_provider = AlibabacloudProvider
|
||||
case Provider.ProviderChoices.CLOUDFLARE.value:
|
||||
from prowler.providers.cloudflare.cloudflare_provider import (
|
||||
CloudflareProvider,
|
||||
)
|
||||
|
||||
prowler_provider = CloudflareProvider
|
||||
case Provider.ProviderChoices.OPENSTACK.value:
|
||||
from prowler.providers.openstack.openstack_provider import OpenstackProvider
|
||||
|
||||
prowler_provider = OpenstackProvider
|
||||
case Provider.ProviderChoices.IMAGE.value:
|
||||
from prowler.providers.image.image_provider import ImageProvider
|
||||
|
||||
prowler_provider = ImageProvider
|
||||
case Provider.ProviderChoices.VERCEL.value:
|
||||
from prowler.providers.vercel.vercel_provider import VercelProvider
|
||||
|
||||
prowler_provider = VercelProvider
|
||||
case Provider.ProviderChoices.OKTA.value:
|
||||
from prowler.providers.okta.okta_provider import OktaProvider
|
||||
|
||||
prowler_provider = OktaProvider
|
||||
case _:
|
||||
raise ValueError(f"Provider type {provider.provider} not supported")
|
||||
return prowler_provider
|
||||
try:
|
||||
return SDKProvider.get_class(provider.provider)
|
||||
except ImportError as error:
|
||||
raise ValueError(f"Provider type {provider.provider} not supported") from error
|
||||
|
||||
|
||||
def get_prowler_provider_kwargs(
|
||||
@@ -204,6 +89,18 @@ def get_prowler_provider_kwargs(
|
||||
Returns:
|
||||
dict: The provider kwargs for the corresponding provider class.
|
||||
"""
|
||||
# External providers declare their own uid/secret -> kwargs mapping through
|
||||
# the SDK contract; built-in providers keep the explicit mapping below.
|
||||
if not SDKProvider.is_builtin(provider.provider):
|
||||
mutelist_content = (
|
||||
mutelist_processor.configuration.get("Mutelist", {})
|
||||
if mutelist_processor
|
||||
else None
|
||||
)
|
||||
return return_prowler_provider(provider).get_scan_arguments(
|
||||
provider.uid, provider.secret.secret, mutelist_content
|
||||
)
|
||||
|
||||
prowler_provider_kwargs = provider.secret.secret
|
||||
if provider.provider == Provider.ProviderChoices.AZURE.value:
|
||||
prowler_provider_kwargs = {
|
||||
@@ -288,24 +185,7 @@ def get_prowler_provider_kwargs(
|
||||
def initialize_prowler_provider(
|
||||
provider: Provider,
|
||||
mutelist_processor: Processor | None = None,
|
||||
) -> (
|
||||
AlibabacloudProvider
|
||||
| AwsProvider
|
||||
| AzureProvider
|
||||
| CloudflareProvider
|
||||
| GcpProvider
|
||||
| GithubProvider
|
||||
| GoogleworkspaceProvider
|
||||
| IacProvider
|
||||
| ImageProvider
|
||||
| KubernetesProvider
|
||||
| M365Provider
|
||||
| MongodbatlasProvider
|
||||
| OktaProvider
|
||||
| OpenstackProvider
|
||||
| OraclecloudProvider
|
||||
| VercelProvider
|
||||
):
|
||||
) -> SDKProvider:
|
||||
"""Initialize a Prowler provider instance based on the given provider type.
|
||||
|
||||
Args:
|
||||
@@ -313,8 +193,8 @@ def initialize_prowler_provider(
|
||||
mutelist_processor (Processor): The mutelist processor object containing the mutelist configuration.
|
||||
|
||||
Returns:
|
||||
AlibabacloudProvider | AwsProvider | AzureProvider | CloudflareProvider | GcpProvider | GithubProvider | GoogleworkspaceProvider | IacProvider | ImageProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OpenstackProvider | OraclecloudProvider: An instance of the corresponding provider class
|
||||
initialized with the provider's secrets.
|
||||
SDKProvider: An instance of the corresponding provider class initialized
|
||||
with the provider's secrets.
|
||||
"""
|
||||
prowler_provider = return_prowler_provider(provider)
|
||||
prowler_provider_kwargs = get_prowler_provider_kwargs(provider, mutelist_processor)
|
||||
@@ -337,6 +217,16 @@ def prowler_provider_connection_test(provider: Provider) -> Connection:
|
||||
except Provider.secret.RelatedObjectDoesNotExist as secret_error:
|
||||
return Connection(is_connected=False, error=secret_error)
|
||||
|
||||
# External providers declare their own connection kwargs through the SDK
|
||||
# contract; built-in providers keep the explicit mapping below.
|
||||
if not SDKProvider.is_builtin(provider.provider):
|
||||
return prowler_provider.test_connection(
|
||||
**prowler_provider.get_connection_arguments(
|
||||
provider.uid, prowler_provider_kwargs
|
||||
),
|
||||
raise_on_exception=False,
|
||||
)
|
||||
|
||||
# For IaC provider, construct the kwargs properly for test_connection
|
||||
if provider.provider == Provider.ProviderChoices.IAC.value:
|
||||
# Don't pass repository_url from secret, use scan_repository_url with the UID
|
||||
|
||||
@@ -10,6 +10,7 @@ from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.db import IntegrityError
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from jwt.exceptions import InvalidKeyError
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
from rest_framework.reverse import reverse
|
||||
from rest_framework.validators import UniqueTogetherValidator
|
||||
from rest_framework_json_api import serializers
|
||||
@@ -71,8 +72,10 @@ 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
|
||||
|
||||
# Base
|
||||
|
||||
@@ -854,7 +857,9 @@ class ProviderGroupMembershipSerializer(RLSSerializer, BaseWriteSerializer):
|
||||
# Providers
|
||||
class ProviderEnumSerializerField(serializers.ChoiceField):
|
||||
def __init__(self, **kwargs):
|
||||
kwargs["choices"] = Provider.ProviderChoices.choices
|
||||
# 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)
|
||||
|
||||
|
||||
@@ -940,6 +945,12 @@ class ProviderIncludeSerializer(RLSSerializer):
|
||||
|
||||
|
||||
class ProviderCreateSerializer(RLSSerializer, BaseWriteSerializer):
|
||||
# Declared explicitly so provider validation stays at the serializer layer:
|
||||
# the model column is now a plain varchar with no choices, so without this
|
||||
# an unknown provider would slip through to Provider.clean() instead of
|
||||
# being rejected here with `invalid_choice`.
|
||||
provider = ProviderEnumSerializerField()
|
||||
|
||||
class Meta:
|
||||
model = Provider
|
||||
fields = [
|
||||
@@ -1532,10 +1543,59 @@ class FindingMetadataSerializer(BaseSerializerV1):
|
||||
|
||||
# Provider secrets
|
||||
class BaseWriteProviderSecretSerializer(BaseWriteSerializer):
|
||||
@staticmethod
|
||||
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.
|
||||
|
||||
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
|
||||
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(
|
||||
provider_type: str, secret_type: ProviderSecret.TypeChoices, secret: dict
|
||||
):
|
||||
# External providers validate against the schemas they declare via the
|
||||
# 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_type, secret
|
||||
)
|
||||
return
|
||||
|
||||
if secret_type == ProviderSecret.TypeChoices.STATIC:
|
||||
if provider_type == Provider.ProviderChoices.AWS.value:
|
||||
serializer = AwsProviderSecret(data=secret)
|
||||
|
||||
@@ -8,14 +8,14 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
- `sagemaker_models_monitor_enabled` check for AWS provider, verifying that each SageMaker monitoring schedule is in the `Scheduled` state so data and model drift is actively detected [(#11278)](https://github.com/prowler-cloud/prowler/pull/11278)
|
||||
- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) universal compliance framework with AWS provider coverage across the five DORA pillars [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131)
|
||||
- Support for external/custom providers, checks, and compliance frameworks without modifying core code [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700)
|
||||
- Public `Provider.get_class()` method that resolves a provider class by name for both built-in and external (entry-point) providers [(#11398)](https://github.com/prowler-cloud/prowler/pull/11398)
|
||||
- `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471)
|
||||
|
||||
---
|
||||
|
||||
## [5.29.3] (Prowler UNRELEASED)
|
||||
- External multi-provider compliance frameworks can be registered via the `prowler.compliance.universal` entry point group [(#11490)](https://github.com/prowler-cloud/prowler/pull/11490)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `load_and_validate_config_file` now unwraps namespaced config for every built-in and external provider, and no longer leaks the full file as the provider's config when the file is namespaced [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700)
|
||||
- GCP `logging_sink_created` now recognizes organization-level aggregated sinks with `includeChildren=True`, avoiding false failures for covered projects [(#11355)](https://github.com/prowler-cloud/prowler/pull/11355)
|
||||
- Jira integration no longer fails with `400 INVALID_INPUT` when a finding has empty fields [(#11474)](https://github.com/prowler-cloud/prowler/pull/11474)
|
||||
- GCP `iam_service_account_unused` now passes disabled service accounts instead of failing them, since a disabled account cannot authenticate or be used [(#11467)](https://github.com/prowler-cloud/prowler/pull/11467)
|
||||
|
||||
+57
-12
@@ -10,7 +10,6 @@ from colorama import Fore, Style
|
||||
from colorama import init as colorama_init
|
||||
|
||||
from prowler.config.config import (
|
||||
EXTERNAL_TOOL_PROVIDERS,
|
||||
cloud_api_base_url,
|
||||
csv_file_suffix,
|
||||
get_available_compliance_frameworks,
|
||||
@@ -205,9 +204,10 @@ def prowler():
|
||||
# We treat the compliance framework as another output format
|
||||
if compliance_framework:
|
||||
args.output_formats.extend(compliance_framework)
|
||||
# If no input compliance framework, set all, unless a specific service or check is input
|
||||
# Skip for IAC and LLM providers that don't use compliance frameworks
|
||||
elif default_execution and provider not in ["iac", "llm"]:
|
||||
# If no input compliance framework, set all, unless a specific service or check is input.
|
||||
# Skip for tool-wrapper providers (iac, llm, image, and any external plug-in
|
||||
# declaring `is_external_tool_provider = True`) — they don't use compliance frameworks.
|
||||
elif default_execution and not Provider.is_tool_wrapper_provider(provider):
|
||||
args.output_formats.extend(get_available_compliance_frameworks(provider))
|
||||
|
||||
# Set Logger configuration
|
||||
@@ -245,7 +245,7 @@ def prowler():
|
||||
universal_frameworks = {}
|
||||
|
||||
# Skip compliance frameworks for external-tool providers
|
||||
if provider not in EXTERNAL_TOOL_PROVIDERS:
|
||||
if not Provider.is_tool_wrapper_provider(provider):
|
||||
bulk_compliance_frameworks = Compliance.get_bulk(provider)
|
||||
# Complete checks metadata with the compliance framework specification
|
||||
bulk_checks_metadata = update_checks_metadata_with_compliance(
|
||||
@@ -313,7 +313,7 @@ def prowler():
|
||||
sys.exit()
|
||||
|
||||
# Skip service and check loading for external-tool providers
|
||||
if provider not in EXTERNAL_TOOL_PROVIDERS:
|
||||
if not Provider.is_tool_wrapper_provider(provider):
|
||||
# Import custom checks from folder
|
||||
if checks_folder:
|
||||
custom_checks = parse_checks_from_folder(global_provider, checks_folder)
|
||||
@@ -436,6 +436,20 @@ def prowler():
|
||||
output_options = ScalewayOutputOptions(
|
||||
args, bulk_checks_metadata, global_provider.identity
|
||||
)
|
||||
else:
|
||||
# Dynamic fallback: any external/custom provider
|
||||
try:
|
||||
output_options = global_provider.get_output_options(
|
||||
args, bulk_checks_metadata
|
||||
)
|
||||
except NotImplementedError:
|
||||
# No provider-specific OutputOptions: use the generic default so the
|
||||
# run still produces output instead of aborting.
|
||||
from prowler.providers.common.models import default_output_options
|
||||
|
||||
output_options = default_output_options(
|
||||
global_provider, args, bulk_checks_metadata
|
||||
)
|
||||
|
||||
# Run the quick inventory for the provider if available
|
||||
if hasattr(args, "quick_inventory") and args.quick_inventory:
|
||||
@@ -445,7 +459,7 @@ def prowler():
|
||||
# Execute checks
|
||||
findings = []
|
||||
|
||||
if provider in EXTERNAL_TOOL_PROVIDERS:
|
||||
if Provider.is_tool_wrapper_provider(provider):
|
||||
# For external-tool providers, run the scan directly
|
||||
if provider == "llm":
|
||||
|
||||
@@ -455,12 +469,19 @@ def prowler():
|
||||
|
||||
findings = global_provider.run_scan(streaming_callback=streaming_callback)
|
||||
else:
|
||||
# Original behavior for IAC and Image
|
||||
try:
|
||||
if provider == "image":
|
||||
try:
|
||||
findings = global_provider.run()
|
||||
except ImageBaseException as error:
|
||||
logger.critical(f"{error}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
# IAC and external tool-wrapper providers registered via entry
|
||||
# points. Unexpected failures propagate to the outer except
|
||||
# Exception backstop further down in this file — keeping the
|
||||
# branch free of an Image-specific catch that would otherwise
|
||||
# mislead plug-in authors reading this code.
|
||||
findings = global_provider.run()
|
||||
except ImageBaseException as error:
|
||||
logger.critical(f"{error}")
|
||||
sys.exit(1)
|
||||
# Note: External tool providers don't support granular progress tracking since
|
||||
# they run external tools as a black box and return all findings at once.
|
||||
# Progress tracking would just be 0% → 100%.
|
||||
@@ -1293,6 +1314,30 @@ def prowler():
|
||||
)
|
||||
generated_outputs["compliance"].append(generic_compliance)
|
||||
generic_compliance.batch_write_data_to_file()
|
||||
else:
|
||||
# Dynamic fallback: any external/custom provider
|
||||
try:
|
||||
global_provider.generate_compliance_output(
|
||||
finding_outputs,
|
||||
bulk_compliance_frameworks,
|
||||
input_compliance_frameworks,
|
||||
output_options,
|
||||
generated_outputs,
|
||||
)
|
||||
except NotImplementedError:
|
||||
# Last resort: generic compliance
|
||||
for compliance_name in input_compliance_frameworks:
|
||||
filename = (
|
||||
f"{output_options.output_directory}/compliance/"
|
||||
f"{output_options.output_filename}_{compliance_name}.csv"
|
||||
)
|
||||
generic_compliance = GenericCompliance(
|
||||
findings=finding_outputs,
|
||||
compliance=bulk_compliance_frameworks[compliance_name],
|
||||
file_path=filename,
|
||||
)
|
||||
generated_outputs["compliance"].append(generic_compliance)
|
||||
generic_compliance.batch_write_data_to_file()
|
||||
|
||||
# AWS Security Hub Integration
|
||||
if provider == "aws":
|
||||
|
||||
+86
-14
@@ -1,3 +1,4 @@
|
||||
import importlib.metadata
|
||||
import os
|
||||
import pathlib
|
||||
from datetime import datetime, timezone
|
||||
@@ -85,13 +86,38 @@ class Provider(str, Enum):
|
||||
actual_directory = pathlib.Path(os.path.dirname(os.path.realpath(__file__)))
|
||||
|
||||
|
||||
def _get_ep_compliance_dirs() -> dict:
|
||||
"""Discover compliance directories from entry points. Returns {provider: path}."""
|
||||
dirs = {}
|
||||
for ep in importlib.metadata.entry_points(group="prowler.compliance"):
|
||||
try:
|
||||
module = ep.load()
|
||||
if hasattr(module, "__path__"):
|
||||
dirs[ep.name] = module.__path__[0]
|
||||
elif hasattr(module, "__file__"):
|
||||
dirs[ep.name] = os.path.dirname(module.__file__)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return dirs
|
||||
|
||||
|
||||
def get_available_compliance_frameworks(provider=None):
|
||||
available_compliance_frameworks = []
|
||||
providers = [p.value for p in Provider]
|
||||
# Built-in compliance
|
||||
compliance_base = f"{actual_directory}/../compliance"
|
||||
if provider:
|
||||
providers = [provider]
|
||||
for current_provider in providers:
|
||||
compliance_dir = f"{actual_directory}/../compliance/{current_provider}"
|
||||
else:
|
||||
# Scan compliance directory for all provider subdirectories
|
||||
providers = []
|
||||
if os.path.isdir(compliance_base):
|
||||
for entry in os.scandir(compliance_base):
|
||||
if entry.is_dir():
|
||||
providers.append(entry.name)
|
||||
for prov in providers:
|
||||
compliance_dir = f"{compliance_base}/{prov}"
|
||||
if not os.path.isdir(compliance_dir):
|
||||
continue
|
||||
with os.scandir(compliance_dir) as files:
|
||||
@@ -100,7 +126,8 @@ def get_available_compliance_frameworks(provider=None):
|
||||
available_compliance_frameworks.append(
|
||||
file.name.removesuffix(".json")
|
||||
)
|
||||
# Also scan top-level compliance/ for multi-provider (universal) JSONs.
|
||||
# Built-in multi-provider frameworks at top-level compliance/ directory.
|
||||
# Placed before external entry points so built-ins win on name collisions.
|
||||
# When a specific provider was requested, only include the framework if it
|
||||
# declares support for that provider; otherwise include all universal frameworks.
|
||||
compliance_root = f"{actual_directory}/../compliance"
|
||||
@@ -117,6 +144,43 @@ def get_available_compliance_frameworks(provider=None):
|
||||
continue
|
||||
if name not in available_compliance_frameworks:
|
||||
available_compliance_frameworks.append(name)
|
||||
# External per-provider compliance via entry points.
|
||||
ep_dirs = _get_ep_compliance_dirs()
|
||||
for prov, path in ep_dirs.items():
|
||||
if provider and prov != provider:
|
||||
continue
|
||||
if os.path.isdir(path):
|
||||
for file in os.scandir(path):
|
||||
if file.is_file() and file.name.endswith(".json"):
|
||||
name = file.name.removesuffix(".json")
|
||||
if name not in available_compliance_frameworks:
|
||||
available_compliance_frameworks.append(name)
|
||||
# External multi-provider frameworks via the dedicated universal group;
|
||||
# filtered by supports_provider when a provider is given.
|
||||
for ep in importlib.metadata.entry_points(group="prowler.compliance.universal"):
|
||||
try:
|
||||
module = ep.load()
|
||||
path = (
|
||||
module.__path__[0]
|
||||
if hasattr(module, "__path__")
|
||||
else os.path.dirname(module.__file__)
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
continue
|
||||
if not os.path.isdir(path):
|
||||
continue
|
||||
for file in os.scandir(path):
|
||||
if file.is_file() and file.name.endswith(".json"):
|
||||
name = file.name.removesuffix(".json")
|
||||
if provider:
|
||||
framework = load_compliance_framework_universal(file.path)
|
||||
if framework is None or not framework.supports_provider(provider):
|
||||
continue
|
||||
if name not in available_compliance_frameworks:
|
||||
available_compliance_frameworks.append(name)
|
||||
return available_compliance_frameworks
|
||||
|
||||
|
||||
@@ -228,18 +292,26 @@ def load_and_validate_config_file(provider: str, config_file_path: str) -> dict:
|
||||
with open(config_file_path, "r", encoding=encoding_format_utf_8) as f:
|
||||
config_file = yaml.safe_load(f)
|
||||
|
||||
# Not to introduce a breaking change, allow the old format config file without any provider keys
|
||||
# and a new format with a key for each provider to include their configuration values within.
|
||||
if any(
|
||||
key in config_file
|
||||
for key in ["aws", "gcp", "azure", "kubernetes", "m365"]
|
||||
# Namespaced format: each provider has its own top-level key.
|
||||
# Works for every built-in and every external plugin without a hardcoded list.
|
||||
# Flat legacy format is AWS-only (historical, pre-multicloud). We identify it
|
||||
# by the absence of nested-dict top-level values (namespaced files always
|
||||
# have dict values; the legacy AWS format only has primitives/lists).
|
||||
if (
|
||||
isinstance(config_file, dict)
|
||||
and provider in config_file
|
||||
and isinstance(config_file[provider], dict)
|
||||
):
|
||||
config = config_file.get(provider, {})
|
||||
config = config_file.get(provider, {}) or {}
|
||||
elif (
|
||||
isinstance(config_file, dict)
|
||||
and config_file
|
||||
and provider == "aws"
|
||||
and not any(isinstance(v, dict) for v in config_file.values())
|
||||
):
|
||||
config = config_file
|
||||
else:
|
||||
config = config_file if config_file else {}
|
||||
# Not to break Azure, K8s and GCP does not support or use the old config format
|
||||
if provider in ["azure", "gcp", "kubernetes", "m365"]:
|
||||
config = {}
|
||||
config = {}
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import importlib
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -19,6 +21,7 @@ from prowler.lib.check.utils import recover_checks_from_provider
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.lib.outputs.outputs import report
|
||||
from prowler.lib.utils.utils import open_file, parse_json_file, print_boxes
|
||||
from prowler.providers.common.builtin import is_builtin_provider
|
||||
from prowler.providers.common.models import Audit_Metadata
|
||||
|
||||
|
||||
@@ -385,6 +388,45 @@ def import_check(check_path: str) -> ModuleType:
|
||||
return lib
|
||||
|
||||
|
||||
def _resolve_check_module(
|
||||
provider_type: str, service: str, check_name: str
|
||||
) -> ModuleType:
|
||||
"""Resolve and import a check module.
|
||||
|
||||
Built-in wins on CheckID collision. Plug-ins are first-class extenders
|
||||
(they can add new checks under new CheckIDs) but cannot override
|
||||
existing built-ins — a security tool prefers fail-loud predictability
|
||||
over silent overrides. CheckMetadata.get_bulk() applies the same
|
||||
precedence on the metadata side (first-write-wins) and emits a warning
|
||||
when a plug-in tries to override, so the user knows their plug-in
|
||||
duplicate is being ignored and can rename it.
|
||||
|
||||
Gates the built-in branch on `is_builtin_provider(provider_type)` —
|
||||
calling `find_spec` on `prowler.providers.{provider_type}.services...`
|
||||
directly would propagate `ModuleNotFoundError` for external providers
|
||||
(their parent package `prowler.providers.{provider_type}` does not
|
||||
exist) instead of returning None. The leaf helper encapsulates the
|
||||
safe lookup, so external providers go straight to entry points. For
|
||||
built-ins we still use `find_spec` to distinguish "check doesn't
|
||||
exist" from "check exists but failed to import" (broken transitive
|
||||
dep, etc.).
|
||||
"""
|
||||
# Built-in first — built-in wins on CheckID collision
|
||||
if is_builtin_provider(provider_type):
|
||||
builtin_path = f"prowler.providers.{provider_type}.services.{service}.{check_name}.{check_name}"
|
||||
if importlib.util.find_spec(builtin_path) is not None:
|
||||
return import_check(builtin_path)
|
||||
|
||||
# Entry point lookup — only consulted when the built-in truly doesn't exist
|
||||
for ep in importlib.metadata.entry_points(group=f"prowler.checks.{provider_type}"):
|
||||
if ep.name == check_name:
|
||||
return importlib.import_module(ep.value)
|
||||
|
||||
raise ModuleNotFoundError(
|
||||
f"Check '{check_name}' not found for provider '{provider_type}'"
|
||||
)
|
||||
|
||||
|
||||
def run_fixer(check_findings: list) -> int:
|
||||
"""
|
||||
Run the fixer for the check if it exists and there are any FAIL findings
|
||||
@@ -525,9 +567,10 @@ def execute_checks(
|
||||
service = check_name.split("_")[0]
|
||||
try:
|
||||
try:
|
||||
# Import check module
|
||||
check_module_path = f"prowler.providers.{global_provider.type}.services.{service}.{check_name}.{check_name}"
|
||||
lib = import_check(check_module_path)
|
||||
# Import check module (built-in or entry point)
|
||||
lib = _resolve_check_module(
|
||||
global_provider.type, service, check_name
|
||||
)
|
||||
# Recover functions from check
|
||||
check_to_execute = getattr(lib, check_name)
|
||||
check = check_to_execute()
|
||||
@@ -605,9 +648,10 @@ def execute_checks(
|
||||
)
|
||||
try:
|
||||
try:
|
||||
# Import check module
|
||||
check_module_path = f"prowler.providers.{global_provider.type}.services.{service}.{check_name}.{check_name}"
|
||||
lib = import_check(check_module_path)
|
||||
# Import check module (built-in or entry point)
|
||||
lib = _resolve_check_module(
|
||||
global_provider.type, service, check_name
|
||||
)
|
||||
# Recover functions from check
|
||||
check_to_execute = getattr(lib, check_name)
|
||||
check = check_to_execute()
|
||||
@@ -753,6 +797,10 @@ def execute(
|
||||
is_finding_muted_args["org_domain"] = (
|
||||
global_provider.identity.org_domain
|
||||
)
|
||||
elif not is_builtin_provider(global_provider.type):
|
||||
# External/custom provider — delegate identity args
|
||||
is_finding_muted_args = global_provider.get_mutelist_finding_args()
|
||||
|
||||
for finding in check_findings:
|
||||
if global_provider.type == "cloudflare":
|
||||
is_finding_muted_args["account_id"] = finding.account_id
|
||||
|
||||
@@ -2,10 +2,10 @@ import sys
|
||||
|
||||
from colorama import Fore, Style
|
||||
|
||||
from prowler.config.config import EXTERNAL_TOOL_PROVIDERS
|
||||
from prowler.lib.check.check import parse_checks_from_file
|
||||
from prowler.lib.check.compliance_models import Compliance
|
||||
from prowler.lib.check.models import CheckMetadata, Severity
|
||||
from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider
|
||||
from prowler.lib.logger import logger
|
||||
|
||||
|
||||
@@ -26,8 +26,13 @@ def load_checks_to_execute(
|
||||
) -> set:
|
||||
"""Generate the list of checks to execute based on the cloud provider and the input arguments given"""
|
||||
try:
|
||||
# Bypass check loading for providers that use external tools directly
|
||||
if provider in EXTERNAL_TOOL_PROVIDERS:
|
||||
# Bypass check loading for tool-wrapper providers — they delegate
|
||||
# scanning to an external tool and have no checks to recover.
|
||||
# Single source of truth across __main__, the CheckMetadata validators,
|
||||
# check discovery and this loader, covering both built-in tool wrappers
|
||||
# (iac/llm/image) and external plug-ins that declare
|
||||
# `is_external_tool_provider = True` via the contract.
|
||||
if is_tool_wrapper_provider(provider):
|
||||
return set()
|
||||
|
||||
# Local subsets
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import importlib.metadata
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
@@ -434,26 +435,63 @@ class Compliance(BaseModel):
|
||||
"""Bulk load all compliance frameworks specification into a dict"""
|
||||
try:
|
||||
bulk_compliance_frameworks = {}
|
||||
# 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}"
|
||||
)
|
||||
# for compliance_framework in available_compliance_framework_modules:
|
||||
for filename in os.listdir(compliance_specification_dir_path):
|
||||
file_path = os.path.join(
|
||||
compliance_specification_dir_path, filename
|
||||
)
|
||||
# Check if it is a file and ti size is greater than 0
|
||||
if os.path.isfile(file_path) and os.stat(file_path).st_size > 0:
|
||||
# Open Compliance file in JSON
|
||||
# cis_v1.4_aws.json --> cis_v1.4_aws
|
||||
compliance_framework_name = filename.split(".json")[0]
|
||||
# Store the compliance info
|
||||
bulk_compliance_frameworks[compliance_framework_name] = (
|
||||
load_compliance_framework(file_path)
|
||||
)
|
||||
|
||||
# External compliance via entry points
|
||||
for ep in importlib.metadata.entry_points(group="prowler.compliance"):
|
||||
if ep.name == provider:
|
||||
try:
|
||||
module = ep.load()
|
||||
compliance_dir = (
|
||||
module.__path__[0]
|
||||
if hasattr(module, "__path__")
|
||||
else os.path.dirname(module.__file__)
|
||||
)
|
||||
for filename in os.listdir(compliance_dir):
|
||||
if filename.endswith(".json"):
|
||||
file_path = os.path.join(compliance_dir, filename)
|
||||
if (
|
||||
os.path.isfile(file_path)
|
||||
and os.stat(file_path).st_size > 0
|
||||
):
|
||||
compliance_framework_name = filename.split(".json")[
|
||||
0
|
||||
]
|
||||
if (
|
||||
compliance_framework_name
|
||||
not in bulk_compliance_frameworks
|
||||
):
|
||||
# External JSON: tolerate non-legacy
|
||||
# schemas (skip + warn) instead of aborting.
|
||||
framework = load_compliance_framework(
|
||||
file_path, fatal=False
|
||||
)
|
||||
if framework is not None:
|
||||
bulk_compliance_frameworks[
|
||||
compliance_framework_name
|
||||
] = framework
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}] -- {e}")
|
||||
|
||||
@@ -462,18 +500,26 @@ class Compliance(BaseModel):
|
||||
|
||||
# Testing Pending
|
||||
def load_compliance_framework(
|
||||
compliance_specification_file: str,
|
||||
) -> Compliance:
|
||||
"""load_compliance_framework loads and parse a Compliance Framework Specification"""
|
||||
compliance_specification_file: str, fatal: bool = True
|
||||
) -> Optional[Compliance]:
|
||||
"""load_compliance_framework loads and parse a Compliance Framework Specification.
|
||||
|
||||
With ``fatal=True`` (built-in JSONs) an invalid file aborts the run; with
|
||||
``fatal=False`` (external JSONs) it is skipped with a warning and ``None``
|
||||
is returned.
|
||||
"""
|
||||
try:
|
||||
compliance_framework = Compliance.parse_file(compliance_specification_file)
|
||||
return Compliance.parse_file(compliance_specification_file)
|
||||
except ValidationError as error:
|
||||
logger.critical(
|
||||
f"Compliance Framework Specification from {compliance_specification_file} is not valid: {error}"
|
||||
if fatal:
|
||||
logger.critical(
|
||||
f"Compliance Framework Specification from {compliance_specification_file} is not valid: {error}"
|
||||
)
|
||||
sys.exit(1)
|
||||
logger.warning(
|
||||
f"Skipping invalid compliance framework {compliance_specification_file}: {error}"
|
||||
)
|
||||
sys.exit(1)
|
||||
else:
|
||||
return compliance_framework
|
||||
return None
|
||||
|
||||
|
||||
# ─── Universal Compliance Schema Models (Phase 1-3) ─────────────────────────
|
||||
@@ -950,6 +996,25 @@ def get_bulk_compliance_frameworks_universal(provider: str) -> dict:
|
||||
if compliance_root and os.path.isdir(compliance_root):
|
||||
_load_jsons_from_dir(compliance_root, provider, bulk)
|
||||
|
||||
# External multi-provider frameworks via the dedicated universal entry
|
||||
# point group, kept separate from the per-provider `prowler.compliance`
|
||||
# group so the legacy loader never parses a universal JSON. Built-ins
|
||||
# (already in bulk) win on a name collision.
|
||||
for ep in importlib.metadata.entry_points(group="prowler.compliance.universal"):
|
||||
try:
|
||||
module = ep.load()
|
||||
ep_dir = (
|
||||
module.__path__[0]
|
||||
if hasattr(module, "__path__")
|
||||
else os.path.dirname(module.__file__)
|
||||
)
|
||||
if os.path.isdir(ep_dir):
|
||||
_load_jsons_from_dir(ep_dir, provider, bulk)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}] -- {e}")
|
||||
return bulk
|
||||
|
||||
+37
-12
@@ -11,10 +11,10 @@ from typing import Any, Dict, Optional, Set
|
||||
from pydantic.v1 import BaseModel, Field, ValidationError, validator
|
||||
from pydantic.v1.error_wrappers import ErrorWrapper
|
||||
|
||||
from prowler.config.config import EXTERNAL_TOOL_PROVIDERS, Provider
|
||||
from prowler.lib.check.compliance_models import Compliance
|
||||
from prowler.lib.check.utils import recover_checks_from_provider
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.common.provider import Provider as ProviderABC
|
||||
|
||||
# Valid ResourceGroup values as defined in the RFC
|
||||
VALID_RESOURCE_GROUPS = frozenset(
|
||||
@@ -259,7 +259,7 @@ class CheckMetadata(BaseModel):
|
||||
)
|
||||
if (
|
||||
value_lower not in VALID_CATEGORIES
|
||||
and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS
|
||||
and not ProviderABC.is_tool_wrapper_provider(values.get("Provider"))
|
||||
):
|
||||
raise ValueError(
|
||||
f"Invalid category: '{value_lower}'. Must be one of: {', '.join(sorted(VALID_CATEGORIES))}."
|
||||
@@ -288,7 +288,9 @@ class CheckMetadata(BaseModel):
|
||||
raise ValueError("ServiceName must be a non-empty string")
|
||||
|
||||
check_id = values.get("CheckID")
|
||||
if check_id and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
|
||||
if check_id and not ProviderABC.is_tool_wrapper_provider(
|
||||
values.get("Provider")
|
||||
):
|
||||
service_from_check_id = check_id.split("_")[0]
|
||||
if service_name != service_from_check_id:
|
||||
raise ValueError(
|
||||
@@ -304,7 +306,9 @@ class CheckMetadata(BaseModel):
|
||||
if not check_id:
|
||||
raise ValueError("CheckID must be a non-empty string")
|
||||
|
||||
if check_id and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
|
||||
if check_id and not ProviderABC.is_tool_wrapper_provider(
|
||||
values.get("Provider")
|
||||
):
|
||||
if "-" in check_id:
|
||||
raise ValueError(
|
||||
f"CheckID {check_id} contains a hyphen, which is not allowed"
|
||||
@@ -313,8 +317,9 @@ class CheckMetadata(BaseModel):
|
||||
return check_id
|
||||
|
||||
@validator("CheckTitle", pre=True, always=True)
|
||||
@classmethod
|
||||
def validate_check_title(cls, check_title, values): # noqa: F841
|
||||
if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
|
||||
if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")):
|
||||
if len(check_title) > 150:
|
||||
raise ValueError(
|
||||
f"CheckTitle must not exceed 150 characters, got {len(check_title)} characters"
|
||||
@@ -326,14 +331,18 @@ class CheckMetadata(BaseModel):
|
||||
return check_title
|
||||
|
||||
@validator("RelatedUrl", pre=True, always=True)
|
||||
@classmethod
|
||||
def validate_related_url(cls, related_url, values): # noqa: F841
|
||||
if related_url and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
|
||||
if related_url and not ProviderABC.is_tool_wrapper_provider(
|
||||
values.get("Provider")
|
||||
):
|
||||
raise ValueError("RelatedUrl must be empty. This field is deprecated.")
|
||||
return related_url
|
||||
|
||||
@validator("Remediation")
|
||||
@classmethod
|
||||
def validate_recommendation_url(cls, remediation, values): # noqa: F841
|
||||
if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
|
||||
if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")):
|
||||
url = remediation.Recommendation.Url
|
||||
if url and not url.startswith("https://hub.prowler.com/"):
|
||||
raise ValueError(
|
||||
@@ -346,7 +355,7 @@ class CheckMetadata(BaseModel):
|
||||
provider = values.get("Provider", "").lower()
|
||||
|
||||
# Non-AWS providers must have an empty CheckType list
|
||||
if provider != "aws" and provider not in EXTERNAL_TOOL_PROVIDERS:
|
||||
if provider != "aws" and not ProviderABC.is_tool_wrapper_provider(provider):
|
||||
if check_type:
|
||||
raise ValueError(
|
||||
f"CheckType must be empty for non-AWS providers. Got {check_type} for provider '{provider}'."
|
||||
@@ -371,8 +380,9 @@ class CheckMetadata(BaseModel):
|
||||
return check_type
|
||||
|
||||
@validator("Description", pre=True, always=True)
|
||||
@classmethod
|
||||
def validate_description(cls, description, values): # noqa: F841
|
||||
if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
|
||||
if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")):
|
||||
if len(description) > 400:
|
||||
raise ValueError(
|
||||
f"Description must not exceed 400 characters, got {len(description)} characters"
|
||||
@@ -380,8 +390,9 @@ class CheckMetadata(BaseModel):
|
||||
return description
|
||||
|
||||
@validator("Risk", pre=True, always=True)
|
||||
@classmethod
|
||||
def validate_risk(cls, risk, values): # noqa: F841
|
||||
if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
|
||||
if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")):
|
||||
if len(risk) > 400:
|
||||
raise ValueError(
|
||||
f"Risk must not exceed 400 characters, got {len(risk)} characters"
|
||||
@@ -433,6 +444,20 @@ class CheckMetadata(BaseModel):
|
||||
metadata_file = f"{check_path}/{check_name}.metadata.json"
|
||||
# Load metadata
|
||||
check_metadata = load_check_metadata(metadata_file)
|
||||
# Built-in wins on CheckID collision. Plug-in entry points are
|
||||
# appended after built-ins by `recover_checks_from_provider`, so
|
||||
# a duplicate CheckID here means an entry-point check is trying
|
||||
# to override a built-in. Ignore the override (the built-in
|
||||
# metadata stays) and surface it via a warning — matching the
|
||||
# precedence enforced by `_resolve_check_module`.
|
||||
if check_metadata.CheckID in bulk_check_metadata:
|
||||
logger.warning(
|
||||
f"Plug-in check metadata '{check_metadata.CheckID}' "
|
||||
f"(loaded from '{metadata_file}') is being IGNORED — "
|
||||
f"a built-in with the same CheckID exists. To use your "
|
||||
f"plug-in, register it under a different CheckID."
|
||||
)
|
||||
continue
|
||||
bulk_check_metadata[check_metadata.CheckID] = check_metadata
|
||||
|
||||
return bulk_check_metadata
|
||||
@@ -470,7 +495,7 @@ class CheckMetadata(BaseModel):
|
||||
# If the bulk checks metadata is not provided, get it
|
||||
if not bulk_checks_metadata:
|
||||
bulk_checks_metadata = {}
|
||||
available_providers = [p.value for p in Provider]
|
||||
available_providers = ProviderABC.get_available_providers()
|
||||
for provider_name in available_providers:
|
||||
bulk_checks_metadata.update(CheckMetadata.get_bulk(provider_name))
|
||||
if provider:
|
||||
@@ -495,7 +520,7 @@ class CheckMetadata(BaseModel):
|
||||
# Loaded here, as it is not always needed
|
||||
if not bulk_compliance_frameworks:
|
||||
bulk_compliance_frameworks = {}
|
||||
available_providers = [p.value for p in Provider]
|
||||
available_providers = ProviderABC.get_available_providers()
|
||||
for provider in available_providers:
|
||||
bulk_compliance_frameworks = Compliance.get_bulk(provider=provider)
|
||||
checks_from_compliance_framework = (
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Standalone helper for tool-wrapper provider detection.
|
||||
|
||||
A provider is a "tool wrapper" if it delegates scanning to an external tool
|
||||
(Trivy, promptfoo, etc.) instead of running checks/services through the
|
||||
standard Prowler engine. This module is the single source of truth for that
|
||||
classification across the codebase.
|
||||
|
||||
Kept as a leaf module with no Prowler imports beyond the leaf
|
||||
`external_tool_providers` so it can be referenced from `prowler.lib.check.*`
|
||||
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
|
||||
# of a duplicate cache entry is negligible (one class object per external
|
||||
# provider, loaded lazily on first lookup).
|
||||
_ep_class_cache: dict = {}
|
||||
|
||||
|
||||
def _load_ep_class(provider: str):
|
||||
"""Return the entry-point provider class for `provider`, or None.
|
||||
|
||||
Caches the result in `_ep_class_cache`. Errors during entry-point loading
|
||||
are swallowed (returning None) so a broken plug-in never crashes the
|
||||
is-tool-wrapper check; it just falls through to "not a tool wrapper".
|
||||
"""
|
||||
if provider in _ep_class_cache:
|
||||
return _ep_class_cache[provider]
|
||||
for ep in importlib.metadata.entry_points(group="prowler.providers"):
|
||||
if ep.name == provider:
|
||||
try:
|
||||
cls = ep.load()
|
||||
except Exception:
|
||||
cls = None
|
||||
_ep_class_cache[provider] = cls
|
||||
return cls
|
||||
_ep_class_cache[provider] = None
|
||||
return None
|
||||
|
||||
|
||||
def is_tool_wrapper_provider(provider: str) -> bool:
|
||||
"""Return True if the provider delegates scanning to an external tool.
|
||||
|
||||
Combines the built-in `EXTERNAL_TOOL_PROVIDERS` frozenset (fast path for
|
||||
iac/llm/image) with the `is_external_tool_provider` class attribute of
|
||||
external plug-ins registered via entry points. This is the single source
|
||||
of truth consulted by `__main__`, the `CheckMetadata` validators, the
|
||||
check-loading utilities, and the checks loader.
|
||||
"""
|
||||
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))
|
||||
+84
-23
@@ -1,9 +1,43 @@
|
||||
import importlib
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from pkgutil import walk_packages
|
||||
|
||||
from prowler.lib.check.external_tool_providers import EXTERNAL_TOOL_PROVIDERS
|
||||
from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.common.builtin import is_builtin_provider
|
||||
|
||||
|
||||
def _recover_ep_checks(provider: str, service: str = None) -> list[tuple]:
|
||||
"""Discover external checks registered via entry points for a provider.
|
||||
|
||||
External plugins follow the same layout as built-ins:
|
||||
`{plugin_root}.services.{service}.{check}.{check}`
|
||||
|
||||
When `service` is provided, only entry points whose dotted path contains
|
||||
`.services.{service}.` are included — mirroring how built-in discovery
|
||||
filters by the `prowler.providers.{provider}.services.{service}` package.
|
||||
|
||||
Uses find_spec to locate the check module without importing it,
|
||||
avoiding service client initialization at discovery time.
|
||||
"""
|
||||
checks = []
|
||||
for ep in importlib.metadata.entry_points(group=f"prowler.checks.{provider}"):
|
||||
try:
|
||||
if service and f".services.{service}." not in ep.value:
|
||||
continue
|
||||
|
||||
spec = importlib.util.find_spec(ep.value)
|
||||
if spec and spec.origin:
|
||||
check_path = os.path.dirname(spec.origin)
|
||||
checks.append((ep.name, check_path))
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return checks
|
||||
|
||||
|
||||
def recover_checks_from_provider(
|
||||
@@ -15,29 +49,55 @@ def recover_checks_from_provider(
|
||||
Returns a list of tuples with the following format (check_name, check_path)
|
||||
"""
|
||||
try:
|
||||
# Bypass check loading for providers that use external tools directly
|
||||
if provider in EXTERNAL_TOOL_PROVIDERS:
|
||||
# Bypass check loading for tool-wrapper providers — they delegate
|
||||
# scanning to an external tool and have no checks to recover.
|
||||
# Single source of truth: combines the EXTERNAL_TOOL_PROVIDERS
|
||||
# frozenset (built-ins) with the per-provider `is_external_tool_provider`
|
||||
# class attribute (so external plug-ins opt in via the contract).
|
||||
if is_tool_wrapper_provider(provider):
|
||||
return []
|
||||
|
||||
checks = []
|
||||
modules = list_modules(provider, service)
|
||||
for module_name in modules:
|
||||
# Format: "prowler.providers.{provider}.services.{service}.{check_name}.{check_name}"
|
||||
check_module_name = module_name.name
|
||||
# We need to exclude common shared libraries in services
|
||||
if (
|
||||
check_module_name.count(".") == 6
|
||||
and ".lib." not in check_module_name
|
||||
and (not check_module_name.endswith("_fixer") or include_fixers)
|
||||
):
|
||||
check_path = module_name.module_finder.path
|
||||
# Check name is the last part of the check_module_name
|
||||
check_name = check_module_name.split(".")[-1]
|
||||
check_info = (check_name, check_path)
|
||||
checks.append(check_info)
|
||||
except ModuleNotFoundError:
|
||||
logger.critical(f"Service {service} was not found for the {provider} provider.")
|
||||
sys.exit(1)
|
||||
# Built-in checks from prowler.providers.{provider}.services. Gate
|
||||
# the built-in branch on `is_builtin_provider(provider)` — calling
|
||||
# `find_spec` directly on `prowler.providers.{provider}.services`
|
||||
# would propagate `ModuleNotFoundError` when the parent package
|
||||
# `prowler.providers.{provider}` does not exist (i.e. the provider
|
||||
# is external), instead of returning None. The leaf helper
|
||||
# encapsulates the safe lookup, so we only run the built-in
|
||||
# discovery when the provider actually ships with the SDK; for
|
||||
# external providers we go straight to entry points.
|
||||
if is_builtin_provider(provider):
|
||||
modules = list_modules(provider, service)
|
||||
for module_name in modules:
|
||||
# Format: "prowler.providers.{provider}.services.{service}.{check_name}.{check_name}"
|
||||
check_module_name = module_name.name
|
||||
# We need to exclude common shared libraries in services
|
||||
if (
|
||||
check_module_name.count(".") == 6
|
||||
and ".lib." not in check_module_name
|
||||
and (not check_module_name.endswith("_fixer") or include_fixers)
|
||||
):
|
||||
check_path = module_name.module_finder.path
|
||||
check_name = check_module_name.split(".")[-1]
|
||||
check_info = (check_name, check_path)
|
||||
checks.append(check_info)
|
||||
|
||||
# External checks registered via entry points — always consulted, with
|
||||
# optional service filter. Previously gated by `if not service:`, which
|
||||
# prevented external providers from being usable with --service.
|
||||
checks.extend(_recover_ep_checks(provider, service))
|
||||
|
||||
# A service was requested but nothing matched in either built-ins or
|
||||
# entry points — surface this as a clear error instead of silently
|
||||
# returning an empty list.
|
||||
if service and not checks:
|
||||
logger.critical(
|
||||
f"Service '{service}' was not found for the '{provider}' provider "
|
||||
f"(neither as a built-in nor via external entry points)."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
logger.critical(f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}]: {e}")
|
||||
sys.exit(1)
|
||||
@@ -64,8 +124,9 @@ def recover_checks_from_service(service_list: list, provider: str) -> set:
|
||||
Returns a set of checks from the given services
|
||||
"""
|
||||
try:
|
||||
# Bypass check loading for providers that use external tools directly
|
||||
if provider in EXTERNAL_TOOL_PROVIDERS:
|
||||
# Bypass check loading for tool-wrapper providers — symmetric with
|
||||
# `recover_checks_from_provider` above, using the same source of truth.
|
||||
if is_tool_wrapper_provider(provider):
|
||||
return set()
|
||||
|
||||
checks = set()
|
||||
|
||||
@@ -20,19 +20,61 @@ from prowler.providers.common.arguments import (
|
||||
validate_provider_arguments,
|
||||
validate_sarif_usage,
|
||||
)
|
||||
from prowler.providers.common.provider import Provider
|
||||
|
||||
|
||||
class ProwlerArgumentParser:
|
||||
# Set the default parser
|
||||
def __init__(self):
|
||||
# Discover any providers not in the hardcoded list below
|
||||
# TODO - First step to support current providers and the new external provider implementation
|
||||
known_providers = {
|
||||
"aws",
|
||||
"azure",
|
||||
"gcp",
|
||||
"kubernetes",
|
||||
"m365",
|
||||
"github",
|
||||
"googleworkspace",
|
||||
"cloudflare",
|
||||
"oraclecloud",
|
||||
"openstack",
|
||||
"alibabacloud",
|
||||
"iac",
|
||||
"llm",
|
||||
"image",
|
||||
"nhn",
|
||||
"mongodbatlas",
|
||||
"vercel",
|
||||
"okta",
|
||||
"scaleway",
|
||||
"stackit",
|
||||
}
|
||||
all_providers = set(Provider.get_available_providers())
|
||||
new_providers = sorted(all_providers - known_providers)
|
||||
|
||||
# Build extra strings for dynamically discovered providers
|
||||
extra_providers_csv = ""
|
||||
extra_providers_text = ""
|
||||
if new_providers:
|
||||
providers_help = Provider.get_providers_help_text()
|
||||
extra_providers_csv = "," + ",".join(new_providers)
|
||||
extra_lines = []
|
||||
for name in new_providers:
|
||||
help_text = providers_help.get(name, "")
|
||||
if help_text:
|
||||
extra_lines.append(f" {name:<20}{help_text}")
|
||||
if extra_lines:
|
||||
extra_providers_text = "\n" + "\n".join(extra_lines)
|
||||
|
||||
# CLI Arguments
|
||||
self.parser = argparse.ArgumentParser(
|
||||
prog="prowler",
|
||||
formatter_class=RawTextHelpFormatter,
|
||||
usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,dashboard,iac,image,llm} ...",
|
||||
epilog="""
|
||||
usage=f"prowler [-h] [--version] {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,dashboard,iac,image,llm{extra_providers_csv}}} ...",
|
||||
epilog=f"""
|
||||
Available Cloud Providers:
|
||||
{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel}
|
||||
{{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel{extra_providers_csv}}}
|
||||
aws AWS Provider
|
||||
azure Azure Provider
|
||||
gcp GCP Provider
|
||||
@@ -52,13 +94,14 @@ Available Cloud Providers:
|
||||
nhn NHN Provider (Unofficial)
|
||||
mongodbatlas MongoDB Atlas Provider
|
||||
scaleway Scaleway Provider
|
||||
vercel Vercel Provider
|
||||
vercel Vercel Provider{extra_providers_text}
|
||||
|
||||
|
||||
Available components:
|
||||
dashboard Local dashboard
|
||||
|
||||
To see the different available options on a specific component, run:
|
||||
prowler {provider|dashboard} -h|--help
|
||||
prowler {{provider|dashboard}} -h|--help
|
||||
|
||||
Detailed documentation at https://docs.prowler.com
|
||||
""",
|
||||
@@ -117,8 +160,10 @@ Detailed documentation at https://docs.prowler.com
|
||||
and (sys.argv[1] not in ("-v", "--version"))
|
||||
):
|
||||
# Since the provider is always the second argument, we are checking if
|
||||
# a flag, starting by "-", is supplied
|
||||
if "-" in sys.argv[1]:
|
||||
# a flag is supplied. Use startswith("-") instead of "in" to avoid
|
||||
# matching external provider names that contain hyphens
|
||||
# (e.g. "local-acme-snowflake").
|
||||
if sys.argv[1].startswith("-"):
|
||||
sys.argv = self.__set_default_provider__(sys.argv)
|
||||
|
||||
# Provider aliases mapping
|
||||
|
||||
@@ -253,14 +253,32 @@ def display_compliance_table(
|
||||
compliance_overview,
|
||||
)
|
||||
else:
|
||||
get_generic_compliance_table(
|
||||
findings,
|
||||
bulk_checks_metadata,
|
||||
compliance_framework,
|
||||
output_filename,
|
||||
output_directory,
|
||||
compliance_overview,
|
||||
)
|
||||
# Try provider-specific table first, fall back to generic
|
||||
from prowler.providers.common.provider import Provider
|
||||
|
||||
provider = Provider.get_global_provider()
|
||||
handled = False
|
||||
if provider is not None:
|
||||
try:
|
||||
handled = provider.display_compliance_table(
|
||||
findings,
|
||||
bulk_checks_metadata,
|
||||
compliance_framework,
|
||||
output_filename,
|
||||
output_directory,
|
||||
compliance_overview,
|
||||
)
|
||||
except NotImplementedError:
|
||||
handled = False
|
||||
if not handled:
|
||||
get_generic_compliance_table(
|
||||
findings,
|
||||
bulk_checks_metadata,
|
||||
compliance_framework,
|
||||
output_filename,
|
||||
output_directory,
|
||||
compliance_overview,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.critical(
|
||||
f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}"
|
||||
|
||||
@@ -34,60 +34,48 @@ class GenericCompliance(ComplianceOutput):
|
||||
Returns:
|
||||
- None
|
||||
"""
|
||||
|
||||
def compliance_row(requirement, attribute, finding=None):
|
||||
# Read attribute fields defensively: GenericCompliance is the
|
||||
# last-resort renderer for any framework, and provider-specific
|
||||
# schemas (e.g. CIS, ENS, ISO27001) do not declare the universal
|
||||
# Section/SubSection/SubGroup/Service/Type/Comment fields.
|
||||
return GenericComplianceModel(
|
||||
Provider=(finding.provider if finding else compliance.Provider.lower()),
|
||||
Description=compliance.Description,
|
||||
AccountId=finding.account_uid if finding else "",
|
||||
Region=finding.region if finding else "",
|
||||
AssessmentDate=str(timestamp),
|
||||
Requirements_Id=requirement.Id,
|
||||
Requirements_Description=requirement.Description,
|
||||
Requirements_Attributes_Section=getattr(attribute, "Section", None),
|
||||
Requirements_Attributes_SubSection=getattr(
|
||||
attribute, "SubSection", None
|
||||
),
|
||||
Requirements_Attributes_SubGroup=getattr(attribute, "SubGroup", None),
|
||||
Requirements_Attributes_Service=getattr(attribute, "Service", None),
|
||||
Requirements_Attributes_Type=getattr(attribute, "Type", None),
|
||||
Requirements_Attributes_Comment=getattr(attribute, "Comment", None),
|
||||
Status=finding.status if finding else "MANUAL",
|
||||
StatusExtended=(finding.status_extended if finding else "Manual check"),
|
||||
ResourceId=finding.resource_uid if finding else "manual_check",
|
||||
ResourceName=finding.resource_name if finding else "Manual check",
|
||||
CheckId=finding.check_id if finding else "manual",
|
||||
Muted=finding.muted if finding else False,
|
||||
Framework=compliance.Framework,
|
||||
Name=compliance.Name,
|
||||
)
|
||||
|
||||
for finding in findings:
|
||||
for requirement in compliance.Requirements:
|
||||
# Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift).
|
||||
if finding.check_id in requirement.Checks:
|
||||
for attribute in requirement.Attributes:
|
||||
compliance_row = GenericComplianceModel(
|
||||
Provider=finding.provider,
|
||||
Description=compliance.Description,
|
||||
AccountId=finding.account_uid,
|
||||
Region=finding.region,
|
||||
AssessmentDate=str(timestamp),
|
||||
Requirements_Id=requirement.Id,
|
||||
Requirements_Description=requirement.Description,
|
||||
Requirements_Attributes_Section=attribute.Section,
|
||||
Requirements_Attributes_SubSection=attribute.SubSection,
|
||||
Requirements_Attributes_SubGroup=attribute.SubGroup,
|
||||
Requirements_Attributes_Service=attribute.Service,
|
||||
Requirements_Attributes_Type=attribute.Type,
|
||||
Requirements_Attributes_Comment=attribute.Comment,
|
||||
Status=finding.status,
|
||||
StatusExtended=finding.status_extended,
|
||||
ResourceId=finding.resource_uid,
|
||||
ResourceName=finding.resource_name,
|
||||
CheckId=finding.check_id,
|
||||
Muted=finding.muted,
|
||||
Framework=compliance.Framework,
|
||||
Name=compliance.Name,
|
||||
self._data.append(
|
||||
compliance_row(requirement, attribute, finding)
|
||||
)
|
||||
self._data.append(compliance_row)
|
||||
# Add manual requirements to the compliance output
|
||||
for requirement in compliance.Requirements:
|
||||
if not requirement.Checks:
|
||||
for attribute in requirement.Attributes:
|
||||
compliance_row = GenericComplianceModel(
|
||||
Provider=compliance.Provider.lower(),
|
||||
Description=compliance.Description,
|
||||
AccountId="",
|
||||
Region="",
|
||||
AssessmentDate=str(timestamp),
|
||||
Requirements_Id=requirement.Id,
|
||||
Requirements_Description=requirement.Description,
|
||||
Requirements_Attributes_Section=attribute.Section,
|
||||
Requirements_Attributes_SubSection=attribute.SubSection,
|
||||
Requirements_Attributes_SubGroup=attribute.SubGroup,
|
||||
Requirements_Attributes_Service=attribute.Service,
|
||||
Requirements_Attributes_Type=attribute.Type,
|
||||
Requirements_Attributes_Comment=attribute.Comment,
|
||||
Status="MANUAL",
|
||||
StatusExtended="Manual check",
|
||||
ResourceId="manual_check",
|
||||
ResourceName="Manual check",
|
||||
CheckId="manual",
|
||||
Muted=False,
|
||||
Framework=compliance.Framework,
|
||||
Name=compliance.Name,
|
||||
)
|
||||
self._data.append(compliance_row)
|
||||
self._data.append(compliance_row(requirement, attribute))
|
||||
|
||||
@@ -517,6 +517,11 @@ class Finding(BaseModel):
|
||||
check_output, "fixed_version", ""
|
||||
)
|
||||
|
||||
else:
|
||||
# Dynamic fallback: any external/custom provider
|
||||
provider_data = provider.get_finding_output_data(check_output)
|
||||
output_data.update(provider_data)
|
||||
|
||||
# check_output Unique ID
|
||||
# TODO: move this to a function
|
||||
# TODO: in Azure, GCP and K8s there are findings without resource_name
|
||||
|
||||
@@ -1608,11 +1608,13 @@ class HTML(Output):
|
||||
# Azure_provider --> azure
|
||||
# Kubernetes_provider --> kubernetes
|
||||
|
||||
# Dynamically get the Provider quick inventory handler
|
||||
provider_html_assessment_summary_function = (
|
||||
f"get_{provider.type}_assessment_summary"
|
||||
)
|
||||
return getattr(HTML, provider_html_assessment_summary_function)(provider)
|
||||
# Try static method first, fall back to provider method
|
||||
method_name = f"get_{provider.type}_assessment_summary"
|
||||
if hasattr(HTML, method_name):
|
||||
return getattr(HTML, method_name)(provider)
|
||||
else:
|
||||
# Dynamic fallback: any external/custom provider
|
||||
return provider.get_html_assessment_summary()
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
|
||||
|
||||
@@ -7,45 +7,52 @@ from prowler.lib.outputs.common import Status
|
||||
from prowler.lib.outputs.finding import Finding
|
||||
|
||||
|
||||
def stdout_report(finding, color, verbose, status, fix):
|
||||
def stdout_report(finding, color, verbose, status, fix, provider=None):
|
||||
if finding.check_metadata.Provider == "aws":
|
||||
details = finding.region
|
||||
if finding.check_metadata.Provider == "azure":
|
||||
elif finding.check_metadata.Provider == "azure":
|
||||
details = finding.location
|
||||
if finding.check_metadata.Provider == "gcp":
|
||||
elif finding.check_metadata.Provider == "gcp":
|
||||
details = finding.location.lower()
|
||||
if finding.check_metadata.Provider == "kubernetes":
|
||||
elif finding.check_metadata.Provider == "kubernetes":
|
||||
details = finding.namespace.lower()
|
||||
if finding.check_metadata.Provider == "github":
|
||||
elif finding.check_metadata.Provider == "github":
|
||||
details = finding.owner
|
||||
if finding.check_metadata.Provider == "m365":
|
||||
elif finding.check_metadata.Provider == "m365":
|
||||
details = finding.location
|
||||
if finding.check_metadata.Provider == "mongodbatlas":
|
||||
elif finding.check_metadata.Provider == "mongodbatlas":
|
||||
details = finding.location
|
||||
if finding.check_metadata.Provider == "nhn":
|
||||
elif finding.check_metadata.Provider == "nhn":
|
||||
details = finding.location
|
||||
if finding.check_metadata.Provider == "stackit":
|
||||
elif finding.check_metadata.Provider == "stackit":
|
||||
details = finding.location
|
||||
if finding.check_metadata.Provider == "llm":
|
||||
elif finding.check_metadata.Provider == "llm":
|
||||
details = finding.check_metadata.CheckID
|
||||
if finding.check_metadata.Provider == "iac":
|
||||
elif finding.check_metadata.Provider == "iac":
|
||||
details = finding.check_metadata.CheckID
|
||||
if finding.check_metadata.Provider == "oraclecloud":
|
||||
elif finding.check_metadata.Provider == "oraclecloud":
|
||||
details = finding.region
|
||||
if finding.check_metadata.Provider == "alibabacloud":
|
||||
elif finding.check_metadata.Provider == "alibabacloud":
|
||||
details = finding.region
|
||||
if finding.check_metadata.Provider == "openstack":
|
||||
elif finding.check_metadata.Provider == "openstack":
|
||||
details = finding.region
|
||||
if finding.check_metadata.Provider == "cloudflare":
|
||||
elif finding.check_metadata.Provider == "cloudflare":
|
||||
details = finding.zone_name
|
||||
if finding.check_metadata.Provider == "googleworkspace":
|
||||
elif finding.check_metadata.Provider == "googleworkspace":
|
||||
details = finding.location
|
||||
if finding.check_metadata.Provider == "vercel":
|
||||
elif finding.check_metadata.Provider == "vercel":
|
||||
details = finding.region
|
||||
if finding.check_metadata.Provider == "okta":
|
||||
elif finding.check_metadata.Provider == "okta":
|
||||
details = finding.region
|
||||
if finding.check_metadata.Provider == "scaleway":
|
||||
elif finding.check_metadata.Provider == "scaleway":
|
||||
details = finding.region
|
||||
else:
|
||||
# Dynamic fallback: any external/custom provider
|
||||
if provider is None:
|
||||
from prowler.providers.common.provider import Provider
|
||||
|
||||
provider = Provider.get_global_provider()
|
||||
details = provider.get_stdout_detail(finding)
|
||||
|
||||
if (verbose or fix) and (not status or finding.status in status):
|
||||
if finding.muted:
|
||||
@@ -65,12 +72,15 @@ def report(check_findings, provider, output_options):
|
||||
if hasattr(output_options, "verbose"):
|
||||
verbose = output_options.verbose
|
||||
if check_findings:
|
||||
# TO-DO Generic Function
|
||||
if provider.type == "aws":
|
||||
check_findings.sort(key=lambda x: x.region)
|
||||
|
||||
if provider.type == "azure":
|
||||
elif provider.type == "azure":
|
||||
check_findings.sort(key=lambda x: x.subscription)
|
||||
else:
|
||||
# Dynamic fallback: any external/custom provider
|
||||
sort_key = provider.get_finding_sort_key()
|
||||
if sort_key and isinstance(sort_key, str):
|
||||
check_findings.sort(key=lambda x: getattr(x, sort_key, ""))
|
||||
|
||||
for finding in check_findings:
|
||||
# Print findings by stdout
|
||||
@@ -81,12 +91,16 @@ def report(check_findings, provider, output_options):
|
||||
if hasattr(output_options, "fixer"):
|
||||
fixer = output_options.fixer
|
||||
color = set_report_color(finding.status, finding.muted)
|
||||
# Pass the local `provider` through so the dynamic else inside
|
||||
# `stdout_report` does not have to consult the global singleton
|
||||
# — defeating the whole purpose of the new parameter.
|
||||
stdout_report(
|
||||
finding,
|
||||
color,
|
||||
verbose,
|
||||
status,
|
||||
fixer,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
else: # No service resources in the whole account
|
||||
|
||||
@@ -121,6 +121,9 @@ def display_summary_table(
|
||||
elif provider.type == "scaleway":
|
||||
entity_type = "Organization"
|
||||
audited_entities = provider.identity.organization_id
|
||||
else:
|
||||
# Dynamic fallback: any external/custom provider
|
||||
entity_type, audited_entities = provider.get_summary_entity()
|
||||
|
||||
# Check if there are findings and that they are not all MANUAL
|
||||
if findings and not all(finding.status == "MANUAL" for finding in findings):
|
||||
|
||||
@@ -4,8 +4,8 @@ from types import SimpleNamespace
|
||||
from typing import Generator
|
||||
|
||||
from prowler.lib.check.check import (
|
||||
_resolve_check_module,
|
||||
execute,
|
||||
import_check,
|
||||
list_services,
|
||||
update_audit_metadata,
|
||||
)
|
||||
@@ -426,9 +426,14 @@ class Scan:
|
||||
# Recover service from check name
|
||||
service = get_service_name_from_check_name(check_name)
|
||||
try:
|
||||
# Import check module
|
||||
check_module_path = f"prowler.providers.{self._provider.type}.services.{service}.{check_name}.{check_name}"
|
||||
lib = import_check(check_module_path)
|
||||
# Import check module (built-in or entry point) —
|
||||
# delegates to `_resolve_check_module` so external
|
||||
# providers registered via entry points are resolved
|
||||
# correctly (their checks do not live under
|
||||
# `prowler.providers.{type}.services...`).
|
||||
lib = _resolve_check_module(
|
||||
self._provider.type, service, check_name
|
||||
)
|
||||
# Recover functions from check
|
||||
check_to_execute = getattr(lib, check_name)
|
||||
check = check_to_execute()
|
||||
|
||||
@@ -16,18 +16,41 @@ def init_providers_parser(self):
|
||||
# We need to call the arguments parser for each provider
|
||||
providers = Provider.get_available_providers()
|
||||
for provider in providers:
|
||||
try:
|
||||
getattr(
|
||||
import_module(
|
||||
f"{providers_path}.{provider}.{provider_arguments_lib_path}"
|
||||
),
|
||||
init_provider_arguments_function,
|
||||
)(self)
|
||||
except Exception as error:
|
||||
logger.critical(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
sys.exit(1)
|
||||
# Discriminate built-in vs external upfront via find_spec, so an
|
||||
# ImportError from a transitive dependency missing inside a built-in
|
||||
# arguments module surfaces clearly instead of being silently
|
||||
# re-routed to the entry-point path (which only has external providers).
|
||||
if Provider.is_builtin(provider):
|
||||
try:
|
||||
getattr(
|
||||
import_module(
|
||||
f"{providers_path}.{provider}.{provider_arguments_lib_path}"
|
||||
),
|
||||
init_provider_arguments_function,
|
||||
)(self)
|
||||
except ImportError as e:
|
||||
logger.critical(
|
||||
f"Failed to load arguments for built-in provider '{provider}'. "
|
||||
f"Missing dependency: {e}. "
|
||||
f"Ensure all required dependencies are installed."
|
||||
)
|
||||
logger.debug("Full traceback:", exc_info=True)
|
||||
sys.exit(1)
|
||||
except Exception as error:
|
||||
logger.critical(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
sys.exit(1)
|
||||
else:
|
||||
# External provider — init_parser classmethod via entry point
|
||||
cls = Provider._load_ep_provider(provider)
|
||||
if cls and hasattr(cls, "init_parser"):
|
||||
try:
|
||||
cls.init_parser(self)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
|
||||
def validate_provider_arguments(arguments: Namespace) -> tuple[bool, str]:
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Leaf helper for built-in provider detection.
|
||||
|
||||
Lives in its own module — with no imports back into `prowler.lib.check` — so
|
||||
that callers in `prowler.lib.check.*` can ask "is this provider built-in?"
|
||||
without creating an import cycle through `prowler.providers.common.provider`
|
||||
(which transitively imports `prowler.config.config` and from there
|
||||
`prowler.lib.check.compliance_models` / `prowler.lib.check.external_tool_providers`).
|
||||
|
||||
Same rationale as `prowler.lib.check.tool_wrapper`: extracting the predicate
|
||||
to a leaf module is the canonical way to break the cycle in this codebase.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
|
||||
|
||||
def is_builtin_provider(provider: str) -> bool:
|
||||
"""Return True if the provider's own package ships with the SDK.
|
||||
|
||||
Wraps `importlib.util.find_spec` in `try/except (ImportError, ValueError)`
|
||||
because `find_spec` propagates `ModuleNotFoundError` when a parent package
|
||||
in the dotted path does not exist (instead of returning `None`). The
|
||||
try/except is what makes the call safe for external providers, whose
|
||||
package does not live under `prowler.providers.{provider}`.
|
||||
"""
|
||||
try:
|
||||
spec = importlib.util.find_spec(f"prowler.providers.{provider}")
|
||||
return spec is not None
|
||||
except (ImportError, ValueError):
|
||||
return False
|
||||
@@ -4,6 +4,7 @@ from os.path import isdir
|
||||
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
from prowler.config.config import output_file_timestamp
|
||||
from prowler.providers.common.provider import Provider
|
||||
|
||||
|
||||
@@ -69,3 +70,15 @@ class Connection:
|
||||
|
||||
is_connected: bool = False
|
||||
error: Exception = None
|
||||
|
||||
|
||||
def default_output_options(provider, arguments, bulk_checks_metadata):
|
||||
"""Generic OutputOptions fallback for external providers that do not
|
||||
implement get_output_options, so the run still produces output instead of
|
||||
aborting. Honors arguments.output_filename and otherwise derives a name
|
||||
from the provider type."""
|
||||
output_options = ProviderOutputOptions(arguments, bulk_checks_metadata)
|
||||
output_options.output_filename = getattr(arguments, "output_filename", None) or (
|
||||
f"prowler-output-{provider.type}-{output_file_timestamp}"
|
||||
)
|
||||
return output_options
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import importlib
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import os
|
||||
import pkgutil
|
||||
import sys
|
||||
@@ -136,6 +138,155 @@ class Provider(ABC):
|
||||
"""
|
||||
return set()
|
||||
|
||||
# --- Dynamic provider contract methods (not @abstractmethod for incremental migration) ---
|
||||
|
||||
_cli_help_text: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_cli_args(cls, arguments: Namespace, fixer_config: dict) -> "Provider":
|
||||
"""Instantiate the provider from CLI arguments and return the instance.
|
||||
|
||||
The caller wires the returned instance into the global provider slot
|
||||
via Provider.set_global_provider(). Implementations that already call
|
||||
set_global_provider(self) from __init__ are also supported — the call
|
||||
site tolerates a None return in that case.
|
||||
"""
|
||||
raise NotImplementedError(f"{cls.__name__} has not implemented from_cli_args()")
|
||||
|
||||
def get_output_options(self, arguments, _bulk_checks_metadata):
|
||||
"""Create the provider-specific OutputOptions."""
|
||||
raise NotImplementedError(
|
||||
f"{self.__class__.__name__} has not implemented get_output_options()"
|
||||
)
|
||||
|
||||
def get_stdout_detail(self, _finding) -> str:
|
||||
"""Return the detail string for stdout reporting (region, location, etc.)."""
|
||||
raise NotImplementedError(
|
||||
f"{self.__class__.__name__} has not implemented get_stdout_detail()"
|
||||
)
|
||||
|
||||
def get_finding_sort_key(self) -> Optional[str]:
|
||||
"""Return the attribute name to sort findings by, or None for no sorting."""
|
||||
return None
|
||||
|
||||
def get_summary_entity(self) -> tuple:
|
||||
"""Return (entity_type, audited_entities) for the summary table."""
|
||||
return (self.type, getattr(self.identity, "account_id", ""))
|
||||
|
||||
def get_finding_output_data(self, _check_output) -> dict:
|
||||
"""Return provider-specific fields for Finding.generate_output()."""
|
||||
raise NotImplementedError(
|
||||
f"{self.__class__.__name__} has not implemented get_finding_output_data()"
|
||||
)
|
||||
|
||||
def get_html_assessment_summary(self) -> str:
|
||||
"""Return the HTML assessment summary card for this provider."""
|
||||
raise NotImplementedError(
|
||||
f"{self.__class__.__name__} has not implemented get_html_assessment_summary()"
|
||||
)
|
||||
|
||||
def generate_compliance_output(
|
||||
self,
|
||||
_findings,
|
||||
_bulk_compliance_frameworks,
|
||||
_input_compliance_frameworks,
|
||||
_output_options,
|
||||
_generated_outputs,
|
||||
) -> None:
|
||||
"""Generate compliance CSV output for this provider's frameworks."""
|
||||
raise NotImplementedError(
|
||||
f"{self.__class__.__name__} has not implemented generate_compliance_output()"
|
||||
)
|
||||
|
||||
def get_mutelist_finding_args(self) -> dict:
|
||||
"""Return extra kwargs for mutelist.is_finding_muted() besides 'finding'.
|
||||
|
||||
External providers must return a dict with the identity key their
|
||||
Mutelist subclass expects, e.g. ``{"account_id": self.identity.account_id}``.
|
||||
The ``finding`` kwarg is added automatically by the caller.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{self.__class__.__name__} has not implemented get_mutelist_finding_args()"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_scan_arguments(
|
||||
cls,
|
||||
provider_uid: str,
|
||||
secret: dict,
|
||||
mutelist_content: Optional[dict] = None,
|
||||
) -> dict:
|
||||
"""Build the provider constructor kwargs from a stored uid and secret.
|
||||
|
||||
This is the programmatic construction interface used by callers that
|
||||
persist a provider as a single ``uid`` plus a ``secret`` dict (e.g. the
|
||||
API), as opposed to the CLI which passes explicit per-provider flags.
|
||||
The base implementation passes the secret through and adds the mutelist;
|
||||
providers whose constructor needs the uid (e.g. as a subscription id) or
|
||||
that rename/filter secret keys override this.
|
||||
"""
|
||||
kwargs = {**secret}
|
||||
if mutelist_content:
|
||||
kwargs["mutelist_content"] = mutelist_content
|
||||
return kwargs
|
||||
|
||||
@classmethod
|
||||
def get_connection_arguments(cls, provider_uid: str, secret: dict) -> dict:
|
||||
"""Build the ``test_connection`` kwargs from a stored uid and secret.
|
||||
|
||||
Companion to :meth:`get_scan_arguments` for the connection check, which
|
||||
often needs a different shape than the constructor. The base passes the
|
||||
secret through; providers add their identity kwarg (and ``provider_id``
|
||||
where their ``test_connection`` expects it).
|
||||
"""
|
||||
return {**secret}
|
||||
|
||||
@classmethod
|
||||
def get_credentials_schema(cls) -> dict:
|
||||
"""Return the provider's credential schemas keyed by secret type.
|
||||
|
||||
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.
|
||||
|
||||
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 {}
|
||||
|
||||
def display_compliance_table(
|
||||
self,
|
||||
_findings: list,
|
||||
_bulk_checks_metadata: dict,
|
||||
_compliance_framework: str,
|
||||
_output_filename: str,
|
||||
_output_directory: str,
|
||||
_compliance_overview: bool,
|
||||
) -> bool:
|
||||
"""Render a custom compliance table in the terminal.
|
||||
|
||||
External providers can override this to display a detailed
|
||||
compliance table (e.g., per-section breakdown). Return True
|
||||
if the table was rendered, False to fall back to the generic table.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{self.__class__.__name__} has not implemented display_compliance_table()"
|
||||
)
|
||||
|
||||
# Class-level flag: True for providers that delegate scanning to an external
|
||||
# tool (e.g. Trivy, promptfoo) and bypass standard check/service loading and
|
||||
# metadata validation. Subclasses override as `is_external_tool_provider = True`.
|
||||
# Kept as a class attribute (not a property) so it can be read from the class
|
||||
# without instantiation — the metadata validators in lib.check.models need to
|
||||
# decide whether to relax validation before any provider instance exists.
|
||||
is_external_tool_provider: bool = False
|
||||
|
||||
# --- End dynamic provider contract methods ---
|
||||
|
||||
@staticmethod
|
||||
def get_excluded_regions_from_env() -> set:
|
||||
"""Parse the PROWLER_AWS_DISALLOWED_REGIONS environment variable.
|
||||
@@ -159,20 +310,57 @@ class Provider(ABC):
|
||||
@staticmethod
|
||||
def init_global_provider(arguments: Namespace) -> None:
|
||||
try:
|
||||
provider_class_path = (
|
||||
f"{providers_path}.{arguments.provider}.{arguments.provider}_provider"
|
||||
)
|
||||
provider_class_name = f"{arguments.provider.capitalize()}Provider"
|
||||
provider_class = getattr(
|
||||
import_module(provider_class_path), provider_class_name
|
||||
)
|
||||
# Delegate class resolution to the public, side-effect-free
|
||||
# resolver. init_global_provider owns the CLI-specific error
|
||||
# handling: a missing transitive dep in a built-in becomes a
|
||||
# logger.critical + sys.exit(1); a completely unknown provider
|
||||
# re-raises so the outer try/except can sys.exit too.
|
||||
try:
|
||||
provider_class = Provider.get_class(arguments.provider)
|
||||
except ImportError as e:
|
||||
if Provider.is_builtin(arguments.provider):
|
||||
# Built-in's transitive dependency is missing — loud CLI error.
|
||||
logger.critical(
|
||||
f"Failed to load built-in provider '{arguments.provider}'. "
|
||||
f"Missing dependency: {e}. "
|
||||
f"Ensure all required dependencies are installed."
|
||||
)
|
||||
logger.debug("Full traceback:", exc_info=True)
|
||||
sys.exit(1)
|
||||
# Unknown or missing external provider — propagate so the
|
||||
# outer try/except can handle it (sys.exit(1) via generic
|
||||
# exception handler).
|
||||
raise
|
||||
|
||||
# Built-in wins on name collision — warn that a same-named
|
||||
# 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. 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 "
|
||||
f"via entry points is being IGNORED — a built-in with "
|
||||
f"the same name exists. To use your plug-in, register "
|
||||
f"it under a different name."
|
||||
)
|
||||
|
||||
fixer_config = load_and_validate_config_file(
|
||||
arguments.provider, arguments.fixer_config
|
||||
)
|
||||
|
||||
# Dispatch by exact provider name (equality, not substring) so
|
||||
# external plug-ins whose names contain a built-in substring
|
||||
# (e.g. `awsx`, `azure_gov`, `iac_v2`) cannot be silently routed
|
||||
# to the wrong built-in branch. Anything that doesn't match a
|
||||
# built-in falls through to the dynamic else and uses the
|
||||
# contract's `from_cli_args`.
|
||||
if not isinstance(Provider._global, provider_class):
|
||||
if "aws" in provider_class_name.lower():
|
||||
if arguments.provider == "aws":
|
||||
excluded_regions = (
|
||||
set(arguments.excluded_region)
|
||||
if getattr(arguments, "excluded_region", None)
|
||||
@@ -196,7 +384,7 @@ class Provider(ABC):
|
||||
mutelist_path=arguments.mutelist_file,
|
||||
fixer_config=fixer_config,
|
||||
)
|
||||
elif "azure" in provider_class_name.lower():
|
||||
elif arguments.provider == "azure":
|
||||
provider_class(
|
||||
az_cli_auth=arguments.az_cli_auth,
|
||||
sp_env_auth=arguments.sp_env_auth,
|
||||
@@ -209,7 +397,7 @@ class Provider(ABC):
|
||||
mutelist_path=arguments.mutelist_file,
|
||||
fixer_config=fixer_config,
|
||||
)
|
||||
elif "gcp" in provider_class_name.lower():
|
||||
elif arguments.provider == "gcp":
|
||||
provider_class(
|
||||
retries_max_attempts=arguments.gcp_retries_max_attempts,
|
||||
organization_id=arguments.organization_id,
|
||||
@@ -223,7 +411,7 @@ class Provider(ABC):
|
||||
fixer_config=fixer_config,
|
||||
skip_api_check=arguments.skip_api_check,
|
||||
)
|
||||
elif "kubernetes" in provider_class_name.lower():
|
||||
elif arguments.provider == "kubernetes":
|
||||
provider_class(
|
||||
kubeconfig_file=arguments.kubeconfig_file,
|
||||
context=arguments.context,
|
||||
@@ -233,7 +421,7 @@ class Provider(ABC):
|
||||
mutelist_path=arguments.mutelist_file,
|
||||
fixer_config=fixer_config,
|
||||
)
|
||||
elif "m365" in provider_class_name.lower():
|
||||
elif arguments.provider == "m365":
|
||||
provider_class(
|
||||
region=arguments.region,
|
||||
config_path=arguments.config_file,
|
||||
@@ -247,7 +435,7 @@ class Provider(ABC):
|
||||
init_modules=arguments.init_modules,
|
||||
fixer_config=fixer_config,
|
||||
)
|
||||
elif "nhn" in provider_class_name.lower():
|
||||
elif arguments.provider == "nhn":
|
||||
provider_class(
|
||||
username=arguments.nhn_username,
|
||||
password=arguments.nhn_password,
|
||||
@@ -256,7 +444,7 @@ class Provider(ABC):
|
||||
mutelist_path=arguments.mutelist_file,
|
||||
fixer_config=fixer_config,
|
||||
)
|
||||
elif "stackit" in provider_class_name.lower():
|
||||
elif arguments.provider == "stackit":
|
||||
provider_class(
|
||||
project_id=arguments.stackit_project_id,
|
||||
service_account_key_path=getattr(
|
||||
@@ -275,7 +463,7 @@ class Provider(ABC):
|
||||
mutelist_path=arguments.mutelist_file,
|
||||
fixer_config=fixer_config,
|
||||
)
|
||||
elif "github" in provider_class_name.lower():
|
||||
elif arguments.provider == "github":
|
||||
orgs = []
|
||||
repos = []
|
||||
|
||||
@@ -307,13 +495,13 @@ class Provider(ABC):
|
||||
exclude_workflows=getattr(arguments, "exclude_workflows", []),
|
||||
fixer_config=fixer_config,
|
||||
)
|
||||
elif "googleworkspace" in provider_class_name.lower():
|
||||
elif arguments.provider == "googleworkspace":
|
||||
provider_class(
|
||||
config_path=arguments.config_file,
|
||||
mutelist_path=arguments.mutelist_file,
|
||||
fixer_config=fixer_config,
|
||||
)
|
||||
elif "cloudflare" in provider_class_name.lower():
|
||||
elif arguments.provider == "cloudflare":
|
||||
provider_class(
|
||||
filter_zones=arguments.region,
|
||||
filter_accounts=arguments.account_id,
|
||||
@@ -321,7 +509,7 @@ class Provider(ABC):
|
||||
mutelist_path=arguments.mutelist_file,
|
||||
fixer_config=fixer_config,
|
||||
)
|
||||
elif "iac" in provider_class_name.lower():
|
||||
elif arguments.provider == "iac":
|
||||
provider_class(
|
||||
scan_path=arguments.scan_path,
|
||||
scan_repository_url=arguments.scan_repository_url,
|
||||
@@ -334,13 +522,13 @@ class Provider(ABC):
|
||||
oauth_app_token=arguments.oauth_app_token,
|
||||
provider_uid=arguments.provider_uid,
|
||||
)
|
||||
elif "llm" in provider_class_name.lower():
|
||||
elif arguments.provider == "llm":
|
||||
provider_class(
|
||||
max_concurrency=arguments.max_concurrency,
|
||||
config_path=arguments.config_file,
|
||||
fixer_config=fixer_config,
|
||||
)
|
||||
elif "image" in provider_class_name.lower():
|
||||
elif arguments.provider == "image":
|
||||
provider_class(
|
||||
images=arguments.images,
|
||||
image_list_file=arguments.image_list_file,
|
||||
@@ -358,7 +546,7 @@ class Provider(ABC):
|
||||
registry_insecure=arguments.registry_insecure,
|
||||
registry_list_images=arguments.registry_list_images,
|
||||
)
|
||||
elif "mongodbatlas" in provider_class_name.lower():
|
||||
elif arguments.provider == "mongodbatlas":
|
||||
provider_class(
|
||||
atlas_public_key=arguments.atlas_public_key,
|
||||
atlas_private_key=arguments.atlas_private_key,
|
||||
@@ -367,7 +555,7 @@ class Provider(ABC):
|
||||
mutelist_path=arguments.mutelist_file,
|
||||
fixer_config=fixer_config,
|
||||
)
|
||||
elif "oraclecloud" in provider_class_name.lower():
|
||||
elif arguments.provider == "oraclecloud":
|
||||
provider_class(
|
||||
oci_config_file=arguments.oci_config_file,
|
||||
profile=arguments.profile,
|
||||
@@ -378,7 +566,7 @@ class Provider(ABC):
|
||||
fixer_config=fixer_config,
|
||||
use_instance_principal=arguments.use_instance_principal,
|
||||
)
|
||||
elif "openstack" in provider_class_name.lower():
|
||||
elif arguments.provider == "openstack":
|
||||
provider_class(
|
||||
clouds_yaml_file=getattr(arguments, "clouds_yaml_file", None),
|
||||
clouds_yaml_content=getattr(
|
||||
@@ -403,7 +591,7 @@ class Provider(ABC):
|
||||
mutelist_path=arguments.mutelist_file,
|
||||
fixer_config=fixer_config,
|
||||
)
|
||||
elif "alibabacloud" in provider_class_name.lower():
|
||||
elif arguments.provider == "alibabacloud":
|
||||
provider_class(
|
||||
role_arn=arguments.role_arn,
|
||||
role_session_name=arguments.role_session_name,
|
||||
@@ -415,14 +603,14 @@ class Provider(ABC):
|
||||
mutelist_path=arguments.mutelist_file,
|
||||
fixer_config=fixer_config,
|
||||
)
|
||||
elif "vercel" in provider_class_name.lower():
|
||||
elif arguments.provider == "vercel":
|
||||
provider_class(
|
||||
projects=getattr(arguments, "project", None),
|
||||
config_path=arguments.config_file,
|
||||
mutelist_path=arguments.mutelist_file,
|
||||
fixer_config=fixer_config,
|
||||
)
|
||||
elif "okta" in provider_class_name.lower():
|
||||
elif arguments.provider == "okta":
|
||||
provider_class(
|
||||
okta_org_domain=getattr(arguments, "okta_org_domain", ""),
|
||||
okta_client_id=getattr(arguments, "okta_client_id", ""),
|
||||
@@ -435,7 +623,7 @@ class Provider(ABC):
|
||||
mutelist_path=arguments.mutelist_file,
|
||||
fixer_config=fixer_config,
|
||||
)
|
||||
elif "scaleway" in provider_class_name.lower():
|
||||
elif arguments.provider == "scaleway":
|
||||
# Credentials are read from the SCW_ACCESS_KEY /
|
||||
# SCW_SECRET_KEY env vars by the provider itself; there
|
||||
# are no credential CLI flags to avoid leaking secrets.
|
||||
@@ -447,6 +635,18 @@ class Provider(ABC):
|
||||
mutelist_path=arguments.mutelist_file,
|
||||
fixer_config=fixer_config,
|
||||
)
|
||||
else:
|
||||
# Dynamic fallback: any external/custom provider.
|
||||
# Honor the from_cli_args type hint (-> Provider): if the
|
||||
# implementation returns an instance, wire it as the global
|
||||
# provider here. Implementations that call
|
||||
# set_global_provider(self) from __init__ return None and
|
||||
# remain supported (the condition below is a no-op for them).
|
||||
provider_instance = provider_class.from_cli_args(
|
||||
arguments, fixer_config
|
||||
)
|
||||
if provider_instance is not None:
|
||||
Provider.set_global_provider(provider_instance)
|
||||
|
||||
except TypeError as error:
|
||||
logger.critical(
|
||||
@@ -459,17 +659,130 @@ class Provider(ABC):
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Cache for entry-point provider classes {name: class}
|
||||
_ep_providers: dict = {}
|
||||
|
||||
@staticmethod
|
||||
def get_available_providers() -> list[str]:
|
||||
"""get_available_providers returns a list of the available providers"""
|
||||
providers = []
|
||||
# Dynamically import the package based on its string path
|
||||
providers = set()
|
||||
# Built-in providers from local package
|
||||
prowler_providers = importlib.import_module(providers_path)
|
||||
# Iterate over all modules found in the prowler_providers package
|
||||
for _, provider, ispkg in pkgutil.iter_modules(prowler_providers.__path__):
|
||||
if provider != "common" and ispkg:
|
||||
providers.append(provider)
|
||||
return providers
|
||||
providers.add(provider)
|
||||
# External providers registered via entry points
|
||||
for ep in importlib.metadata.entry_points(group="prowler.providers"):
|
||||
providers.add(ep.name)
|
||||
return sorted(providers)
|
||||
|
||||
@staticmethod
|
||||
def is_tool_wrapper_provider(provider: str) -> bool:
|
||||
"""Return True if the provider delegates scanning to an external tool.
|
||||
|
||||
Delegates to `prowler.lib.check.tool_wrapper.is_tool_wrapper_provider`,
|
||||
the leaf module that holds the actual logic. Kept on `Provider` as a
|
||||
convenience entry point for callers that already import `Provider`.
|
||||
"""
|
||||
from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider as _impl
|
||||
|
||||
return _impl(provider)
|
||||
|
||||
@staticmethod
|
||||
def is_builtin(provider: str) -> bool:
|
||||
"""Return True if the provider's own package is importable as a built-in.
|
||||
|
||||
Delegates to `prowler.providers.common.builtin.is_builtin_provider`,
|
||||
the leaf module that holds the actual check. Kept on `Provider` as a
|
||||
convenience entry point for callers that already import `Provider`.
|
||||
Call sites in `prowler.lib.check.*` should import from the leaf
|
||||
directly to avoid the import cycle through this module.
|
||||
"""
|
||||
from prowler.providers.common.builtin import is_builtin_provider as _impl
|
||||
|
||||
return _impl(provider)
|
||||
|
||||
@staticmethod
|
||||
def _load_ep_provider(name: str):
|
||||
"""Load an external provider class from entry points, with cache.
|
||||
|
||||
Caches both hits and misses so repeated lookups for unknown names do
|
||||
not re-iterate entry_points(). Symmetric with
|
||||
tool_wrapper._ep_class_cache.
|
||||
"""
|
||||
if name in Provider._ep_providers:
|
||||
return Provider._ep_providers[name]
|
||||
for ep in importlib.metadata.entry_points(group="prowler.providers"):
|
||||
if ep.name == name:
|
||||
try:
|
||||
cls = ep.load()
|
||||
Provider._ep_providers[name] = cls
|
||||
return cls
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
Provider._ep_providers[name] = None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_class(provider: str) -> type:
|
||||
"""Resolve the provider class for a name (built-in or entry-point).
|
||||
|
||||
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.
|
||||
|
||||
Returns:
|
||||
The provider class (a subclass of :class:`Provider`).
|
||||
|
||||
Raises:
|
||||
ImportError: If not found as built-in or entry point, or a
|
||||
built-in's transitive dependency is missing.
|
||||
"""
|
||||
if Provider.is_builtin(provider):
|
||||
provider_class_path = f"{providers_path}.{provider}.{provider}_provider"
|
||||
provider_class_name = f"{provider.capitalize()}Provider"
|
||||
# Let ImportError propagate — the caller decides whether to
|
||||
# sys.exit (CLI) or return HTTP 400 (API).
|
||||
module = import_module(provider_class_path)
|
||||
try:
|
||||
return getattr(module, provider_class_name)
|
||||
except AttributeError:
|
||||
# Module exists but doesn't define the expected class —
|
||||
# fall through to entry points.
|
||||
cls = Provider._load_ep_provider(provider)
|
||||
if cls is not None:
|
||||
return cls
|
||||
raise ImportError(
|
||||
f"Provider '{provider}' not found as built-in or entry point"
|
||||
)
|
||||
|
||||
cls = Provider._load_ep_provider(provider)
|
||||
if cls is None:
|
||||
raise ImportError(
|
||||
f"Provider '{provider}' not found as built-in or entry point"
|
||||
)
|
||||
return cls
|
||||
|
||||
@staticmethod
|
||||
def get_providers_help_text() -> dict:
|
||||
"""Returns a dict of {provider_name: cli_help_text} for all available providers."""
|
||||
help_text = {}
|
||||
for name in Provider.get_available_providers():
|
||||
try:
|
||||
cls = Provider.get_class(name)
|
||||
help_text[name] = getattr(cls, "_cli_help_text", "")
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
help_text[name] = ""
|
||||
return help_text
|
||||
|
||||
@staticmethod
|
||||
def update_provider_config(audit_config: dict, variable: str, value: str):
|
||||
|
||||
@@ -471,6 +471,32 @@ class Test_Config:
|
||||
all_frameworks = get_available_compliance_frameworks()
|
||||
assert "csa_ccm_4.0" in all_frameworks
|
||||
|
||||
@mock.patch("prowler.config.config._get_ep_compliance_dirs")
|
||||
def test_get_available_compliance_frameworks_dedupes_ep_collisions_with_builtins(
|
||||
self, mock_dirs
|
||||
):
|
||||
"""Entry-point compliance frameworks that collide with a built-in
|
||||
name must appear only once in the available frameworks list.
|
||||
Built-in wins silently — same policy as the universal frameworks
|
||||
loop and as Compliance.get_bulk."""
|
||||
import json
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# cis_2.0_aws ships as a built-in under prowler/compliance/aws/
|
||||
json_path = os.path.join(tmpdir, "cis_2.0_aws.json")
|
||||
with open(json_path, "w") as f:
|
||||
json.dump({"Framework": "CIS", "Provider": "aws"}, f)
|
||||
|
||||
mock_dirs.return_value = {"aws": tmpdir}
|
||||
|
||||
frameworks = get_available_compliance_frameworks("aws")
|
||||
|
||||
assert frameworks.count("cis_2.0_aws") == 1, (
|
||||
f"Expected cis_2.0_aws to appear exactly once, got "
|
||||
f"{frameworks.count('cis_2.0_aws')} occurrences in: {frameworks}"
|
||||
)
|
||||
|
||||
def test_load_and_validate_config_file_aws(self):
|
||||
path = pathlib.Path(os.path.dirname(os.path.realpath(__file__)))
|
||||
config_test_file = f"{path}/fixtures/config.yaml"
|
||||
@@ -508,6 +534,32 @@ class Test_Config:
|
||||
assert load_and_validate_config_file("azure", config_test_file) == {}
|
||||
assert load_and_validate_config_file("kubernetes", config_test_file) == {}
|
||||
|
||||
def test_load_and_validate_config_file_namespaced_non_listed_provider(self):
|
||||
path = pathlib.Path(os.path.dirname(os.path.realpath(__file__)))
|
||||
config_test_file = f"{path}/fixtures/config_namespaced_external.yaml"
|
||||
# github is a built-in not in the legacy hardcoded list; namespaced format must unwrap it.
|
||||
assert load_and_validate_config_file("github", config_test_file) == {
|
||||
"token": "abc",
|
||||
"org": "prowler-cloud",
|
||||
}
|
||||
|
||||
def test_load_and_validate_config_file_namespaced_external_provider(self):
|
||||
path = pathlib.Path(os.path.dirname(os.path.realpath(__file__)))
|
||||
config_test_file = f"{path}/fixtures/config_namespaced_external.yaml"
|
||||
# External plug-in provider: namespaced format must unwrap its block.
|
||||
assert load_and_validate_config_file("custom_plugin", config_test_file) == {
|
||||
"setting": "value",
|
||||
"nested": {"key": 42},
|
||||
}
|
||||
|
||||
def test_load_and_validate_config_file_namespaced_missing_provider(self):
|
||||
path = pathlib.Path(os.path.dirname(os.path.realpath(__file__)))
|
||||
config_test_file = f"{path}/fixtures/config_namespaced_external.yaml"
|
||||
# Provider with no section in a namespaced file must return empty config,
|
||||
# not the full file (prevents cross-provider config leakage).
|
||||
assert load_and_validate_config_file("aws", config_test_file) == {}
|
||||
assert load_and_validate_config_file("gcp", config_test_file) == {}
|
||||
|
||||
def test_load_and_validate_config_file_invalid_config_file_path(self, caplog):
|
||||
provider = "aws"
|
||||
config_file_path = "invalid/path/to/fixer_config.yaml"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Namespaced config covering a non-listed built-in (github) and an external plugin.
|
||||
github:
|
||||
token: abc
|
||||
org: prowler-cloud
|
||||
custom_plugin:
|
||||
setting: value
|
||||
nested:
|
||||
key: 42
|
||||
@@ -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"]
|
||||
|
||||
@@ -95,6 +95,38 @@ class TestCheckMetada:
|
||||
"/path/to/accessanalyzer_enabled/accessanalyzer_enabled.metadata.json"
|
||||
)
|
||||
|
||||
@mock.patch("prowler.lib.check.models.logger")
|
||||
@mock.patch("prowler.lib.check.models.load_check_metadata")
|
||||
@mock.patch("prowler.lib.check.models.recover_checks_from_provider")
|
||||
def test_get_bulk_builtin_wins_on_check_id_collision(
|
||||
self, mock_recover_checks, mock_load_metadata, mock_logger
|
||||
):
|
||||
"""Regression guard: when an entry-point plug-in re-registers a
|
||||
built-in CheckID, the BUILT-IN metadata wins (first-write-wins) and
|
||||
the plug-in is IGNORED. The override is surfaced via a warning so
|
||||
the user knows their plug-in duplicate is being skipped and can
|
||||
rename it. Matches the precedence in `_resolve_check_module`. See
|
||||
PR #10700 review (HugoPBrito)."""
|
||||
# Built-in first, plug-in last (matches recover_checks_from_provider order)
|
||||
mock_recover_checks.return_value = [
|
||||
("accessanalyzer_enabled", "/builtin/accessanalyzer_enabled"),
|
||||
("accessanalyzer_enabled", "/plugin/accessanalyzer_enabled"),
|
||||
]
|
||||
|
||||
builtin_metadata = mock.MagicMock(CheckID="accessanalyzer_enabled")
|
||||
plugin_metadata = mock.MagicMock(CheckID="accessanalyzer_enabled")
|
||||
mock_load_metadata.side_effect = [builtin_metadata, plugin_metadata]
|
||||
|
||||
result = CheckMetadata.get_bulk(provider="aws")
|
||||
|
||||
# Built-in wins (first-write-wins on CheckID), plug-in is ignored
|
||||
assert result["accessanalyzer_enabled"] is builtin_metadata
|
||||
# Override is surfaced via warning naming the plug-in metadata file
|
||||
mock_logger.warning.assert_called_once()
|
||||
warning_msg = mock_logger.warning.call_args.args[0]
|
||||
assert "accessanalyzer_enabled" in warning_msg
|
||||
assert "/plugin/accessanalyzer_enabled" in warning_msg
|
||||
|
||||
@mock.patch("prowler.lib.check.models.load_check_metadata")
|
||||
@mock.patch("prowler.lib.check.models.recover_checks_from_provider")
|
||||
def test_list(self, mock_recover_checks, mock_load_metadata):
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Unit tests for prowler.lib.check.tool_wrapper.
|
||||
|
||||
Covers the leaf helper directly (Provider.is_tool_wrapper_provider delegates
|
||||
to it). Tests the frozenset fast path, the entry-point fallback for external
|
||||
plug-ins, the broken-plug-in path, the no-match path, and the module-level
|
||||
cache.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_ep_class_cache():
|
||||
"""Reset the leaf module's cache between tests so they stay independent."""
|
||||
from prowler.lib.check import tool_wrapper
|
||||
|
||||
tool_wrapper._ep_class_cache.clear()
|
||||
yield
|
||||
tool_wrapper._ep_class_cache.clear()
|
||||
|
||||
|
||||
def _make_entry_point(name, cls):
|
||||
"""Create a mock entry point whose `load()` returns `cls`."""
|
||||
ep = MagicMock()
|
||||
ep.name = name
|
||||
ep.load.return_value = cls
|
||||
return ep
|
||||
|
||||
|
||||
class TestIsToolWrapperProvider:
|
||||
"""is_tool_wrapper_provider: frozenset + entry-point fallback."""
|
||||
|
||||
@pytest.mark.parametrize("name", ["iac", "llm", "image"])
|
||||
def test_returns_true_for_builtin_tool_wrappers(self, name):
|
||||
from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider
|
||||
|
||||
assert is_tool_wrapper_provider(name) is True
|
||||
|
||||
@pytest.mark.parametrize("name", ["aws", "azure", "gcp", "github", "kubernetes"])
|
||||
def test_returns_false_for_regular_builtins(self, name):
|
||||
from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider
|
||||
|
||||
assert is_tool_wrapper_provider(name) is False
|
||||
|
||||
@patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points")
|
||||
def test_returns_true_for_external_plugin_with_flag(self, mock_eps):
|
||||
from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider
|
||||
|
||||
cls = MagicMock(is_external_tool_provider=True)
|
||||
mock_eps.return_value = [_make_entry_point("custom_wrapper", cls)]
|
||||
|
||||
assert is_tool_wrapper_provider("custom_wrapper") is True
|
||||
|
||||
@patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points")
|
||||
def test_returns_false_for_external_plugin_without_flag(self, mock_eps):
|
||||
from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider
|
||||
|
||||
cls = MagicMock(is_external_tool_provider=False)
|
||||
mock_eps.return_value = [_make_entry_point("vanilla_external", cls)]
|
||||
|
||||
assert is_tool_wrapper_provider("vanilla_external") is False
|
||||
|
||||
@patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points")
|
||||
def test_returns_false_for_unknown_provider(self, mock_eps):
|
||||
from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider
|
||||
|
||||
mock_eps.return_value = []
|
||||
|
||||
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."""
|
||||
|
||||
@patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points")
|
||||
def test_caches_result_across_calls(self, mock_eps):
|
||||
from prowler.lib.check.tool_wrapper import _load_ep_class
|
||||
|
||||
cls = MagicMock(is_external_tool_provider=True)
|
||||
mock_eps.return_value = [_make_entry_point("cached_one", cls)]
|
||||
|
||||
first = _load_ep_class("cached_one")
|
||||
second = _load_ep_class("cached_one")
|
||||
|
||||
assert first is cls
|
||||
assert second is cls
|
||||
# entry_points consulted only on the first call
|
||||
assert mock_eps.call_count == 1
|
||||
|
||||
@patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points")
|
||||
def test_returns_none_for_broken_plugin(self, mock_eps):
|
||||
from prowler.lib.check.tool_wrapper import _load_ep_class
|
||||
|
||||
broken_ep = MagicMock()
|
||||
broken_ep.name = "broken"
|
||||
broken_ep.load.side_effect = ImportError("plug-in is broken")
|
||||
mock_eps.return_value = [broken_ep]
|
||||
|
||||
assert _load_ep_class("broken") is None
|
||||
|
||||
@patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points")
|
||||
def test_returns_none_when_no_entry_point_matches(self, mock_eps):
|
||||
from prowler.lib.check.tool_wrapper import _load_ep_class
|
||||
|
||||
cls = MagicMock()
|
||||
mock_eps.return_value = [_make_entry_point("other_provider", cls)]
|
||||
|
||||
assert _load_ep_class("missing_provider") is None
|
||||
@@ -1,5 +1,7 @@
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic.v1 import ValidationError
|
||||
@@ -23,6 +25,7 @@ from prowler.lib.check.compliance_models import (
|
||||
TableLabels,
|
||||
UniversalComplianceRequirement,
|
||||
adapt_legacy_to_universal,
|
||||
get_bulk_compliance_frameworks_universal,
|
||||
load_compliance_framework_universal,
|
||||
)
|
||||
from tests.lib.outputs.compliance.fixtures import (
|
||||
@@ -1116,3 +1119,121 @@ class TestAttributesMetadataValidation:
|
||||
],
|
||||
attributes_metadata=self._metadata(enum=["high", "low"]),
|
||||
)
|
||||
|
||||
|
||||
class TestGetBulkUniversalEntryPoints:
|
||||
"""Entry-point discovery for universal (multi-provider) compliance frameworks."""
|
||||
|
||||
@staticmethod
|
||||
def _write_universal_json(directory, filename, framework, display_name):
|
||||
data = {
|
||||
"framework": framework,
|
||||
"name": display_name,
|
||||
"version": "1.0",
|
||||
"description": "External multi-provider framework",
|
||||
"requirements": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Requirement 1",
|
||||
"description": "desc",
|
||||
"checks": {"fakeexternal": ["check_a"]},
|
||||
}
|
||||
],
|
||||
}
|
||||
with open(os.path.join(directory, filename), "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
@staticmethod
|
||||
def _entry_point(path):
|
||||
module = MagicMock()
|
||||
module.__path__ = [path]
|
||||
ep = MagicMock()
|
||||
ep.name = "fakeexternal"
|
||||
ep.group = "prowler.compliance.universal"
|
||||
ep.load.return_value = module
|
||||
return ep
|
||||
|
||||
@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
|
||||
@patch("prowler.lib.check.compliance_models.list_compliance_modules")
|
||||
def test_includes_external_universal_framework(self, mock_list_modules, mock_ep):
|
||||
mock_list_modules.return_value = []
|
||||
with tempfile.TemporaryDirectory() as ep_dir:
|
||||
self._write_universal_json(
|
||||
ep_dir, "customuniversal_1.0.json", "CustomUniversal", "Custom"
|
||||
)
|
||||
mock_ep.return_value = [self._entry_point(ep_dir)]
|
||||
|
||||
bulk = get_bulk_compliance_frameworks_universal("fakeexternal")
|
||||
|
||||
mock_ep.assert_called_with(group="prowler.compliance.universal")
|
||||
assert "customuniversal_1.0" in bulk
|
||||
assert bulk["customuniversal_1.0"].framework == "CustomUniversal"
|
||||
|
||||
@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
|
||||
@patch("prowler.lib.check.compliance_models.list_compliance_modules")
|
||||
def test_builtin_wins_over_external_on_name_collision(
|
||||
self, mock_list_modules, mock_ep
|
||||
):
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as root,
|
||||
tempfile.TemporaryDirectory() as ep_dir,
|
||||
):
|
||||
builtin_sub = os.path.join(root, "builtinprov")
|
||||
os.makedirs(builtin_sub)
|
||||
self._write_universal_json(
|
||||
builtin_sub, "shared_1.0.json", "SharedFramework", "Built-in"
|
||||
)
|
||||
builtin_module = MagicMock()
|
||||
builtin_module.module_finder.path = root
|
||||
builtin_module.name = "prowler.compliance.builtinprov"
|
||||
mock_list_modules.return_value = [builtin_module]
|
||||
|
||||
self._write_universal_json(
|
||||
ep_dir, "shared_1.0.json", "SharedFramework", "External"
|
||||
)
|
||||
mock_ep.return_value = [self._entry_point(ep_dir)]
|
||||
|
||||
bulk = get_bulk_compliance_frameworks_universal("fakeexternal")
|
||||
|
||||
assert "shared_1.0" in bulk
|
||||
assert bulk["shared_1.0"].name == "Built-in"
|
||||
|
||||
@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
|
||||
@patch("prowler.lib.check.compliance_models.list_compliance_modules")
|
||||
def test_loads_all_frameworks_in_a_single_entry_point_path(
|
||||
self, mock_list_modules, mock_ep
|
||||
):
|
||||
"""All JSONs in one entry-point directory are added, not collapsed to one."""
|
||||
mock_list_modules.return_value = []
|
||||
with tempfile.TemporaryDirectory() as ep_dir:
|
||||
self._write_universal_json(ep_dir, "fw_a_1.0.json", "FwA", "Framework A")
|
||||
self._write_universal_json(ep_dir, "fw_b_1.0.json", "FwB", "Framework B")
|
||||
mock_ep.return_value = [self._entry_point(ep_dir)]
|
||||
|
||||
bulk = get_bulk_compliance_frameworks_universal("fakeexternal")
|
||||
|
||||
assert "fw_a_1.0" in bulk
|
||||
assert "fw_b_1.0" in bulk
|
||||
|
||||
@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
|
||||
@patch("prowler.lib.check.compliance_models.list_compliance_modules")
|
||||
def test_merges_frameworks_from_multiple_packages_same_provider(
|
||||
self, mock_list_modules, mock_ep
|
||||
):
|
||||
"""Two packages under the same provider name are both discovered."""
|
||||
mock_list_modules.return_value = []
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as dir_a,
|
||||
tempfile.TemporaryDirectory() as dir_b,
|
||||
):
|
||||
self._write_universal_json(dir_a, "pkg_a_1.0.json", "PkgA", "Package A")
|
||||
self._write_universal_json(dir_b, "pkg_b_1.0.json", "PkgB", "Package B")
|
||||
mock_ep.return_value = [
|
||||
self._entry_point(dir_a),
|
||||
self._entry_point(dir_b),
|
||||
]
|
||||
|
||||
bulk = get_bulk_compliance_frameworks_universal("fakeexternal")
|
||||
|
||||
assert "pkg_a_1.0" in bulk
|
||||
assert "pkg_b_1.0" in bulk
|
||||
|
||||
@@ -9,6 +9,7 @@ from prowler.lib.check.compliance_models import (
|
||||
Compliance,
|
||||
Compliance_Requirement,
|
||||
Generic_Compliance_Requirement_Attribute,
|
||||
ISO27001_2013_Requirement_Attribute,
|
||||
)
|
||||
from prowler.lib.outputs.compliance.generic.generic import GenericCompliance
|
||||
from prowler.lib.outputs.compliance.generic.models import GenericComplianceModel
|
||||
@@ -198,3 +199,47 @@ class TestAWSGenericCompliance:
|
||||
), f"Expected 1 row driven by framework JSON, got {len(rows)}"
|
||||
assert rows[0].Requirements_Id == "req_in_framework"
|
||||
assert rows[0].CheckId == "service_check_in_framework"
|
||||
|
||||
def test_transform_tolerates_framework_specific_attribute_schema(self):
|
||||
"""GenericCompliance is the documented last-resort renderer, so it must not
|
||||
crash on a framework whose attribute schema lacks the universal fields
|
||||
(Section, SubSection, SubGroup, Service, Type, Comment). ISO27001 declares
|
||||
none of them; missing fields must render as None instead of raising
|
||||
AttributeError and dropping the whole CSV."""
|
||||
framework_name = "ISO27001-2013-External"
|
||||
compliance = Compliance(
|
||||
Framework=framework_name,
|
||||
Name=framework_name,
|
||||
Provider="external",
|
||||
Version="",
|
||||
Description="Framework shipping a provider-specific attribute schema",
|
||||
Requirements=[
|
||||
Compliance_Requirement(
|
||||
Id="A.5.1.1",
|
||||
Description="Policies for information security",
|
||||
Attributes=[
|
||||
ISO27001_2013_Requirement_Attribute(
|
||||
Category="Information security policies",
|
||||
Objetive_ID="A.5.1",
|
||||
Objetive_Name="Management direction",
|
||||
Check_Summary="Policy is defined",
|
||||
)
|
||||
],
|
||||
Checks=["service_test_check_id"],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
findings = [generate_finding_output(check_id="service_test_check_id")]
|
||||
|
||||
output = GenericCompliance(findings, compliance)
|
||||
|
||||
rows = [row for row in output.data if row.Status != "MANUAL"]
|
||||
assert len(rows) == 1
|
||||
assert rows[0].Requirements_Id == "A.5.1.1"
|
||||
assert rows[0].Requirements_Attributes_Section is None
|
||||
assert rows[0].Requirements_Attributes_SubSection is None
|
||||
assert rows[0].Requirements_Attributes_SubGroup is None
|
||||
assert rows[0].Requirements_Attributes_Service is None
|
||||
assert rows[0].Requirements_Attributes_Type is None
|
||||
assert rows[0].Requirements_Attributes_Comment is None
|
||||
|
||||
@@ -51,7 +51,7 @@ def mock_provider():
|
||||
def mock_execute():
|
||||
with mock.patch("prowler.lib.scan.scan.execute", autospec=True) as mock_exec:
|
||||
findings = [finding]
|
||||
mock_exec.side_effect = lambda *args, **kwargs: findings
|
||||
mock_exec.side_effect = lambda *_args, **_kwargs: findings
|
||||
yield mock_exec
|
||||
|
||||
|
||||
@@ -264,10 +264,10 @@ class TestScan:
|
||||
@patch("prowler.lib.scan.scan.update_checks_metadata_with_compliance")
|
||||
@patch("prowler.lib.scan.scan.Compliance.get_bulk")
|
||||
@patch("prowler.lib.scan.scan.CheckMetadata.get_bulk")
|
||||
@patch("prowler.lib.scan.scan.import_check")
|
||||
@patch("prowler.lib.scan.scan._resolve_check_module")
|
||||
def test_scan(
|
||||
self,
|
||||
mock_import_check,
|
||||
mock_resolve_check_module,
|
||||
mock_get_bulk,
|
||||
mock_compliance_get_bulk,
|
||||
mock_update_checks_metadata,
|
||||
@@ -285,7 +285,7 @@ class TestScan:
|
||||
mock_check_instance.CheckTitle = "Check if IAM Access Analyzer is enabled"
|
||||
mock_check_instance.Categories = []
|
||||
|
||||
mock_import_check.return_value = MagicMock(
|
||||
mock_resolve_check_module.return_value = MagicMock(
|
||||
accessanalyzer_enabled=mock_check_class
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user