mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-06-10 21:42:29 +00:00
Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ffb0f6f45 | |||
| 2b1c9c3381 | |||
| 983c2141ff | |||
| cdac1ce915 | |||
| 789dbfb620 | |||
| d7346a6e63 | |||
| 8efff5ccf8 | |||
| 900a668ddc | |||
| f34daf1e69 | |||
| 3c72e9d25e | |||
| 5392a87a30 | |||
| 40da359804 | |||
| 8a0d56786d | |||
| f729c5a9f0 | |||
| f9682c1354 | |||
| 6f172a5c19 | |||
| 29825f9a2f | |||
| 356e6e2bb4 | |||
| 8bc8b16a77 | |||
| efa3283a25 | |||
| a7d180ea5b | |||
| d4bbc8b5ad | |||
| a5bc226f11 | |||
| 3a3d9d6146 | |||
| bcd282d3d0 | |||
| eb7949c884 | |||
| e60a4462e5 | |||
| 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 |
Generated
+6
-6
@@ -71,7 +71,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Setup Scripts
|
||||
uses: github/gh-aw/actions/setup@58d1bedbb7200f59c2d224151339e38fd8687d05 # v0.76.1
|
||||
uses: github/gh-aw/actions/setup@4d44d0e89851a877f4ddc0cb6c0197e42b1016c5 # v0.73.0
|
||||
with:
|
||||
destination: /opt/gh-aw/actions
|
||||
- name: Check workflow file timestamps
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Setup Scripts
|
||||
uses: github/gh-aw/actions/setup@58d1bedbb7200f59c2d224151339e38fd8687d05 # v0.76.1
|
||||
uses: github/gh-aw/actions/setup@4d44d0e89851a877f4ddc0cb6c0197e42b1016c5 # v0.73.0
|
||||
with:
|
||||
destination: /opt/gh-aw/actions
|
||||
- name: Checkout repository
|
||||
@@ -875,7 +875,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Setup Scripts
|
||||
uses: github/gh-aw/actions/setup@58d1bedbb7200f59c2d224151339e38fd8687d05 # v0.76.1
|
||||
uses: github/gh-aw/actions/setup@4d44d0e89851a877f4ddc0cb6c0197e42b1016c5 # v0.73.0
|
||||
with:
|
||||
destination: /opt/gh-aw/actions
|
||||
- name: Download agent output artifact
|
||||
@@ -987,7 +987,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Setup Scripts
|
||||
uses: github/gh-aw/actions/setup@58d1bedbb7200f59c2d224151339e38fd8687d05 # v0.76.1
|
||||
uses: github/gh-aw/actions/setup@4d44d0e89851a877f4ddc0cb6c0197e42b1016c5 # v0.73.0
|
||||
with:
|
||||
destination: /opt/gh-aw/actions
|
||||
- name: Download agent artifacts
|
||||
@@ -1096,7 +1096,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Setup Scripts
|
||||
uses: github/gh-aw/actions/setup@58d1bedbb7200f59c2d224151339e38fd8687d05 # v0.76.1
|
||||
uses: github/gh-aw/actions/setup@4d44d0e89851a877f4ddc0cb6c0197e42b1016c5 # v0.73.0
|
||||
with:
|
||||
destination: /opt/gh-aw/actions
|
||||
- name: Add eyes reaction for immediate feedback
|
||||
@@ -1169,7 +1169,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Setup Scripts
|
||||
uses: github/gh-aw/actions/setup@58d1bedbb7200f59c2d224151339e38fd8687d05 # v0.76.1
|
||||
uses: github/gh-aw/actions/setup@4d44d0e89851a877f4ddc0cb6c0197e42b1016c5 # v0.73.0
|
||||
with:
|
||||
destination: /opt/gh-aw/actions
|
||||
- name: Download agent output artifact
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import warnings
|
||||
|
||||
from dashboard.common_methods import get_section_containers_3_levels
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
|
||||
def get_table(data):
|
||||
aux = data[
|
||||
[
|
||||
"REQUIREMENTS_ATTRIBUTES_SECTION",
|
||||
"REQUIREMENTS_ATTRIBUTES_SUBSECTION",
|
||||
"NAME",
|
||||
"CHECKID",
|
||||
"STATUS",
|
||||
"REGION",
|
||||
"ACCOUNTID",
|
||||
"RESOURCEID",
|
||||
]
|
||||
]
|
||||
|
||||
return get_section_containers_3_levels(
|
||||
aux,
|
||||
"REQUIREMENTS_ATTRIBUTES_SECTION",
|
||||
"REQUIREMENTS_ATTRIBUTES_SUBSECTION",
|
||||
"NAME",
|
||||
)
|
||||
@@ -47,7 +47,11 @@ Follow these steps to remove a user of your account:
|
||||
1. Navigate to **Users** from the side menu.
|
||||
2. Click the delete button of your current user.
|
||||
|
||||
> **Note: Each user will be able to delete himself and not others, regardless of his permissions.**
|
||||
> **Note: Each user can only delete their own account, regardless of their permissions. For this reason, the delete button is only shown on your own row and not on other users' rows.**
|
||||
|
||||
Deleting a user removes the **entire user account** from Prowler, not just its membership in your organization. Because a single account can belong to more than one tenant, allowing one administrator to delete it outright could affect organizations they don't manage and irreversibly remove another person's identity. To keep this destructive action under the control of the account owner, the API only permits a user to delete themselves (it rejects any other target with a `400` response), and the UI mirrors this by showing the delete button exclusively on your own row.
|
||||
|
||||
To remove **another** user from your organization, use the [_Expel from organization_](/user-guide/tutorials/prowler-app-multi-tenant#expelling-a-user-from-an-organization) action instead. Expelling removes the user's membership, role grants, and active sessions for your tenant only, and deletes the underlying account just for that user if your organization was their last remaining membership. This action is reserved for tenant **owners**.
|
||||
|
||||
<img src="/images/prowler-app/rbac/user_remove.png" alt="Remove User" width="700" />
|
||||
|
||||
|
||||
@@ -8,6 +8,18 @@ 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)
|
||||
- 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)
|
||||
- AWS AI Security Framework now renders in the dashboard instead of showing "No data found for this compliance", by adding the missing compliance view module [(#11470)](https://github.com/prowler-cloud/prowler/pull/11470)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+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":
|
||||
|
||||
@@ -1863,7 +1863,9 @@
|
||||
"Id": "ELB.4",
|
||||
"Name": "Application load balancers should be configured to drop HTTP headers",
|
||||
"Description": "This control evaluates AWS Application Load Balancers (ALB) to ensure they are configured to drop invalid HTTP headers. The control fails if the value of routing.http.drop_invalid_header_fields.enabled is set to false. By default, ALBs are not configured to drop invalid HTTP header values. Removing these header values prevents HTTP desync attacks.",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"elbv2_alb_drop_invalid_header_fields_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ELB.4",
|
||||
|
||||
+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}"
|
||||
|
||||
@@ -229,7 +229,9 @@ class MarkdownToADFConverter:
|
||||
return node
|
||||
|
||||
def _paragraph_with_text(self, text: str) -> Dict:
|
||||
return {"type": "paragraph", "content": [self._create_text_node(text, None)]}
|
||||
# ADF forbids empty text nodes; emit an empty paragraph instead.
|
||||
content = [self._create_text_node(text, None)] if text else []
|
||||
return {"type": "paragraph", "content": content}
|
||||
|
||||
@staticmethod
|
||||
def _pop_mark(marks_stack: List[Dict], mark_type: str) -> None:
|
||||
@@ -1118,6 +1120,18 @@ class Jira:
|
||||
tenant_info: str = "",
|
||||
) -> dict:
|
||||
|
||||
# ADF forbids empty text nodes, so Jira rejects them with 400 INVALID_INPUT.
|
||||
def _safe(value: str) -> str:
|
||||
return value if (value and value.strip()) else "-"
|
||||
|
||||
check_id = _safe(check_id)
|
||||
check_title = _safe(check_title)
|
||||
status_extended = _safe(status_extended)
|
||||
provider = _safe(provider)
|
||||
region = _safe(region)
|
||||
resource_uid = _safe(resource_uid)
|
||||
resource_name = _safe(resource_name)
|
||||
|
||||
table_rows = [
|
||||
{
|
||||
"type": "tableRow",
|
||||
|
||||
@@ -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()
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "elbv2_alb_drop_invalid_header_fields_enabled",
|
||||
"CheckTitle": "Application Load Balancer should be configured to drop invalid HTTP header fields",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
|
||||
"TTPs/Initial Access",
|
||||
"Effects/Data Exposure"
|
||||
],
|
||||
"ServiceName": "elbv2",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "AwsElbv2LoadBalancer",
|
||||
"ResourceGroup": "network",
|
||||
"Description": "Ensure that Application Load Balancers (ALB) are configured to drop invalid HTTP header fields. The check fails when `routing.http.drop_invalid_header_fields.enabled` is not set to `true`. By default, ALBs do not remove HTTP headers that do not conform to RFC 7230.",
|
||||
"Risk": "Forwarding non-RFC-compliant HTTP headers to backend targets enables HTTP desync (request smuggling):\n- **Confidentiality**: session/token theft, data exfiltration\n- **Integrity**: cache poisoning, request routing bypass, unauthorized actions\n- **Availability**: backend exhaustion.\nDropping invalid header fields removes a primary smuggling vector.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#drop-invalid-header-fields",
|
||||
"https://docs.aws.amazon.com/securityhub/latest/userguide/elb-controls.html#elb-4"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws elbv2 modify-load-balancer-attributes --load-balancer-arn <ALB_ARN> --attributes Key=routing.http.drop_invalid_header_fields.enabled,Value=true",
|
||||
"NativeIaC": "```yaml\n# CloudFormation: enable drop invalid header fields on an ALB\nResources:\n <example_resource_name>:\n Type: AWS::ElasticLoadBalancingV2::LoadBalancer\n Properties:\n Type: application\n Subnets:\n - <example_subnet_id1>\n - <example_subnet_id2>\n LoadBalancerAttributes:\n - Key: routing.http.drop_invalid_header_fields.enabled # Critical: drop non-RFC-compliant headers\n Value: true\n```",
|
||||
"Other": "1. Open the Amazon EC2 console and choose Load Balancers.\n2. Select the Application Load Balancer.\n3. On the Attributes tab, choose Edit.\n4. Set 'Drop invalid header fields' to Enabled.\n5. Save changes.",
|
||||
"Terraform": "```hcl\n# Terraform: enable drop invalid header fields on an ALB\nresource \"aws_lb\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n load_balancer_type = \"application\"\n subnets = [\"<example_subnet_id1>\", \"<example_subnet_id2>\"]\n drop_invalid_header_fields = true # Critical: drop non-RFC-compliant headers\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable 'drop invalid header fields' on Application Load Balancers so non-RFC-compliant HTTP headers are removed before requests reach backend targets, reducing exposure to HTTP desync and request smuggling. Apply defense in depth and validate requests at the application layer as well.",
|
||||
"Url": "https://hub.prowler.com/check/elbv2_alb_drop_invalid_header_fields_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.elbv2.elbv2_client import elbv2_client
|
||||
|
||||
|
||||
class elbv2_alb_drop_invalid_header_fields_enabled(Check):
|
||||
def execute(self):
|
||||
findings = []
|
||||
for lb in elbv2_client.loadbalancersv2.values():
|
||||
if lb.type == "application":
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(),
|
||||
resource=lb,
|
||||
)
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"ELBv2 ALB {lb.name} is configured to drop invalid "
|
||||
"header fields."
|
||||
)
|
||||
if lb.drop_invalid_header_fields != "true":
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"ELBv2 ALB {lb.name} is not configured to drop "
|
||||
"invalid header fields."
|
||||
)
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -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):
|
||||
|
||||
@@ -37,6 +37,7 @@ class IAM(GCPService):
|
||||
display_name=account.get("displayName", ""),
|
||||
project_id=project_id,
|
||||
uniqueId=account.get("uniqueId", ""),
|
||||
disabled=account.get("disabled", False),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -102,6 +103,7 @@ class ServiceAccount(BaseModel):
|
||||
keys: list[Key] = []
|
||||
project_id: str
|
||||
uniqueId: str
|
||||
disabled: bool = False
|
||||
|
||||
|
||||
class AccessApproval(GCPService):
|
||||
|
||||
+6
-1
@@ -19,7 +19,12 @@ class iam_service_account_unused(Check):
|
||||
resource_id=account.email,
|
||||
location=iam_client.region,
|
||||
)
|
||||
if account.uniqueId in sa_ids_used:
|
||||
if account.disabled:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Service Account {account.email} is disabled and cannot be used."
|
||||
)
|
||||
elif account.uniqueId in sa_ids_used:
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"Service Account {account.email} was used over the last {max_unused_days} days."
|
||||
else:
|
||||
|
||||
@@ -12,6 +12,7 @@ class Logging(GCPService):
|
||||
self.sinks = []
|
||||
self.metrics = []
|
||||
self._get_sinks()
|
||||
self._get_org_sinks()
|
||||
self._get_metrics()
|
||||
|
||||
def _get_sinks(self):
|
||||
@@ -39,6 +40,38 @@ class Logging(GCPService):
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _get_org_sinks(self):
|
||||
"""Fetch org-level sinks with includeChildren so child projects are not falsely failed."""
|
||||
org_ids = set()
|
||||
for project in self.projects.values():
|
||||
if project.organization:
|
||||
org_ids.add(project.organization.id)
|
||||
|
||||
for org_id in org_ids:
|
||||
try:
|
||||
request = self.client.sinks().list(parent=f"organizations/{org_id}")
|
||||
while request is not None:
|
||||
response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS)
|
||||
|
||||
for sink in response.get("sinks", []):
|
||||
self.sinks.append(
|
||||
Sink(
|
||||
name=sink["name"],
|
||||
destination=sink["destination"],
|
||||
filter=sink.get("filter", "all"),
|
||||
project_id=f"organizations/{org_id}",
|
||||
include_children=sink.get("includeChildren", False),
|
||||
)
|
||||
)
|
||||
|
||||
request = self.client.sinks().list_next(
|
||||
previous_request=request, previous_response=response
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _get_metrics(self):
|
||||
for project_id in self.project_ids:
|
||||
try:
|
||||
@@ -76,6 +109,7 @@ class Sink(BaseModel):
|
||||
destination: str
|
||||
filter: str
|
||||
project_id: str
|
||||
include_children: bool = False
|
||||
|
||||
|
||||
class Metric(BaseModel):
|
||||
|
||||
+46
-15
@@ -5,26 +5,30 @@ from prowler.providers.gcp.services.logging.logging_client import logging_client
|
||||
class logging_sink_created(Check):
|
||||
def execute(self) -> Check_Report_GCP:
|
||||
findings = []
|
||||
|
||||
# Map project_id -> sink for direct project-level sinks
|
||||
projects_with_logging_sink = {}
|
||||
for sink in logging_client.sinks:
|
||||
if sink.filter == "all":
|
||||
if sink.filter == "all" and not sink.include_children:
|
||||
projects_with_logging_sink[sink.project_id] = sink
|
||||
|
||||
# Collect org resource names that have a covering sink (includeChildren=True)
|
||||
covering_org_sinks = {}
|
||||
for sink in logging_client.sinks:
|
||||
if sink.filter == "all" and sink.include_children:
|
||||
covering_org_sinks[sink.project_id] = sink
|
||||
|
||||
for project in logging_client.project_ids:
|
||||
if project not in projects_with_logging_sink.keys():
|
||||
project_obj = logging_client.projects.get(project)
|
||||
report = Check_Report_GCP(
|
||||
metadata=self.metadata(),
|
||||
resource=project_obj,
|
||||
resource_id=project,
|
||||
project_id=project,
|
||||
location=logging_client.region,
|
||||
resource_name=(getattr(project_obj, "name", None) or "GCP Project"),
|
||||
)
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"There are no logging sinks to export copies of all the log entries in project {project}."
|
||||
findings.append(report)
|
||||
else:
|
||||
project_obj = logging_client.projects.get(project)
|
||||
|
||||
# Determine whether this project is covered by an org-level sink
|
||||
org = getattr(project_obj, "organization", None) if project_obj else None
|
||||
org_resource = f"organizations/{org.id}" if org else None
|
||||
covering_sink = (
|
||||
covering_org_sinks.get(org_resource) if org_resource else None
|
||||
)
|
||||
|
||||
if project in projects_with_logging_sink:
|
||||
sink = projects_with_logging_sink[project]
|
||||
sink_name = getattr(sink, "name", None) or "unknown"
|
||||
report = Check_Report_GCP(
|
||||
@@ -40,4 +44,31 @@ class logging_sink_created(Check):
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"Sink {sink_name} is enabled exporting copies of all the log entries in project {project}."
|
||||
findings.append(report)
|
||||
elif covering_sink:
|
||||
sink_name = getattr(covering_sink, "name", None) or "unknown"
|
||||
report = Check_Report_GCP(
|
||||
metadata=self.metadata(),
|
||||
resource=covering_sink,
|
||||
resource_id=sink_name,
|
||||
project_id=project,
|
||||
location=logging_client.region,
|
||||
resource_name=(
|
||||
sink_name if sink_name != "unknown" else "Logging Sink"
|
||||
),
|
||||
)
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"Sink {sink_name} at organization level is exporting copies of all the log entries in project {project}."
|
||||
findings.append(report)
|
||||
else:
|
||||
report = Check_Report_GCP(
|
||||
metadata=self.metadata(),
|
||||
resource=project_obj,
|
||||
resource_id=project,
|
||||
project_id=project,
|
||||
location=logging_client.region,
|
||||
resource_name=(getattr(project_obj, "name", None) or "GCP Project"),
|
||||
)
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"There are no logging sinks to export copies of all the log entries in project {project}."
|
||||
findings.append(report)
|
||||
return findings
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1004,6 +1004,89 @@ class TestJiraIntegration:
|
||||
for mark in node.get("marks", [])
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _find_empty_text_nodes(node) -> List[str]:
|
||||
# ADF forbids empty text nodes; collect any to assert the document is valid.
|
||||
empties: List[str] = []
|
||||
|
||||
def walk(current) -> None:
|
||||
if isinstance(current, dict):
|
||||
if current.get("type") == "text" and current.get("text", "") == "":
|
||||
empties.append(current.get("text", ""))
|
||||
for value in current.values():
|
||||
walk(value)
|
||||
elif isinstance(current, list):
|
||||
for item in current:
|
||||
walk(item)
|
||||
|
||||
walk(node)
|
||||
return empties
|
||||
|
||||
def test_get_adf_description_empty_resource_name_has_no_empty_text_nodes(self):
|
||||
# A resource without a name (e.g. an AWS-managed IAM policy) used to emit an
|
||||
# empty ADF text node, making Jira reject the issue with 400 INVALID_INPUT.
|
||||
adf_description = self.jira_integration.get_adf_description(
|
||||
check_id="CHECK-1",
|
||||
check_title="Sample check",
|
||||
severity="CRITICAL",
|
||||
severity_color="#FF0000",
|
||||
status="FAIL",
|
||||
status_color="#FF0000",
|
||||
status_extended="Some status",
|
||||
provider="aws",
|
||||
region="eu-west-1",
|
||||
resource_uid="arn:aws:iam::aws:policy/AdministratorAccess",
|
||||
resource_name="",
|
||||
recommendation_text="",
|
||||
)
|
||||
|
||||
assert self._find_empty_text_nodes(adf_description) == []
|
||||
|
||||
table = adf_description["content"][1]
|
||||
resource_name_row = self._find_table_row(table["content"], "Resource Name")
|
||||
value_cell = resource_name_row["content"][1]
|
||||
assert self._collect_text_from_cell(value_cell) == "-"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field, header",
|
||||
[
|
||||
("check_id", "Check Id"),
|
||||
("check_title", "Check Title"),
|
||||
("status_extended", "Status Extended"),
|
||||
("provider", "Provider"),
|
||||
("region", "Region"),
|
||||
("resource_uid", "Resource UID"),
|
||||
("resource_name", "Resource Name"),
|
||||
],
|
||||
)
|
||||
def test_get_adf_description_empty_plain_text_fields_render_placeholder(
|
||||
self, field, header
|
||||
):
|
||||
base_kwargs = dict(
|
||||
check_id="CHECK-1",
|
||||
check_title="Sample check",
|
||||
severity="HIGH",
|
||||
severity_color="#FF0000",
|
||||
status="FAIL",
|
||||
status_color="#00FF00",
|
||||
status_extended="Some status",
|
||||
provider="aws",
|
||||
region="us-east-1",
|
||||
resource_uid="resource-1",
|
||||
resource_name="resource-name",
|
||||
recommendation_text="",
|
||||
)
|
||||
base_kwargs[field] = ""
|
||||
|
||||
adf_description = self.jira_integration.get_adf_description(**base_kwargs)
|
||||
|
||||
assert self._find_empty_text_nodes(adf_description) == []
|
||||
|
||||
table = adf_description["content"][1]
|
||||
row = self._find_table_row(table["content"], header)
|
||||
value_cell = row["content"][1]
|
||||
assert self._collect_text_from_cell(value_cell) == "-"
|
||||
|
||||
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
|
||||
@patch.object(
|
||||
Jira, "get_available_issue_types", return_value=["Bug", "Task", "Story"]
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
from importlib import import_module
|
||||
from unittest import mock
|
||||
|
||||
from boto3 import client, resource
|
||||
from moto import mock_aws
|
||||
|
||||
from tests.providers.aws.utils import (
|
||||
AWS_REGION_EU_WEST_1,
|
||||
AWS_REGION_EU_WEST_1_AZA,
|
||||
AWS_REGION_EU_WEST_1_AZB,
|
||||
AWS_REGION_US_EAST_1,
|
||||
set_mocked_aws_provider,
|
||||
)
|
||||
|
||||
CHECK_MODULE = (
|
||||
"prowler.providers.aws.services.elbv2."
|
||||
"elbv2_alb_drop_invalid_header_fields_enabled."
|
||||
"elbv2_alb_drop_invalid_header_fields_enabled"
|
||||
)
|
||||
ELBV2_CLIENT_PATCH = f"{CHECK_MODULE}.elbv2_client"
|
||||
GLOBAL_PROVIDER_PATCH = ".".join(
|
||||
[
|
||||
"prowler.providers.common.provider.Provider",
|
||||
"get_global_provider",
|
||||
]
|
||||
)
|
||||
PASS_STATUS_EXTENDED = " ".join(
|
||||
[
|
||||
"ELBv2 ALB my-lb is configured to drop invalid",
|
||||
"header fields.",
|
||||
]
|
||||
)
|
||||
FAIL_STATUS_EXTENDED = (
|
||||
"ELBv2 ALB my-lb is not configured to drop invalid header fields."
|
||||
)
|
||||
|
||||
|
||||
def get_check_class():
|
||||
return getattr(
|
||||
import_module(CHECK_MODULE),
|
||||
"elbv2_alb_drop_invalid_header_fields_enabled",
|
||||
)
|
||||
|
||||
|
||||
class Test_elbv2_alb_drop_invalid_header_fields_enabled:
|
||||
@mock_aws
|
||||
def test_elb_no_balancers(self):
|
||||
from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
GLOBAL_PROVIDER_PATCH,
|
||||
return_value=set_mocked_aws_provider(
|
||||
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
|
||||
),
|
||||
),
|
||||
mock.patch(
|
||||
ELBV2_CLIENT_PATCH,
|
||||
new=ELBv2(
|
||||
set_mocked_aws_provider(
|
||||
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1],
|
||||
create_default_organization=False,
|
||||
)
|
||||
),
|
||||
),
|
||||
):
|
||||
check = get_check_class()()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
@mock_aws
|
||||
def test_elbv2_dropping_invalid_header_fields(self):
|
||||
conn = client("elbv2", region_name=AWS_REGION_EU_WEST_1)
|
||||
ec2 = resource("ec2", region_name=AWS_REGION_EU_WEST_1)
|
||||
|
||||
security_group = ec2.create_security_group(
|
||||
GroupName="a-security-group", Description="First One"
|
||||
)
|
||||
vpc = ec2.create_vpc(
|
||||
CidrBlock="172.28.7.0/24",
|
||||
InstanceTenancy="default",
|
||||
)
|
||||
subnet1 = ec2.create_subnet(
|
||||
VpcId=vpc.id,
|
||||
CidrBlock="172.28.7.192/26",
|
||||
AvailabilityZone=AWS_REGION_EU_WEST_1_AZA,
|
||||
)
|
||||
subnet2 = ec2.create_subnet(
|
||||
VpcId=vpc.id,
|
||||
CidrBlock="172.28.7.0/26",
|
||||
AvailabilityZone=AWS_REGION_EU_WEST_1_AZB,
|
||||
)
|
||||
|
||||
lb = conn.create_load_balancer(
|
||||
Name="my-lb",
|
||||
Subnets=[subnet1.id, subnet2.id],
|
||||
SecurityGroups=[security_group.id],
|
||||
Scheme="internal",
|
||||
Type="application",
|
||||
)["LoadBalancers"][0]
|
||||
|
||||
conn.modify_load_balancer_attributes(
|
||||
LoadBalancerArn=lb["LoadBalancerArn"],
|
||||
Attributes=[
|
||||
{
|
||||
"Key": "routing.http.drop_invalid_header_fields.enabled",
|
||||
"Value": "true",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
GLOBAL_PROVIDER_PATCH,
|
||||
return_value=set_mocked_aws_provider(
|
||||
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
|
||||
),
|
||||
),
|
||||
mock.patch(
|
||||
ELBV2_CLIENT_PATCH,
|
||||
new=ELBv2(
|
||||
set_mocked_aws_provider(
|
||||
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1],
|
||||
create_default_organization=False,
|
||||
)
|
||||
),
|
||||
),
|
||||
):
|
||||
check = get_check_class()()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert result[0].status_extended == PASS_STATUS_EXTENDED
|
||||
assert result[0].resource_id == "my-lb"
|
||||
assert result[0].resource_arn == lb["LoadBalancerArn"]
|
||||
|
||||
@mock_aws
|
||||
def test_elbv2_not_dropping_invalid_header_fields(self):
|
||||
conn = client("elbv2", region_name=AWS_REGION_EU_WEST_1)
|
||||
ec2 = resource("ec2", region_name=AWS_REGION_EU_WEST_1)
|
||||
|
||||
security_group = ec2.create_security_group(
|
||||
GroupName="a-security-group", Description="First One"
|
||||
)
|
||||
vpc = ec2.create_vpc(
|
||||
CidrBlock="172.28.7.0/24",
|
||||
InstanceTenancy="default",
|
||||
)
|
||||
subnet1 = ec2.create_subnet(
|
||||
VpcId=vpc.id,
|
||||
CidrBlock="172.28.7.192/26",
|
||||
AvailabilityZone=AWS_REGION_EU_WEST_1_AZA,
|
||||
)
|
||||
subnet2 = ec2.create_subnet(
|
||||
VpcId=vpc.id,
|
||||
CidrBlock="172.28.7.0/26",
|
||||
AvailabilityZone=AWS_REGION_EU_WEST_1_AZB,
|
||||
)
|
||||
|
||||
lb = conn.create_load_balancer(
|
||||
Name="my-lb",
|
||||
Subnets=[subnet1.id, subnet2.id],
|
||||
SecurityGroups=[security_group.id],
|
||||
Scheme="internal",
|
||||
Type="application",
|
||||
)["LoadBalancers"][0]
|
||||
|
||||
conn.modify_load_balancer_attributes(
|
||||
LoadBalancerArn=lb["LoadBalancerArn"],
|
||||
Attributes=[
|
||||
{
|
||||
"Key": "routing.http.drop_invalid_header_fields.enabled",
|
||||
"Value": "false",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
GLOBAL_PROVIDER_PATCH,
|
||||
return_value=set_mocked_aws_provider(
|
||||
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
|
||||
),
|
||||
),
|
||||
mock.patch(
|
||||
ELBV2_CLIENT_PATCH,
|
||||
new=ELBv2(
|
||||
set_mocked_aws_provider(
|
||||
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1],
|
||||
create_default_organization=False,
|
||||
)
|
||||
),
|
||||
),
|
||||
):
|
||||
check = get_check_class()()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert result[0].status_extended == FAIL_STATUS_EXTENDED
|
||||
assert result[0].resource_id == "my-lb"
|
||||
assert result[0].resource_arn == lb["LoadBalancerArn"]
|
||||
|
||||
@mock_aws
|
||||
def test_elbv2_network_load_balancer_ignored(self):
|
||||
conn = client("elbv2", region_name=AWS_REGION_EU_WEST_1)
|
||||
ec2 = resource("ec2", region_name=AWS_REGION_EU_WEST_1)
|
||||
|
||||
vpc = ec2.create_vpc(
|
||||
CidrBlock="172.28.7.0/24",
|
||||
InstanceTenancy="default",
|
||||
)
|
||||
subnet1 = ec2.create_subnet(
|
||||
VpcId=vpc.id,
|
||||
CidrBlock="172.28.7.192/26",
|
||||
AvailabilityZone=AWS_REGION_EU_WEST_1_AZA,
|
||||
)
|
||||
|
||||
conn.create_load_balancer(
|
||||
Name="my-nlb",
|
||||
Subnets=[subnet1.id],
|
||||
Scheme="internal",
|
||||
Type="network",
|
||||
)
|
||||
|
||||
from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
GLOBAL_PROVIDER_PATCH,
|
||||
return_value=set_mocked_aws_provider(
|
||||
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
|
||||
),
|
||||
),
|
||||
mock.patch(
|
||||
ELBV2_CLIENT_PATCH,
|
||||
new=ELBv2(
|
||||
set_mocked_aws_provider(
|
||||
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1],
|
||||
create_default_organization=False,
|
||||
)
|
||||
),
|
||||
),
|
||||
):
|
||||
check = get_check_class()()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 0
|
||||
File diff suppressed because it is too large
Load Diff
+57
@@ -179,3 +179,60 @@ class Test_iam_service_account_unused:
|
||||
assert result[1].project_id == GCP_PROJECT_ID
|
||||
assert result[1].location == GCP_US_CENTER1_LOCATION
|
||||
assert result[1].resource == iam_client.service_accounts[1]
|
||||
|
||||
def test_iam_service_account_disabled(self):
|
||||
iam_client = mock.MagicMock()
|
||||
monitoring_client = mock.MagicMock()
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_gcp_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.gcp.services.iam.iam_service_account_unused.iam_service_account_unused.iam_client",
|
||||
new=iam_client,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.gcp.services.iam.iam_service_account_unused.iam_service_account_unused.monitoring_client",
|
||||
new=monitoring_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.gcp.services.iam.iam_service import ServiceAccount
|
||||
from prowler.providers.gcp.services.iam.iam_service_account_unused.iam_service_account_unused import (
|
||||
iam_service_account_unused,
|
||||
)
|
||||
|
||||
iam_client.project_ids = [GCP_PROJECT_ID]
|
||||
iam_client.region = GCP_US_CENTER1_LOCATION
|
||||
|
||||
iam_client.service_accounts = [
|
||||
ServiceAccount(
|
||||
name="projects/my-project/serviceAccounts/disabled-sa@my-project.iam.gserviceaccount.com",
|
||||
email="disabled-sa@my-project.iam.gserviceaccount.com",
|
||||
display_name="Disabled service account",
|
||||
keys=[],
|
||||
project_id=GCP_PROJECT_ID,
|
||||
uniqueId="999888877776666",
|
||||
disabled=True,
|
||||
)
|
||||
]
|
||||
|
||||
# The account is absent from the usage metrics, so a non-disabled
|
||||
# account here would FAIL. Being disabled must take precedence and
|
||||
# PASS, since a disabled account cannot authenticate or be used.
|
||||
monitoring_client.sa_api_metrics = set()
|
||||
monitoring_client.audit_config = {"max_unused_account_days": 30}
|
||||
|
||||
check = iam_service_account_unused()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== f"Service Account {iam_client.service_accounts[0].email} is disabled and cannot be used."
|
||||
)
|
||||
assert result[0].resource_id == iam_client.service_accounts[0].email
|
||||
assert result[0].project_id == GCP_PROJECT_ID
|
||||
assert result[0].location == GCP_US_CENTER1_LOCATION
|
||||
assert result[0].resource == iam_client.service_accounts[0]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from prowler.providers.gcp.services.logging.logging_service import Logging
|
||||
from tests.providers.gcp.gcp_fixtures import (
|
||||
@@ -66,3 +66,74 @@ class TestLoggingService:
|
||||
== "resource.type=gae_app AND severity>=ERROR"
|
||||
)
|
||||
assert logging_client.metrics[1].project_id == GCP_PROJECT_ID
|
||||
|
||||
def test_org_sinks_fetched_when_project_has_organization(self):
|
||||
"""_get_org_sinks() appends org-level sinks when projects have an org."""
|
||||
from prowler.providers.gcp.models import GCPOrganization, GCPProject
|
||||
|
||||
org_id = "999888777"
|
||||
provider = set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID])
|
||||
provider.projects = {
|
||||
GCP_PROJECT_ID: GCPProject(
|
||||
id=GCP_PROJECT_ID,
|
||||
number="123456789012",
|
||||
name="test",
|
||||
labels={},
|
||||
lifecycle_state="ACTIVE",
|
||||
organization=GCPOrganization(id=org_id, name=f"organizations/{org_id}"),
|
||||
)
|
||||
}
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.sinks().list().execute.return_value = {
|
||||
"sinks": [
|
||||
{
|
||||
"name": "org-sink",
|
||||
"destination": "storage.googleapis.com/org-bucket",
|
||||
"filter": "all",
|
||||
"includeChildren": True,
|
||||
}
|
||||
]
|
||||
}
|
||||
mock_client.sinks().list_next.return_value = None
|
||||
mock_client.projects().metrics().list().execute.return_value = {"metrics": []}
|
||||
mock_client.projects().metrics().list_next.return_value = None
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__",
|
||||
new=mock_is_api_active,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.gcp.lib.service.service.GCPService.__generate_client__",
|
||||
return_value=mock_client,
|
||||
),
|
||||
):
|
||||
logging_svc = Logging(provider)
|
||||
|
||||
org_sinks = [
|
||||
s for s in logging_svc.sinks if s.project_id == f"organizations/{org_id}"
|
||||
]
|
||||
assert len(org_sinks) == 1
|
||||
assert org_sinks[0].name == "org-sink"
|
||||
assert org_sinks[0].include_children is True
|
||||
assert org_sinks[0].filter == "all"
|
||||
|
||||
def test_org_sinks_skipped_when_no_organization(self):
|
||||
"""_get_org_sinks() adds nothing when projects have no organization."""
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__",
|
||||
new=mock_is_api_active,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.gcp.lib.service.service.GCPService.__generate_client__",
|
||||
new=mock_api_client,
|
||||
),
|
||||
):
|
||||
logging_svc = Logging(set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]))
|
||||
|
||||
org_sinks = [
|
||||
s for s in logging_svc.sinks if s.project_id.startswith("organizations/")
|
||||
]
|
||||
assert org_sinks == []
|
||||
|
||||
+176
-2
@@ -1,6 +1,6 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from prowler.providers.gcp.models import GCPProject
|
||||
from prowler.providers.gcp.models import GCPOrganization, GCPProject
|
||||
from tests.providers.gcp.gcp_fixtures import (
|
||||
GCP_EU1_LOCATION,
|
||||
GCP_PROJECT_ID,
|
||||
@@ -268,6 +268,7 @@ class Test_logging_sink_created:
|
||||
sink.name = None
|
||||
sink.filter = "all"
|
||||
sink.project_id = GCP_PROJECT_ID
|
||||
sink.include_children = False
|
||||
|
||||
logging_client.project_ids = [GCP_PROJECT_ID]
|
||||
logging_client.region = GCP_EU1_LOCATION
|
||||
@@ -311,9 +312,10 @@ class Test_logging_sink_created:
|
||||
)
|
||||
|
||||
# Create a MagicMock sink object without name attribute
|
||||
sink = MagicMock(spec=["filter", "project_id"])
|
||||
sink = MagicMock(spec=["filter", "project_id", "include_children"])
|
||||
sink.filter = "all"
|
||||
sink.project_id = GCP_PROJECT_ID
|
||||
sink.include_children = False
|
||||
|
||||
logging_client.project_ids = [GCP_PROJECT_ID]
|
||||
logging_client.region = GCP_EU1_LOCATION
|
||||
@@ -336,3 +338,175 @@ class Test_logging_sink_created:
|
||||
assert result[0].resource_id == "unknown"
|
||||
assert result[0].project_id == GCP_PROJECT_ID
|
||||
assert result[0].location == GCP_EU1_LOCATION
|
||||
|
||||
def test_org_level_sink_with_include_children_passes(self):
|
||||
"""Projects covered by an org-level sink with includeChildren=True should PASS."""
|
||||
logging_client = MagicMock()
|
||||
org_id = "111222333"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_gcp_provider(),
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client",
|
||||
new=logging_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.gcp.services.logging.logging_service import Sink
|
||||
from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import (
|
||||
logging_sink_created,
|
||||
)
|
||||
|
||||
logging_client.project_ids = [GCP_PROJECT_ID]
|
||||
logging_client.region = GCP_EU1_LOCATION
|
||||
logging_client.sinks = [
|
||||
Sink(
|
||||
name="org-sink",
|
||||
destination="storage.googleapis.com/org-bucket",
|
||||
filter="all",
|
||||
project_id=f"organizations/{org_id}",
|
||||
include_children=True,
|
||||
)
|
||||
]
|
||||
logging_client.projects = {
|
||||
GCP_PROJECT_ID: GCPProject(
|
||||
id=GCP_PROJECT_ID,
|
||||
number="123456789012",
|
||||
name="test",
|
||||
labels={},
|
||||
lifecycle_state="ACTIVE",
|
||||
organization=GCPOrganization(
|
||||
id=org_id, name=f"organizations/{org_id}"
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
check = logging_sink_created()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== f"Sink org-sink at organization level is exporting copies of all the log entries in project {GCP_PROJECT_ID}."
|
||||
)
|
||||
assert result[0].resource_id == "org-sink"
|
||||
assert result[0].project_id == GCP_PROJECT_ID
|
||||
assert result[0].location == GCP_EU1_LOCATION
|
||||
|
||||
def test_org_level_sink_without_include_children_fails(self):
|
||||
"""Projects NOT covered by includeChildren should still FAIL if no direct project sink."""
|
||||
logging_client = MagicMock()
|
||||
org_id = "111222333"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_gcp_provider(),
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client",
|
||||
new=logging_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.gcp.services.logging.logging_service import Sink
|
||||
from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import (
|
||||
logging_sink_created,
|
||||
)
|
||||
|
||||
logging_client.project_ids = [GCP_PROJECT_ID]
|
||||
logging_client.region = GCP_EU1_LOCATION
|
||||
logging_client.sinks = [
|
||||
Sink(
|
||||
name="org-sink-no-children",
|
||||
destination="storage.googleapis.com/org-bucket",
|
||||
filter="all",
|
||||
project_id=f"organizations/{org_id}",
|
||||
include_children=False,
|
||||
)
|
||||
]
|
||||
logging_client.projects = {
|
||||
GCP_PROJECT_ID: GCPProject(
|
||||
id=GCP_PROJECT_ID,
|
||||
number="123456789012",
|
||||
name="test",
|
||||
labels={},
|
||||
lifecycle_state="ACTIVE",
|
||||
organization=GCPOrganization(
|
||||
id=org_id, name=f"organizations/{org_id}"
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
check = logging_sink_created()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== f"There are no logging sinks to export copies of all the log entries in project {GCP_PROJECT_ID}."
|
||||
)
|
||||
assert result[0].resource_id == GCP_PROJECT_ID
|
||||
assert result[0].project_id == GCP_PROJECT_ID
|
||||
|
||||
def test_project_sink_takes_precedence_over_org_sink(self):
|
||||
"""A direct project sink should be reported even when an org-level sink also covers the project."""
|
||||
logging_client = MagicMock()
|
||||
org_id = "111222333"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_gcp_provider(),
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client",
|
||||
new=logging_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.gcp.services.logging.logging_service import Sink
|
||||
from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import (
|
||||
logging_sink_created,
|
||||
)
|
||||
|
||||
logging_client.project_ids = [GCP_PROJECT_ID]
|
||||
logging_client.region = GCP_EU1_LOCATION
|
||||
logging_client.sinks = [
|
||||
Sink(
|
||||
name="project-sink",
|
||||
destination="storage.googleapis.com/project-bucket",
|
||||
filter="all",
|
||||
project_id=GCP_PROJECT_ID,
|
||||
),
|
||||
Sink(
|
||||
name="org-sink",
|
||||
destination="storage.googleapis.com/org-bucket",
|
||||
filter="all",
|
||||
project_id=f"organizations/{org_id}",
|
||||
include_children=True,
|
||||
),
|
||||
]
|
||||
logging_client.projects = {
|
||||
GCP_PROJECT_ID: GCPProject(
|
||||
id=GCP_PROJECT_ID,
|
||||
number="123456789012",
|
||||
name="test",
|
||||
labels={},
|
||||
lifecycle_state="ACTIVE",
|
||||
organization=GCPOrganization(
|
||||
id=org_id, name=f"organizations/{org_id}"
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
check = logging_sink_created()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== f"Sink project-sink is enabled exporting copies of all the log entries in project {GCP_PROJECT_ID}."
|
||||
)
|
||||
assert result[0].resource_id == "project-sink"
|
||||
assert result[0].project_id == GCP_PROJECT_ID
|
||||
|
||||
@@ -10,6 +10,23 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
---
|
||||
|
||||
## [1.29.2] (Prowler v5.29.2)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Account and provider-type selector triggers now show the provider icon, with a non-deduped icon stack [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Add Provider modal now closes without reloading the providers page [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424)
|
||||
- Users page now shows the "Delete User" action only on the current user's row, matching the backend rule that a user can only delete their own account [(#11447)](https://github.com/prowler-cloud/prowler/pull/11447)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Vitest toolchain upgraded `4.0.18` → `4.1.8` to clear two critical `pnpm audit` advisories [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424)
|
||||
|
||||
---
|
||||
|
||||
## [1.29.0] (Prowler v5.29.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
QueryResultAttributes,
|
||||
} from "@/types/attack-paths";
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_BASE_URL!;
|
||||
const API = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
type JsonApiErrorBody = {
|
||||
errors: Array<{ detail: string; status: string }>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -57,7 +57,7 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
|
||||
);
|
||||
},
|
||||
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
<div data-testid="trigger">{children}</div>
|
||||
),
|
||||
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
|
||||
<span>{placeholder}</span>
|
||||
@@ -220,4 +220,45 @@ describe("AccountsSelector", () => {
|
||||
|
||||
expect(multiSelectSpy).toHaveBeenLastCalledWith({ open: false });
|
||||
});
|
||||
|
||||
it("shows the provider icon next to the name in the trigger for a single selection", async () => {
|
||||
render(
|
||||
<AccountsSelector
|
||||
providers={providers}
|
||||
onBatchChange={vi.fn()}
|
||||
selectedValues={["provider-1"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByTestId("trigger");
|
||||
expect(await within(trigger).findByText("AWS")).toBeInTheDocument();
|
||||
expect(within(trigger).getByText("Production AWS")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders one icon per selected account without deduping by provider type", async () => {
|
||||
const secondAws = {
|
||||
...providers[0],
|
||||
id: "provider-2",
|
||||
attributes: {
|
||||
...providers[0].attributes,
|
||||
uid: "999999999999",
|
||||
alias: "Staging AWS",
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<AccountsSelector
|
||||
providers={[providers[0], secondAws]}
|
||||
onBatchChange={vi.fn()}
|
||||
selectedValues={["provider-1", "provider-2"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByTestId("trigger");
|
||||
// Two AWS accounts -> two AWS icons in the trigger (no dedupe).
|
||||
expect(await within(trigger).findAllByText("AWS")).toHaveLength(2);
|
||||
expect(
|
||||
within(trigger).getByText("2 Providers selected"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
AlibabaCloudProviderBadge,
|
||||
AWSProviderBadge,
|
||||
AzureProviderBadge,
|
||||
CloudflareProviderBadge,
|
||||
GCPProviderBadge,
|
||||
GitHubProviderBadge,
|
||||
GoogleWorkspaceProviderBadge,
|
||||
IacProviderBadge,
|
||||
ImageProviderBadge,
|
||||
KS8ProviderBadge,
|
||||
M365ProviderBadge,
|
||||
MongoDBAtlasProviderBadge,
|
||||
OktaProviderBadge,
|
||||
OpenStackProviderBadge,
|
||||
OracleCloudProviderBadge,
|
||||
VercelProviderBadge,
|
||||
} from "@/components/icons/providers-badge";
|
||||
ProviderTypeIcon,
|
||||
ProviderTypeIconStack,
|
||||
} from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { Badge } from "@/components/shadcn";
|
||||
import {
|
||||
MultiSelect,
|
||||
@@ -45,25 +31,6 @@ const ACCOUNT_SELECTOR_FILTER = {
|
||||
type AccountSelectorFilter =
|
||||
(typeof ACCOUNT_SELECTOR_FILTER)[keyof typeof ACCOUNT_SELECTOR_FILTER];
|
||||
|
||||
const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
|
||||
aws: <AWSProviderBadge width={18} height={18} />,
|
||||
azure: <AzureProviderBadge width={18} height={18} />,
|
||||
gcp: <GCPProviderBadge width={18} height={18} />,
|
||||
kubernetes: <KS8ProviderBadge width={18} height={18} />,
|
||||
m365: <M365ProviderBadge width={18} height={18} />,
|
||||
github: <GitHubProviderBadge width={18} height={18} />,
|
||||
googleworkspace: <GoogleWorkspaceProviderBadge width={18} height={18} />,
|
||||
iac: <IacProviderBadge width={18} height={18} />,
|
||||
image: <ImageProviderBadge width={18} height={18} />,
|
||||
oraclecloud: <OracleCloudProviderBadge width={18} height={18} />,
|
||||
mongodbatlas: <MongoDBAtlasProviderBadge width={18} height={18} />,
|
||||
alibabacloud: <AlibabaCloudProviderBadge width={18} height={18} />,
|
||||
cloudflare: <CloudflareProviderBadge width={18} height={18} />,
|
||||
openstack: <OpenStackProviderBadge width={18} height={18} />,
|
||||
vercel: <VercelProviderBadge width={18} height={18} />,
|
||||
okta: <OktaProviderBadge width={18} height={18} />,
|
||||
};
|
||||
|
||||
/** Common props shared by both batch and instant modes. */
|
||||
interface AccountsSelectorBaseProps {
|
||||
providers: ProviderProps[];
|
||||
@@ -158,10 +125,36 @@ export function AccountsSelector({
|
||||
if (selectedIds.length === 1) {
|
||||
const p = providers.find((pr) => getProviderValue(pr) === selectedIds[0]);
|
||||
const name = p ? p.attributes.alias || p.attributes.uid : selectedIds[0];
|
||||
return <span className="truncate">{name}</span>;
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{p && (
|
||||
<span aria-hidden="true">
|
||||
<ProviderTypeIcon type={p.attributes.provider} />
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{name}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// One icon per selected account (no dedupe): two accounts of the same
|
||||
// provider show two icons, disambiguated by the UID tooltip on hover.
|
||||
const items = selectedIds
|
||||
.map((selectedId) =>
|
||||
providers.find((pr) => getProviderValue(pr) === selectedId),
|
||||
)
|
||||
.filter((p): p is ProviderProps => Boolean(p))
|
||||
.map((p) => ({
|
||||
key: p.id,
|
||||
type: p.attributes.provider as ProviderType,
|
||||
tooltip: p.attributes.uid,
|
||||
}));
|
||||
return (
|
||||
<span className="truncate">{selectedIds.length} Providers selected</span>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ProviderTypeIconStack items={items} />
|
||||
<span className="truncate">
|
||||
{selectedIds.length} Providers selected
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -208,7 +201,6 @@ export function AccountsSelector({
|
||||
const isDisabled = disabledValuesSet.has(value);
|
||||
const displayName = p.attributes.alias || p.attributes.uid;
|
||||
const providerType = p.attributes.provider as ProviderType;
|
||||
const icon = PROVIDER_ICON[providerType];
|
||||
const searchKeywords = [
|
||||
displayName,
|
||||
p.attributes.alias,
|
||||
@@ -228,7 +220,9 @@ export function AccountsSelector({
|
||||
if (closeOnSelect) setSelectorOpen(false);
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true">{icon}</span>
|
||||
<span aria-hidden="true">
|
||||
<ProviderTypeIcon type={providerType} />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className="truncate">{displayName}</span>
|
||||
{isDisabled && <Badge variant="tag">Disconnected</Badge>}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ProviderTypeSelector } from "./provider-type-selector";
|
||||
@@ -39,7 +39,7 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
|
||||
<div>{children}</div>
|
||||
),
|
||||
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
<div data-testid="trigger">{children}</div>
|
||||
),
|
||||
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
|
||||
<span>{placeholder}</span>
|
||||
@@ -145,4 +145,26 @@ describe("ProviderTypeSelector", () => {
|
||||
).toHaveAttribute("aria-disabled", "true");
|
||||
expect(screen.getByText("All selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows one icon per selected type and a count in the trigger", async () => {
|
||||
const azure = {
|
||||
...providers[0],
|
||||
id: "provider-2",
|
||||
attributes: { ...providers[0].attributes, provider: "azure" as const },
|
||||
};
|
||||
|
||||
render(
|
||||
<ProviderTypeSelector
|
||||
providers={[providers[0], azure]}
|
||||
onBatchChange={vi.fn()}
|
||||
selectedValues={["aws", "azure"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByTestId("trigger");
|
||||
expect(await within(trigger).findByText("AWS")).toBeInTheDocument();
|
||||
expect(
|
||||
within(trigger).getByText("2 Provider Types selected"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { type ComponentType, lazy, Suspense } from "react";
|
||||
|
||||
import {
|
||||
PROVIDER_TYPE_DATA,
|
||||
ProviderTypeIcon,
|
||||
ProviderTypeIconStack,
|
||||
} from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import {
|
||||
MultiSelect,
|
||||
MultiSelectContent,
|
||||
@@ -14,163 +18,6 @@ import {
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
import { type ProviderProps, ProviderType } from "@/types/providers";
|
||||
|
||||
const AWSProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.AWSProviderBadge,
|
||||
})),
|
||||
);
|
||||
const AzureProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.AzureProviderBadge,
|
||||
})),
|
||||
);
|
||||
const GCPProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.GCPProviderBadge,
|
||||
})),
|
||||
);
|
||||
const KS8ProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.KS8ProviderBadge,
|
||||
})),
|
||||
);
|
||||
const M365ProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.M365ProviderBadge,
|
||||
})),
|
||||
);
|
||||
const GitHubProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.GitHubProviderBadge,
|
||||
})),
|
||||
);
|
||||
const IacProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.IacProviderBadge,
|
||||
})),
|
||||
);
|
||||
const ImageProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.ImageProviderBadge,
|
||||
})),
|
||||
);
|
||||
const OracleCloudProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.OracleCloudProviderBadge,
|
||||
})),
|
||||
);
|
||||
const MongoDBAtlasProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.MongoDBAtlasProviderBadge,
|
||||
})),
|
||||
);
|
||||
const AlibabaCloudProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.AlibabaCloudProviderBadge,
|
||||
})),
|
||||
);
|
||||
const CloudflareProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.CloudflareProviderBadge,
|
||||
})),
|
||||
);
|
||||
const OpenStackProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.OpenStackProviderBadge,
|
||||
})),
|
||||
);
|
||||
const GoogleWorkspaceProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.GoogleWorkspaceProviderBadge,
|
||||
})),
|
||||
);
|
||||
const VercelProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.VercelProviderBadge,
|
||||
})),
|
||||
);
|
||||
const OktaProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.OktaProviderBadge,
|
||||
})),
|
||||
);
|
||||
|
||||
type IconProps = { width: number; height: number };
|
||||
|
||||
const IconPlaceholder = ({ width, height }: IconProps) => (
|
||||
<div style={{ width, height }} />
|
||||
);
|
||||
|
||||
const PROVIDER_DATA: Record<
|
||||
ProviderType,
|
||||
{ label: string; icon: ComponentType<IconProps> }
|
||||
> = {
|
||||
aws: {
|
||||
label: "Amazon Web Services",
|
||||
icon: AWSProviderBadge,
|
||||
},
|
||||
azure: {
|
||||
label: "Microsoft Azure",
|
||||
icon: AzureProviderBadge,
|
||||
},
|
||||
gcp: {
|
||||
label: "Google Cloud Platform",
|
||||
icon: GCPProviderBadge,
|
||||
},
|
||||
kubernetes: {
|
||||
label: "Kubernetes",
|
||||
icon: KS8ProviderBadge,
|
||||
},
|
||||
m365: {
|
||||
label: "Microsoft 365",
|
||||
icon: M365ProviderBadge,
|
||||
},
|
||||
github: {
|
||||
label: "GitHub",
|
||||
icon: GitHubProviderBadge,
|
||||
},
|
||||
googleworkspace: {
|
||||
label: "Google Workspace",
|
||||
icon: GoogleWorkspaceProviderBadge,
|
||||
},
|
||||
iac: {
|
||||
label: "Infrastructure as Code",
|
||||
icon: IacProviderBadge,
|
||||
},
|
||||
image: {
|
||||
label: "Container Registry",
|
||||
icon: ImageProviderBadge,
|
||||
},
|
||||
oraclecloud: {
|
||||
label: "Oracle Cloud Infrastructure",
|
||||
icon: OracleCloudProviderBadge,
|
||||
},
|
||||
mongodbatlas: {
|
||||
label: "MongoDB Atlas",
|
||||
icon: MongoDBAtlasProviderBadge,
|
||||
},
|
||||
alibabacloud: {
|
||||
label: "Alibaba Cloud",
|
||||
icon: AlibabaCloudProviderBadge,
|
||||
},
|
||||
cloudflare: {
|
||||
label: "Cloudflare",
|
||||
icon: CloudflareProviderBadge,
|
||||
},
|
||||
openstack: {
|
||||
label: "OpenStack",
|
||||
icon: OpenStackProviderBadge,
|
||||
},
|
||||
vercel: {
|
||||
label: "Vercel",
|
||||
icon: VercelProviderBadge,
|
||||
},
|
||||
okta: {
|
||||
label: "Okta",
|
||||
icon: OktaProviderBadge,
|
||||
},
|
||||
};
|
||||
|
||||
/** Common props shared by both batch and instant modes. */
|
||||
interface ProviderTypeSelectorBaseProps {
|
||||
providers: ProviderProps[];
|
||||
@@ -247,34 +94,38 @@ export const ProviderTypeSelector = ({
|
||||
.map((p) => p.attributes.provider),
|
||||
),
|
||||
)
|
||||
.filter((type): type is ProviderType => type in PROVIDER_DATA)
|
||||
.filter((type): type is ProviderType => type in PROVIDER_TYPE_DATA)
|
||||
.sort((a, b) =>
|
||||
PROVIDER_DATA[a].label.localeCompare(PROVIDER_DATA[b].label),
|
||||
PROVIDER_TYPE_DATA[a].label.localeCompare(PROVIDER_TYPE_DATA[b].label),
|
||||
);
|
||||
|
||||
const renderIcon = (providerType: ProviderType) => {
|
||||
const IconComponent = PROVIDER_DATA[providerType].icon;
|
||||
return (
|
||||
<Suspense fallback={<IconPlaceholder width={24} height={24} />}>
|
||||
<IconComponent width={24} height={24} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
const selectedLabel = () => {
|
||||
if (selectedTypes.length === 0) return null;
|
||||
if (selectedTypes.length === 1) {
|
||||
const providerType = selectedTypes[0] as ProviderType;
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{renderIcon(providerType)}
|
||||
<span className="truncate">{PROVIDER_DATA[providerType].label}</span>
|
||||
<span aria-hidden="true">
|
||||
<ProviderTypeIcon type={providerType} />
|
||||
</span>
|
||||
<span className="truncate">
|
||||
{PROVIDER_TYPE_DATA[providerType].label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="min-w-0 truncate">
|
||||
{selectedTypes.length} Provider Types selected
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ProviderTypeIconStack
|
||||
items={(selectedTypes as ProviderType[]).map((type) => ({
|
||||
key: type,
|
||||
type,
|
||||
tooltip: PROVIDER_TYPE_DATA[type].label,
|
||||
}))}
|
||||
/>
|
||||
<span className="min-w-0 truncate">
|
||||
{selectedTypes.length} Provider Types selected
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -329,12 +180,17 @@ export const ProviderTypeSelector = ({
|
||||
<MultiSelectItem
|
||||
key={providerType}
|
||||
value={providerType}
|
||||
badgeLabel={PROVIDER_DATA[providerType].label}
|
||||
keywords={[providerType, PROVIDER_DATA[providerType].label]}
|
||||
aria-label={`${PROVIDER_DATA[providerType].label} Provider Type`}
|
||||
badgeLabel={PROVIDER_TYPE_DATA[providerType].label}
|
||||
keywords={[
|
||||
providerType,
|
||||
PROVIDER_TYPE_DATA[providerType].label,
|
||||
]}
|
||||
aria-label={`${PROVIDER_TYPE_DATA[providerType].label} Provider Type`}
|
||||
>
|
||||
<span aria-hidden="true">{renderIcon(providerType)}</span>
|
||||
<span>{PROVIDER_DATA[providerType].label}</span>
|
||||
<span aria-hidden="true">
|
||||
<ProviderTypeIcon type={providerType} size={24} />
|
||||
</span>
|
||||
<span>{PROVIDER_TYPE_DATA[providerType].label}</span>
|
||||
</MultiSelectItem>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -109,6 +109,9 @@ const SSRDataTable = async ({
|
||||
roles,
|
||||
canBeExpelled,
|
||||
currentTenantId: canBeExpelled ? currentTenantId : undefined,
|
||||
// Users may only delete their own account; gate the delete action so the
|
||||
// UI matches the backend rule and never offers an action that would fail.
|
||||
isCurrentUser: user.id === currentUserId,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ProviderType } from "@/types/providers";
|
||||
|
||||
import { ProviderIconCell } from "./provider-icon-cell";
|
||||
|
||||
// Render the lazy provider badges as plain text so we can assert on them. The
|
||||
// real PROVIDER_TYPE_DATA map (and its `in` guard) is exercised on purpose.
|
||||
vi.mock("@/components/icons/providers-badge", () => ({
|
||||
AWSProviderBadge: () => <span>AWS</span>,
|
||||
AzureProviderBadge: () => <span>Azure</span>,
|
||||
GCPProviderBadge: () => <span>GCP</span>,
|
||||
KS8ProviderBadge: () => <span>Kubernetes</span>,
|
||||
M365ProviderBadge: () => <span>M365</span>,
|
||||
GitHubProviderBadge: () => <span>GitHub</span>,
|
||||
GoogleWorkspaceProviderBadge: () => <span>Google Workspace</span>,
|
||||
IacProviderBadge: () => <span>IaC</span>,
|
||||
ImageProviderBadge: () => <span>Image</span>,
|
||||
OracleCloudProviderBadge: () => <span>Oracle Cloud</span>,
|
||||
MongoDBAtlasProviderBadge: () => <span>MongoDB Atlas</span>,
|
||||
AlibabaCloudProviderBadge: () => <span>Alibaba Cloud</span>,
|
||||
CloudflareProviderBadge: () => <span>Cloudflare</span>,
|
||||
OpenStackProviderBadge: () => <span>OpenStack</span>,
|
||||
VercelProviderBadge: () => <span>Vercel</span>,
|
||||
OktaProviderBadge: () => <span>Okta</span>,
|
||||
}));
|
||||
|
||||
describe("ProviderIconCell", () => {
|
||||
it("renders the shared provider-type icon for a known provider", async () => {
|
||||
render(<ProviderIconCell provider="aws" />);
|
||||
|
||||
expect(await screen.findByText("AWS")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a '?' placeholder for a provider type missing from the map", () => {
|
||||
render(
|
||||
<ProviderIconCell
|
||||
provider={"future-provider" as unknown as ProviderType}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("?")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,43 +1,10 @@
|
||||
import {
|
||||
AlibabaCloudProviderBadge,
|
||||
AWSProviderBadge,
|
||||
AzureProviderBadge,
|
||||
CloudflareProviderBadge,
|
||||
GCPProviderBadge,
|
||||
GitHubProviderBadge,
|
||||
GoogleWorkspaceProviderBadge,
|
||||
IacProviderBadge,
|
||||
ImageProviderBadge,
|
||||
KS8ProviderBadge,
|
||||
M365ProviderBadge,
|
||||
MongoDBAtlasProviderBadge,
|
||||
OktaProviderBadge,
|
||||
OpenStackProviderBadge,
|
||||
OracleCloudProviderBadge,
|
||||
VercelProviderBadge,
|
||||
} from "@/components/icons/providers-badge";
|
||||
PROVIDER_TYPE_DATA,
|
||||
ProviderTypeIcon,
|
||||
} from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ProviderType } from "@/types";
|
||||
|
||||
export const PROVIDER_ICONS = {
|
||||
aws: AWSProviderBadge,
|
||||
azure: AzureProviderBadge,
|
||||
gcp: GCPProviderBadge,
|
||||
kubernetes: KS8ProviderBadge,
|
||||
m365: M365ProviderBadge,
|
||||
github: GitHubProviderBadge,
|
||||
googleworkspace: GoogleWorkspaceProviderBadge,
|
||||
iac: IacProviderBadge,
|
||||
image: ImageProviderBadge,
|
||||
oraclecloud: OracleCloudProviderBadge,
|
||||
mongodbatlas: MongoDBAtlasProviderBadge,
|
||||
alibabacloud: AlibabaCloudProviderBadge,
|
||||
cloudflare: CloudflareProviderBadge,
|
||||
openstack: OpenStackProviderBadge,
|
||||
vercel: VercelProviderBadge,
|
||||
okta: OktaProviderBadge,
|
||||
} as const;
|
||||
|
||||
interface ProviderIconCellProps {
|
||||
provider: ProviderType;
|
||||
size?: number;
|
||||
@@ -49,9 +16,9 @@ export const ProviderIconCell = ({
|
||||
size = 26,
|
||||
className = "size-8 rounded-md bg-white",
|
||||
}: ProviderIconCellProps) => {
|
||||
const IconComponent = PROVIDER_ICONS[provider];
|
||||
|
||||
if (!IconComponent) {
|
||||
// Unknown provider types (present in the data but missing from the shared
|
||||
// PROVIDER_TYPE_DATA map) render an explicit "?" rather than an empty icon.
|
||||
if (!(provider in PROVIDER_TYPE_DATA)) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center", className)}>
|
||||
<span className="text-text-neutral-secondary text-xs">?</span>
|
||||
@@ -66,7 +33,7 @@ export const ProviderIconCell = ({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<IconComponent width={size} height={size} />
|
||||
<ProviderTypeIcon type={provider} size={size} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ProviderType } from "@/types/providers";
|
||||
|
||||
import { ProviderTypeIcon, ProviderTypeIconStack } from "./provider-type-icon";
|
||||
|
||||
// A provider type the API may return but this UI build does not know about.
|
||||
const UNKNOWN_TYPE = "future-provider" as unknown as ProviderType;
|
||||
|
||||
// Render the lazy provider badges as plain text so we can assert on them.
|
||||
vi.mock("@/components/icons/providers-badge", () => ({
|
||||
AWSProviderBadge: () => <span>AWS</span>,
|
||||
AzureProviderBadge: () => <span>Azure</span>,
|
||||
GCPProviderBadge: () => <span>GCP</span>,
|
||||
KS8ProviderBadge: () => <span>Kubernetes</span>,
|
||||
M365ProviderBadge: () => <span>M365</span>,
|
||||
GitHubProviderBadge: () => <span>GitHub</span>,
|
||||
GoogleWorkspaceProviderBadge: () => <span>Google Workspace</span>,
|
||||
IacProviderBadge: () => <span>IaC</span>,
|
||||
ImageProviderBadge: () => <span>Image</span>,
|
||||
OracleCloudProviderBadge: () => <span>Oracle Cloud</span>,
|
||||
MongoDBAtlasProviderBadge: () => <span>MongoDB Atlas</span>,
|
||||
AlibabaCloudProviderBadge: () => <span>Alibaba Cloud</span>,
|
||||
CloudflareProviderBadge: () => <span>Cloudflare</span>,
|
||||
OpenStackProviderBadge: () => <span>OpenStack</span>,
|
||||
VercelProviderBadge: () => <span>Vercel</span>,
|
||||
OktaProviderBadge: () => <span>Okta</span>,
|
||||
}));
|
||||
|
||||
// Render the tooltip pieces inline so the hover content is queryable in jsdom.
|
||||
vi.mock("@/components/shadcn", () => ({
|
||||
Badge: ({ children }: { children: React.ReactNode }) => (
|
||||
<span data-testid="badge">{children}</span>
|
||||
),
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<span data-testid="tooltip">{children}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("ProviderTypeIcon", () => {
|
||||
it("renders the badge for the given provider type", async () => {
|
||||
render(<ProviderTypeIcon type="aws" />);
|
||||
|
||||
expect(await screen.findByText("AWS")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a sized placeholder instead of crashing for an unknown type", () => {
|
||||
// Regression guard for #9991: an unknown provider type must not throw.
|
||||
const { container } = render(
|
||||
<ProviderTypeIcon type={UNKNOWN_TYPE} size={24} />,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("AWS")).not.toBeInTheDocument();
|
||||
expect(container.querySelector("div")).toHaveStyle({
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProviderTypeIconStack", () => {
|
||||
it("renders one icon per item without deduping by type", async () => {
|
||||
render(
|
||||
<ProviderTypeIconStack
|
||||
items={[
|
||||
{ key: "a", type: "aws", tooltip: "111" },
|
||||
{ key: "b", type: "aws", tooltip: "222" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Two AWS accounts -> two AWS icons (no dedupe).
|
||||
expect(await screen.findAllByText("AWS")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows each item's tooltip text on the icon", async () => {
|
||||
render(
|
||||
<ProviderTypeIconStack
|
||||
items={[{ key: "a", type: "aws", tooltip: "account-uid-123" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByTestId("tooltip")).toHaveTextContent(
|
||||
"account-uid-123",
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses items beyond `max` into a +N badge", async () => {
|
||||
render(
|
||||
<ProviderTypeIconStack
|
||||
max={3}
|
||||
items={[
|
||||
{ key: "a", type: "aws", tooltip: "1" },
|
||||
{ key: "b", type: "azure", tooltip: "2" },
|
||||
{ key: "c", type: "gcp", tooltip: "3" },
|
||||
{ key: "d", type: "github", tooltip: "4" },
|
||||
{ key: "e", type: "okta", tooltip: "5" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByTestId("badge")).toHaveTextContent("+2");
|
||||
// First icon is shown; items sliced beyond `max` never reach the DOM.
|
||||
expect(await screen.findByText("AWS")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Okta")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders known icons and skips unknown types without crashing", async () => {
|
||||
// Regression guard for #9991: an unknown type in the stack must not throw.
|
||||
render(
|
||||
<ProviderTypeIconStack
|
||||
items={[
|
||||
{ key: "a", type: "aws", tooltip: "111" },
|
||||
{ key: "b", type: UNKNOWN_TYPE, tooltip: "222" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText("AWS")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
"use client";
|
||||
|
||||
import { type ComponentType, lazy, Suspense } from "react";
|
||||
|
||||
import {
|
||||
Badge,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ProviderType } from "@/types/providers";
|
||||
|
||||
type IconProps = { width: number; height: number };
|
||||
|
||||
const IconPlaceholder = ({ width, height }: IconProps) => (
|
||||
<div style={{ width, height }} />
|
||||
);
|
||||
|
||||
// Lazy-load every provider badge so the ~16 SVGs ship in a single deferred
|
||||
// chunk instead of being eagerly bundled wherever a selector is imported.
|
||||
const AWSProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.AWSProviderBadge,
|
||||
})),
|
||||
);
|
||||
const AzureProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.AzureProviderBadge,
|
||||
})),
|
||||
);
|
||||
const GCPProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.GCPProviderBadge,
|
||||
})),
|
||||
);
|
||||
const KS8ProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.KS8ProviderBadge,
|
||||
})),
|
||||
);
|
||||
const M365ProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.M365ProviderBadge,
|
||||
})),
|
||||
);
|
||||
const GitHubProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.GitHubProviderBadge,
|
||||
})),
|
||||
);
|
||||
const GoogleWorkspaceProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.GoogleWorkspaceProviderBadge,
|
||||
})),
|
||||
);
|
||||
const IacProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.IacProviderBadge,
|
||||
})),
|
||||
);
|
||||
const ImageProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.ImageProviderBadge,
|
||||
})),
|
||||
);
|
||||
const OracleCloudProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.OracleCloudProviderBadge,
|
||||
})),
|
||||
);
|
||||
const MongoDBAtlasProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.MongoDBAtlasProviderBadge,
|
||||
})),
|
||||
);
|
||||
const AlibabaCloudProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.AlibabaCloudProviderBadge,
|
||||
})),
|
||||
);
|
||||
const CloudflareProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.CloudflareProviderBadge,
|
||||
})),
|
||||
);
|
||||
const OpenStackProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.OpenStackProviderBadge,
|
||||
})),
|
||||
);
|
||||
const VercelProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.VercelProviderBadge,
|
||||
})),
|
||||
);
|
||||
const OktaProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.OktaProviderBadge,
|
||||
})),
|
||||
);
|
||||
|
||||
/**
|
||||
* Single source of truth mapping each provider type to its human-readable
|
||||
* label and (lazy) badge component. Shared by the account and provider-type
|
||||
* selectors so both stay in sync on labels, icons, and sizing.
|
||||
*/
|
||||
export const PROVIDER_TYPE_DATA: Record<
|
||||
ProviderType,
|
||||
{ label: string; icon: ComponentType<IconProps> }
|
||||
> = {
|
||||
aws: { label: "Amazon Web Services", icon: AWSProviderBadge },
|
||||
azure: { label: "Microsoft Azure", icon: AzureProviderBadge },
|
||||
gcp: { label: "Google Cloud Platform", icon: GCPProviderBadge },
|
||||
kubernetes: { label: "Kubernetes", icon: KS8ProviderBadge },
|
||||
m365: { label: "Microsoft 365", icon: M365ProviderBadge },
|
||||
github: { label: "GitHub", icon: GitHubProviderBadge },
|
||||
googleworkspace: {
|
||||
label: "Google Workspace",
|
||||
icon: GoogleWorkspaceProviderBadge,
|
||||
},
|
||||
iac: { label: "Infrastructure as Code", icon: IacProviderBadge },
|
||||
image: { label: "Container Registry", icon: ImageProviderBadge },
|
||||
oraclecloud: {
|
||||
label: "Oracle Cloud Infrastructure",
|
||||
icon: OracleCloudProviderBadge,
|
||||
},
|
||||
mongodbatlas: { label: "MongoDB Atlas", icon: MongoDBAtlasProviderBadge },
|
||||
alibabacloud: { label: "Alibaba Cloud", icon: AlibabaCloudProviderBadge },
|
||||
cloudflare: { label: "Cloudflare", icon: CloudflareProviderBadge },
|
||||
openstack: { label: "OpenStack", icon: OpenStackProviderBadge },
|
||||
vercel: { label: "Vercel", icon: VercelProviderBadge },
|
||||
okta: { label: "Okta", icon: OktaProviderBadge },
|
||||
};
|
||||
|
||||
interface ProviderTypeIconProps {
|
||||
type: ProviderType;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single provider-type badge with a sized placeholder fallback.
|
||||
*
|
||||
* Falls back to the placeholder for provider types missing from
|
||||
* `PROVIDER_TYPE_DATA` (e.g. a brand-new provider the API knows but this UI
|
||||
* build does not). The `type` is statically typed as `ProviderType`, so this
|
||||
* only guards the runtime case — see #9991, which fixed the same crash class.
|
||||
*/
|
||||
export const ProviderTypeIcon = ({
|
||||
type,
|
||||
size = 18,
|
||||
}: ProviderTypeIconProps) => {
|
||||
const data = PROVIDER_TYPE_DATA[type];
|
||||
if (!data) return <IconPlaceholder width={size} height={size} />;
|
||||
|
||||
const Icon = data.icon;
|
||||
return (
|
||||
<Suspense fallback={<IconPlaceholder width={size} height={size} />}>
|
||||
<Icon width={size} height={size} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export interface ProviderTypeIconStackItem {
|
||||
/** Stable React key (account id for accounts, provider type for types). */
|
||||
key: string;
|
||||
type: ProviderType;
|
||||
/** Text shown on hover to disambiguate the icon (e.g. an account UID). */
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
interface ProviderTypeIconStackProps {
|
||||
items: ProviderTypeIconStackItem[];
|
||||
max?: number;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon with a hover tooltip. `TooltipContent` (shadcn) already renders inside a
|
||||
* Radix portal, so the tooltip is not clipped by the selector trigger and we do
|
||||
* not need to portal it ourselves. `delayDuration` is set on the tooltip itself
|
||||
* because shadcn's `Tooltip` wraps each instance in its own `TooltipProvider`
|
||||
* (delay 0), which would otherwise override an ancestor provider's delay.
|
||||
*/
|
||||
const IconWithTooltip = ({
|
||||
item,
|
||||
size,
|
||||
}: {
|
||||
item: ProviderTypeIconStackItem;
|
||||
size: number;
|
||||
}) => {
|
||||
const icon = (
|
||||
<span className="inline-flex shrink-0">
|
||||
<ProviderTypeIcon type={item.type} size={size} />
|
||||
</span>
|
||||
);
|
||||
|
||||
if (!item.tooltip) return icon;
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={150}>
|
||||
<TooltipTrigger asChild>{icon}</TooltipTrigger>
|
||||
<TooltipContent side="top">{item.tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders up to `max` provider-type icons followed by a `+N` badge for the
|
||||
* remainder. Each icon shows its `tooltip` on hover. Items are rendered as
|
||||
* passed (one per selection) — callers decide whether to dedupe.
|
||||
*/
|
||||
export const ProviderTypeIconStack = ({
|
||||
items,
|
||||
max = 3,
|
||||
size = 18,
|
||||
className,
|
||||
}: ProviderTypeIconStackProps) => {
|
||||
const visible = items.slice(0, max);
|
||||
const overflow = items.slice(max);
|
||||
const overflowLabel = overflow
|
||||
.map((item) => item.tooltip)
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<span className={cn("flex shrink-0 items-center gap-1", className)}>
|
||||
<span className="flex items-center gap-1">
|
||||
{visible.map((item) => (
|
||||
<IconWithTooltip key={item.key} item={item} size={size} />
|
||||
))}
|
||||
</span>
|
||||
{overflow.length > 0 && (
|
||||
<Tooltip delayDuration={150}>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="tag" className="px-1.5 py-0.5 text-xs font-medium">
|
||||
+{overflow.length}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
{overflowLabel && (
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
{overflowLabel}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -8,7 +8,10 @@ import { useState } from "react";
|
||||
import { Control, useForm } from "react-hook-form";
|
||||
|
||||
import { createIntegration, updateIntegration } from "@/actions/integrations";
|
||||
import { PROVIDER_ICONS } from "@/components/findings/table/provider-icon-cell";
|
||||
import {
|
||||
PROVIDER_TYPE_DATA,
|
||||
ProviderTypeIcon,
|
||||
} from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form";
|
||||
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
|
||||
import { useToast } from "@/components/ui";
|
||||
@@ -279,11 +282,14 @@ export const S3IntegrationForm = ({
|
||||
// Show configuration step (step 0 or editing configuration)
|
||||
if (isEditingConfig || currentStep === 0) {
|
||||
const providerOptions = providers.map((provider) => {
|
||||
const Icon = PROVIDER_ICONS[provider.attributes.provider];
|
||||
const providerType = provider.attributes.provider;
|
||||
return {
|
||||
value: provider.id,
|
||||
label: provider.attributes.alias || provider.attributes.uid,
|
||||
icon: Icon ? <Icon width={20} height={20} /> : undefined,
|
||||
icon:
|
||||
providerType in PROVIDER_TYPE_DATA ? (
|
||||
<ProviderTypeIcon type={providerType} size={20} />
|
||||
) : undefined,
|
||||
description: provider.attributes.connection.connected
|
||||
? "Connected"
|
||||
: "Disconnected",
|
||||
|
||||
@@ -10,7 +10,10 @@ import { useEffect, useState } from "react";
|
||||
import { Control, useForm } from "react-hook-form";
|
||||
|
||||
import { createIntegration, updateIntegration } from "@/actions/integrations";
|
||||
import { PROVIDER_ICONS } from "@/components/findings/table/provider-icon-cell";
|
||||
import {
|
||||
PROVIDER_TYPE_DATA,
|
||||
ProviderTypeIcon,
|
||||
} from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form";
|
||||
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
|
||||
import { useToast } from "@/components/ui";
|
||||
@@ -121,11 +124,14 @@ export const SecurityHubIntegrationForm = ({
|
||||
? "Connected"
|
||||
: "Disconnected";
|
||||
|
||||
const Icon = PROVIDER_ICONS[provider.attributes.provider];
|
||||
const providerType = provider.attributes.provider;
|
||||
return {
|
||||
value: provider.id,
|
||||
label: provider.attributes.alias || provider.attributes.uid,
|
||||
icon: Icon ? <Icon width={20} height={20} /> : undefined,
|
||||
icon:
|
||||
providerType in PROVIDER_TYPE_DATA ? (
|
||||
<ProviderTypeIcon type={providerType} size={20} />
|
||||
) : undefined,
|
||||
description: isDisabled
|
||||
? `${connectionLabel} (Already in use)`
|
||||
: connectionLabel,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { InfoIcon } from "@/components/icons/Icons";
|
||||
import { Button, Card, CardContent } from "@/components/shadcn";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const NO_PROVIDERS_ADDED_ACTION = {
|
||||
BUTTON: "button",
|
||||
LINK: "link",
|
||||
} as const;
|
||||
|
||||
interface NoProvidersAddedBaseProps {
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
interface NoProvidersAddedButtonProps extends NoProvidersAddedBaseProps {
|
||||
action: typeof NO_PROVIDERS_ADDED_ACTION.BUTTON;
|
||||
onOpenWizard: () => void;
|
||||
href?: never;
|
||||
}
|
||||
|
||||
interface NoProvidersAddedLinkProps extends NoProvidersAddedBaseProps {
|
||||
action: typeof NO_PROVIDERS_ADDED_ACTION.LINK;
|
||||
href: string;
|
||||
onOpenWizard?: never;
|
||||
}
|
||||
|
||||
type NoProvidersAddedProps =
|
||||
| NoProvidersAddedButtonProps
|
||||
| NoProvidersAddedLinkProps;
|
||||
|
||||
const renderCta = (props: NoProvidersAddedProps) => {
|
||||
if (props.action === NO_PROVIDERS_ADDED_ACTION.LINK) {
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
aria-label="Open Add Provider modal"
|
||||
className="w-full max-w-xs justify-center"
|
||||
size="lg"
|
||||
>
|
||||
<Link href={props.href}>Get Started</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Open Add Provider modal"
|
||||
className="w-full max-w-xs justify-center"
|
||||
size="lg"
|
||||
onClick={props.onOpenWizard}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const NoProvidersAdded = (props: NoProvidersAddedProps) => {
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-labelledby="no-providers-added-title"
|
||||
className={cn(
|
||||
"flex min-h-[calc(100dvh-10rem)] items-center justify-center",
|
||||
props.containerClassName,
|
||||
)}
|
||||
>
|
||||
<Card variant="base" className="mx-auto w-full max-w-3xl">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-6 text-center sm:p-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<InfoIcon className="h-10 w-10 text-gray-800 dark:text-white" />
|
||||
<h2
|
||||
id="no-providers-added-title"
|
||||
className="text-2xl font-bold text-gray-800 dark:text-white"
|
||||
>
|
||||
No Providers Configured
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-md leading-relaxed text-gray-600 dark:text-gray-300">
|
||||
No providers have been configured. Start by setting up a provider.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{renderCta(props)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,36 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
|
||||
import type { ProvidersTableRow } from "@/types/providers-table";
|
||||
|
||||
const { refreshMock, replaceMock, searchParamsValue } = vi.hoisted(() => ({
|
||||
refreshMock: vi.fn(),
|
||||
replaceMock: vi.fn(),
|
||||
searchParamsValue: { current: "" },
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/providers",
|
||||
useRouter: () => ({
|
||||
refresh: refreshMock,
|
||||
replace: replaceMock,
|
||||
}),
|
||||
useSearchParams: () => new URLSearchParams(searchParamsValue.current),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/table", () => ({
|
||||
SkeletonTableProviders: () => <div data-testid="providers-skeleton" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/add-provider-button", () => ({
|
||||
AddProviderButton: () => <button type="button">Add provider</button>,
|
||||
AddProviderButton: ({ onOpenWizard }: { onOpenWizard: () => void }) => (
|
||||
<button type="button" onClick={onOpenWizard}>
|
||||
Add Provider
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/muted-findings-config-button", () => ({
|
||||
@@ -15,7 +40,12 @@ vi.mock("@/components/providers/muted-findings-config-button", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/providers-filters", () => ({
|
||||
ProvidersFilters: () => <div data-testid="providers-filters">Filters</div>,
|
||||
ProvidersFilters: ({ actions }: { actions: ReactNode }) => (
|
||||
<div data-testid="providers-filters">
|
||||
Filters
|
||||
{actions}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/providers-accounts-table", () => ({
|
||||
@@ -23,7 +53,21 @@ vi.mock("@/components/providers/providers-accounts-table", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/wizard", () => ({
|
||||
ProviderWizardModal: () => <div data-testid="provider-wizard-modal" />,
|
||||
ProviderWizardModal: ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) =>
|
||||
open ? (
|
||||
<div role="dialog">
|
||||
Provider wizard
|
||||
<button type="button" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
import { ProvidersAccountsView } from "./providers-accounts-view";
|
||||
@@ -36,8 +80,55 @@ const metadata: MetaDataProps = {
|
||||
version: "latest",
|
||||
};
|
||||
|
||||
const disconnectedProviders: ProviderProps[] = [
|
||||
{
|
||||
id: "provider-1",
|
||||
type: "providers",
|
||||
attributes: {
|
||||
provider: "aws",
|
||||
uid: "123456789012",
|
||||
alias: "Production",
|
||||
status: "completed",
|
||||
resources: 0,
|
||||
connection: {
|
||||
connected: false,
|
||||
last_checked_at: "2026-04-13T00:00:00Z",
|
||||
},
|
||||
scanner_args: {
|
||||
only_logs: false,
|
||||
excluded_checks: [],
|
||||
aws_retries_max_attempts: 3,
|
||||
},
|
||||
inserted_at: "2026-04-13T00:00:00Z",
|
||||
updated_at: "2026-04-13T00:00:00Z",
|
||||
created_by: {
|
||||
object: "user",
|
||||
id: "user-1",
|
||||
},
|
||||
},
|
||||
relationships: {
|
||||
secret: {
|
||||
data: null,
|
||||
},
|
||||
provider_groups: {
|
||||
meta: {
|
||||
count: 0,
|
||||
},
|
||||
data: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("ProvidersAccountsView", () => {
|
||||
it("keeps the same vertical spacing between filters and table as other views", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
searchParamsValue.current = "";
|
||||
window.history.replaceState({}, "", "/");
|
||||
});
|
||||
|
||||
it("shows a full page empty state without filters or table when there are no providers", () => {
|
||||
// Given/When
|
||||
render(
|
||||
<ProvidersAccountsView
|
||||
isCloud={false}
|
||||
@@ -48,11 +139,170 @@ describe("ProvidersAccountsView", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("region", { name: /no providers configured/i }),
|
||||
).toHaveClass("min-h-[calc(100dvh-28rem)]");
|
||||
expect(screen.queryByTestId("providers-filters")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("providers-table")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the provider wizard from the no providers CTA", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ProvidersAccountsView
|
||||
isCloud={false}
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={providers}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open add provider modal/i }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
|
||||
});
|
||||
|
||||
it("opens the provider wizard from the URL without immediately clearing the one-shot intent", () => {
|
||||
// Given
|
||||
searchParamsValue.current = "tab=connected&addProvider=true";
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
"/providers?tab=connected&addProvider=true",
|
||||
);
|
||||
// Spy only after the URL setup so we measure what the component does on mount.
|
||||
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
|
||||
|
||||
render(
|
||||
<ProvidersAccountsView
|
||||
isCloud={false}
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={providers}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
|
||||
expect(replaceStateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cleans the one-shot intent from the URL without refetching when the URL-opened wizard closes", async () => {
|
||||
// Given
|
||||
searchParamsValue.current = "tab=connected&addProvider=true";
|
||||
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ProvidersAccountsView
|
||||
isCloud={false}
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={providers}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: /close/i }));
|
||||
|
||||
// Then
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
// The URL is cleaned via the History API (no RSC refetch). We must NOT
|
||||
// refresh/replace here: re-running the /providers Server Component on close
|
||||
// read as a full page reload. The provider-creation actions already
|
||||
// revalidatePath("/providers"), so the table is fresh behind the modal.
|
||||
expect(replaceStateSpy).toHaveBeenCalledWith(
|
||||
null,
|
||||
"",
|
||||
"/providers?tab=connected",
|
||||
);
|
||||
expect(refreshMock).not.toHaveBeenCalled();
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not touch the URL or refetch when a manually opened wizard closes", async () => {
|
||||
// Given: no addProvider param in the URL, wizard opened via the CTA.
|
||||
searchParamsValue.current = "";
|
||||
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ProvidersAccountsView
|
||||
isCloud={false}
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={providers}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When: open the wizard from the empty-state CTA, then close it.
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open add provider modal/i }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /close/i }));
|
||||
|
||||
// Then: nothing to clean and no refresh — the creation actions own the
|
||||
// data refresh via revalidatePath.
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(replaceStateSpy).not.toHaveBeenCalled();
|
||||
expect(refreshMock).not.toHaveBeenCalled();
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps filters and table visible when providers are disconnected", () => {
|
||||
// Given/When
|
||||
render(
|
||||
<ProvidersAccountsView
|
||||
isCloud={false}
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={disconnectedProviders}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByTestId("providers-filters").parentElement).toHaveClass(
|
||||
"flex",
|
||||
"flex-col",
|
||||
"gap-6",
|
||||
);
|
||||
expect(screen.getByTestId("providers-table")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("No Providers Configured"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the provider wizard from the normal Add Provider button", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ProvidersAccountsView
|
||||
isCloud={false}
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={disconnectedProviders}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: /add provider/i }));
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { AddProviderButton } from "@/components/providers/add-provider-button";
|
||||
import { MutedFindingsConfigButton } from "@/components/providers/muted-findings-config-button";
|
||||
import { NoProvidersAdded } from "@/components/providers/no-providers-added";
|
||||
import { ProvidersAccountsTable } from "@/components/providers/providers-accounts-table";
|
||||
import { ProvidersFilters } from "@/components/providers/providers-filters";
|
||||
import { ProviderWizardModal } from "@/components/providers/wizard";
|
||||
@@ -11,6 +13,10 @@ import type {
|
||||
OrgWizardInitialData,
|
||||
ProviderWizardInitialData,
|
||||
} from "@/components/providers/wizard/types";
|
||||
import {
|
||||
ADD_PROVIDER_SEARCH_PARAM,
|
||||
ADD_PROVIDER_SEARCH_VALUE,
|
||||
} from "@/lib/providers-navigation";
|
||||
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
|
||||
import type { ProvidersTableRow } from "@/types/providers-table";
|
||||
|
||||
@@ -29,7 +35,14 @@ export function ProvidersAccountsView({
|
||||
providers,
|
||||
rows,
|
||||
}: ProvidersAccountsViewProps) {
|
||||
const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const hasNoProviders = providers.length === 0;
|
||||
const shouldOpenProviderWizardFromUrl =
|
||||
searchParams.get(ADD_PROVIDER_SEARCH_PARAM) === ADD_PROVIDER_SEARCH_VALUE;
|
||||
const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(
|
||||
() => shouldOpenProviderWizardFromUrl,
|
||||
);
|
||||
const [providerWizardInitialData, setProviderWizardInitialData] = useState<
|
||||
ProviderWizardInitialData | undefined
|
||||
>(undefined);
|
||||
@@ -52,38 +65,64 @@ export function ProvidersAccountsView({
|
||||
const handleWizardOpenChange = (open: boolean) => {
|
||||
setIsProviderWizardOpen(open);
|
||||
|
||||
if (!open) {
|
||||
setProviderWizardInitialData(undefined);
|
||||
setOrgWizardInitialData(undefined);
|
||||
if (open) return;
|
||||
|
||||
setProviderWizardInitialData(undefined);
|
||||
setOrgWizardInitialData(undefined);
|
||||
|
||||
// Only clean the one-shot ?addProvider intent from the URL bar, via the
|
||||
// History API so it does NOT trigger an RSC refetch. We must not refresh
|
||||
// here: the provider-creation actions (addProvider / addCredentialsProvider
|
||||
// / checkConnectionProvider) already revalidatePath("/providers"), so the
|
||||
// table updates behind the modal. A router.refresh()/replace() on close
|
||||
// re-ran the whole /providers Server Component, which read as a full reload.
|
||||
if (searchParams.has(ADD_PROVIDER_SEARCH_PARAM)) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete(ADD_PROVIDER_SEARCH_PARAM);
|
||||
const query = params.toString();
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
query ? `${pathname}?${query}` : pathname,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-6">
|
||||
<ProvidersFilters
|
||||
filters={filters}
|
||||
providers={providers}
|
||||
actions={
|
||||
<>
|
||||
<MutedFindingsConfigButton />
|
||||
<AddProviderButton onOpenWizard={() => openProviderWizard()} />
|
||||
</>
|
||||
}
|
||||
{hasNoProviders ? (
|
||||
<NoProvidersAdded
|
||||
action="button"
|
||||
containerClassName="min-h-[calc(100dvh-28rem)]"
|
||||
onOpenWizard={() => openProviderWizard()}
|
||||
/>
|
||||
<ProvidersAccountsTable
|
||||
isCloud={isCloud}
|
||||
metadata={metadata}
|
||||
rows={rows}
|
||||
onOpenProviderWizard={openProviderWizard}
|
||||
onOpenOrganizationWizard={openOrganizationWizard}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ProvidersFilters
|
||||
filters={filters}
|
||||
providers={providers}
|
||||
actions={
|
||||
<>
|
||||
<MutedFindingsConfigButton />
|
||||
<AddProviderButton onOpenWizard={() => openProviderWizard()} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<ProvidersAccountsTable
|
||||
isCloud={isCloud}
|
||||
metadata={metadata}
|
||||
rows={rows}
|
||||
onOpenProviderWizard={openProviderWizard}
|
||||
onOpenOrganizationWizard={openOrganizationWizard}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ProviderWizardModal
|
||||
open={isProviderWizardOpen}
|
||||
onOpenChange={handleWizardOpenChange}
|
||||
initialData={providerWizardInitialData}
|
||||
orgInitialData={orgWizardInitialData}
|
||||
refreshOnClose={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -50,6 +50,10 @@ interface UseProviderWizardControllerProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
initialData?: ProviderWizardInitialData;
|
||||
orgInitialData?: OrgWizardInitialData;
|
||||
// When false, the caller skips the post-close router.refresh() and relies on
|
||||
// the provider-creation actions' revalidatePath("/providers") to refresh the
|
||||
// data. Defaults to true so standalone callers keep refreshing.
|
||||
refreshOnClose?: boolean;
|
||||
}
|
||||
|
||||
export function useProviderWizardController({
|
||||
@@ -57,6 +61,7 @@ export function useProviderWizardController({
|
||||
onOpenChange,
|
||||
initialData,
|
||||
orgInitialData,
|
||||
refreshOnClose = true,
|
||||
}: UseProviderWizardControllerProps) {
|
||||
const router = useRouter();
|
||||
const initialProviderId = initialData?.providerId ?? null;
|
||||
@@ -185,7 +190,9 @@ export function useProviderWizardController({
|
||||
setProviderTypeHint(null);
|
||||
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
|
||||
onOpenChange(false);
|
||||
router.refresh();
|
||||
if (refreshOnClose) {
|
||||
router.refresh();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDialogOpenChange = (nextOpen: boolean) => {
|
||||
|
||||
@@ -38,6 +38,7 @@ interface ProviderWizardModalProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
initialData?: ProviderWizardInitialData;
|
||||
orgInitialData?: OrgWizardInitialData;
|
||||
refreshOnClose?: boolean;
|
||||
}
|
||||
|
||||
export function ProviderWizardModal({
|
||||
@@ -45,6 +46,7 @@ export function ProviderWizardModal({
|
||||
onOpenChange,
|
||||
initialData,
|
||||
orgInitialData,
|
||||
refreshOnClose,
|
||||
}: ProviderWizardModalProps) {
|
||||
const {
|
||||
backToProviderFlow,
|
||||
@@ -72,6 +74,7 @@ export function ProviderWizardModal({
|
||||
onOpenChange,
|
||||
initialData,
|
||||
orgInitialData,
|
||||
refreshOnClose,
|
||||
});
|
||||
const scrollHintRefreshToken = `${wizardVariant}-${currentStep}-${orgCurrentStep}-${orgSetupPhase}`;
|
||||
const { containerRef, sentinelRef, showScrollHint } = useScrollHint({
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Card, CardContent } from "@/components/shadcn";
|
||||
|
||||
import { InfoIcon } from "../icons/Icons";
|
||||
|
||||
interface NoProvidersAddedProps {
|
||||
onOpenWizard: () => void;
|
||||
}
|
||||
|
||||
export const NoProvidersAdded = ({ onOpenWizard }: NoProvidersAddedProps) => (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card variant="base" className="mx-auto w-full max-w-3xl">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-6 text-center sm:p-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<InfoIcon className="h-10 w-10 text-gray-800 dark:text-white" />
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white">
|
||||
No Providers Configured
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-md leading-relaxed text-gray-600 dark:text-gray-300">
|
||||
No providers have been configured. Start by setting up a provider.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
aria-label="Open Add Provider modal"
|
||||
className="w-full max-w-xs justify-center"
|
||||
size="lg"
|
||||
onClick={onOpenWizard}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
@@ -1,73 +1,43 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ADD_PROVIDER_HREF } from "@/lib/providers-navigation";
|
||||
|
||||
import { ScansProvidersEmptyState } from "./scans-providers-empty-state";
|
||||
|
||||
const { replaceMock, searchParamsValue } = vi.hoisted(() => ({
|
||||
replaceMock: vi.fn(),
|
||||
searchParamsValue: { current: "" },
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/scans",
|
||||
useRouter: () => ({
|
||||
replace: replaceMock,
|
||||
}),
|
||||
useSearchParams: () => new URLSearchParams(searchParamsValue.current),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/wizard", () => ({
|
||||
ProviderWizardModal: ({ open }: { open: boolean }) =>
|
||||
open ? <div role="dialog">Provider wizard</div> : null,
|
||||
}));
|
||||
|
||||
vi.mock("./no-providers-connected", () => ({
|
||||
NoProvidersConnected: () => <div>No Connected Providers</div>,
|
||||
}));
|
||||
|
||||
describe("ScansProvidersEmptyState", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
searchParamsValue.current = "";
|
||||
});
|
||||
|
||||
it("shows the add provider message and opens the provider wizard", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
it("shows the add provider message with a providers page CTA", () => {
|
||||
// Given/When
|
||||
render(<ScansProvidersEmptyState thereIsNoProviders />);
|
||||
|
||||
expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open add provider modal/i }),
|
||||
);
|
||||
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
|
||||
});
|
||||
|
||||
it("clears the launch scan URL intent before opening the provider wizard", async () => {
|
||||
// Given
|
||||
searchParamsValue.current = "tab=completed&launchScan=true";
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ScansProvidersEmptyState thereIsNoProviders />);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open add provider modal/i }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(replaceMock).toHaveBeenCalledWith("/scans?tab=completed", {
|
||||
scroll: false,
|
||||
expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
|
||||
const cta = screen.getByRole("link", {
|
||||
name: /open add provider modal/i,
|
||||
});
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
|
||||
|
||||
expect(cta).toHaveAttribute("href", ADD_PROVIDER_HREF);
|
||||
expect(cta.tagName).toBe("A");
|
||||
});
|
||||
|
||||
it("does not render the provider wizard in Scans", () => {
|
||||
// Given/When
|
||||
render(<ScansProvidersEmptyState thereIsNoProviders />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the no connected providers message", () => {
|
||||
// Given/When
|
||||
render(<ScansProvidersEmptyState thereIsNoProviders={false} />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("No Connected Providers")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
"use client";
|
||||
import { NoProvidersAdded } from "@/components/providers/no-providers-added";
|
||||
import { ADD_PROVIDER_HREF } from "@/lib/providers-navigation";
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ProviderWizardModal } from "@/components/providers/wizard";
|
||||
import { LAUNCH_SCAN_SEARCH_PARAM } from "@/lib/scans-navigation";
|
||||
|
||||
import { NoProvidersAdded } from "./no-providers-added";
|
||||
import { NoProvidersConnected } from "./no-providers-connected";
|
||||
|
||||
interface ScansProvidersEmptyStateProps {
|
||||
@@ -16,35 +10,13 @@ interface ScansProvidersEmptyStateProps {
|
||||
export function ScansProvidersEmptyState({
|
||||
thereIsNoProviders,
|
||||
}: ScansProvidersEmptyStateProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false);
|
||||
|
||||
const openProviderWizard = () => {
|
||||
if (searchParams.has(LAUNCH_SCAN_SEARCH_PARAM)) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete(LAUNCH_SCAN_SEARCH_PARAM);
|
||||
const query = params.toString();
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, {
|
||||
scroll: false,
|
||||
});
|
||||
}
|
||||
|
||||
setIsProviderWizardOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{thereIsNoProviders ? (
|
||||
<NoProvidersAdded onOpenWizard={openProviderWizard} />
|
||||
<NoProvidersAdded action="link" href={ADD_PROVIDER_HREF} />
|
||||
) : (
|
||||
<NoProvidersConnected />
|
||||
)}
|
||||
<ProviderWizardModal
|
||||
open={isProviderWizardOpen}
|
||||
onOpenChange={setIsProviderWizardOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { Row } from "@tanstack/react-table";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// The forms pull in server actions (`@/actions/users/users`) that can't run in
|
||||
// jsdom, so stub them with identifiable markers to assert which modal opens.
|
||||
vi.mock("../forms", () => ({
|
||||
DeleteForm: ({ userId }: { userId: string }) => (
|
||||
<div data-testid="delete-form">delete-form:{userId}</div>
|
||||
),
|
||||
EditForm: ({ userId }: { userId: string }) => (
|
||||
<div data-testid="edit-form">edit-form:{userId}</div>
|
||||
),
|
||||
ExpelUserForm: ({ userId }: { userId: string }) => (
|
||||
<div data-testid="expel-form">expel-form:{userId}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { DataTableRowActions } from "./data-table-row-actions";
|
||||
|
||||
interface RowOptions {
|
||||
id?: string;
|
||||
isCurrentUser?: boolean;
|
||||
canBeExpelled?: boolean;
|
||||
currentTenantId?: string;
|
||||
}
|
||||
|
||||
const createRow = ({
|
||||
id = "user-1",
|
||||
isCurrentUser,
|
||||
canBeExpelled,
|
||||
currentTenantId,
|
||||
}: RowOptions = {}) =>
|
||||
({
|
||||
original: {
|
||||
id,
|
||||
attributes: {
|
||||
name: "Jane Doe",
|
||||
email: "jane@example.com",
|
||||
company_name: "Acme",
|
||||
role: { name: "admin" },
|
||||
},
|
||||
isCurrentUser,
|
||||
canBeExpelled,
|
||||
currentTenantId,
|
||||
},
|
||||
}) as unknown as Row<{ id: string }>;
|
||||
|
||||
const openMenu = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await user.click(screen.getByRole("button", { name: "Open actions menu" }));
|
||||
};
|
||||
|
||||
describe("DataTableRowActions (users)", () => {
|
||||
it("always renders the Edit User action", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DataTableRowActions row={createRow()} />);
|
||||
|
||||
await openMenu(user);
|
||||
|
||||
expect(screen.getByText("Edit User")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Delete User only for the current user's row", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DataTableRowActions row={createRow({ isCurrentUser: true })} />);
|
||||
|
||||
await openMenu(user);
|
||||
|
||||
expect(screen.getByText("Delete User")).toBeInTheDocument();
|
||||
expect(screen.getByText("Danger zone")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does NOT show Delete User for another user's row", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DataTableRowActions row={createRow({ isCurrentUser: false })} />);
|
||||
|
||||
await openMenu(user);
|
||||
|
||||
expect(screen.queryByText("Delete User")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does NOT show Delete User when isCurrentUser is undefined", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DataTableRowActions row={createRow({})} />);
|
||||
|
||||
await openMenu(user);
|
||||
|
||||
expect(screen.queryByText("Delete User")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the Danger zone entirely when the user can neither be deleted nor expelled", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={createRow({ isCurrentUser: false, canBeExpelled: false })}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openMenu(user);
|
||||
|
||||
// Only the non-destructive Edit action remains.
|
||||
expect(screen.getByText("Edit User")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Danger zone")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Delete User")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Expel from organization"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Expel but not Delete User for an expellable, non-current user", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={createRow({
|
||||
isCurrentUser: false,
|
||||
canBeExpelled: true,
|
||||
currentTenantId: "tenant-1",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openMenu(user);
|
||||
|
||||
expect(screen.getByText("Danger zone")).toBeInTheDocument();
|
||||
expect(screen.getByText("Expel from organization")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Delete User")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Delete User with destructive styling", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DataTableRowActions row={createRow({ isCurrentUser: true })} />);
|
||||
|
||||
await openMenu(user);
|
||||
|
||||
const menuItem = screen
|
||||
.getByText("Delete User")
|
||||
.closest("[role='menuitem']");
|
||||
expect(menuItem).toBeInTheDocument();
|
||||
expect(menuItem).toHaveClass("text-text-error-primary");
|
||||
});
|
||||
|
||||
it("opens the delete confirmation modal when Delete User is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={createRow({ id: "user-42", isCurrentUser: true })}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openMenu(user);
|
||||
await user.click(screen.getByText("Delete User"));
|
||||
|
||||
expect(screen.getByText("Are you absolutely sure?")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("delete-form")).toHaveTextContent(
|
||||
"delete-form:user-42",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ interface UserRowData {
|
||||
attributes?: UserRowAttributes;
|
||||
canBeExpelled?: boolean;
|
||||
currentTenantId?: string;
|
||||
isCurrentUser?: boolean;
|
||||
}
|
||||
|
||||
interface DataTableRowActionsProps<UserProps extends UserRowData> {
|
||||
@@ -57,6 +58,10 @@ export function DataTableRowActions<UserProps extends UserRowData>({
|
||||
row.original.canBeExpelled === true && !!row.original.currentTenantId;
|
||||
const currentTenantId = row.original.currentTenantId;
|
||||
|
||||
// A user can only delete their own account (enforced by the backend), so the
|
||||
// delete action is shown exclusively for the current user's row.
|
||||
const canDeleteUser = row.original.isCurrentUser === true;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
@@ -74,14 +79,16 @@ export function DataTableRowActions<UserProps extends UserRowData>({
|
||||
setIsOpen={setIsEditOpen}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={isDeleteOpen}
|
||||
onOpenChange={setIsDeleteOpen}
|
||||
title="Are you absolutely sure?"
|
||||
description="This action cannot be undone. This will permanently delete your user account and remove your data from the server."
|
||||
>
|
||||
<DeleteForm userId={userId} setIsOpen={setIsDeleteOpen} />
|
||||
</Modal>
|
||||
{canDeleteUser && (
|
||||
<Modal
|
||||
open={isDeleteOpen}
|
||||
onOpenChange={setIsDeleteOpen}
|
||||
title="Are you absolutely sure?"
|
||||
description="This action cannot be undone. This will permanently delete your user account and remove your data from the server."
|
||||
>
|
||||
<DeleteForm userId={userId} setIsOpen={setIsDeleteOpen} />
|
||||
</Modal>
|
||||
)}
|
||||
{canExpelUser && currentTenantId && (
|
||||
<Modal
|
||||
open={isExpelOpen}
|
||||
@@ -104,22 +111,26 @@ export function DataTableRowActions<UserProps extends UserRowData>({
|
||||
label="Edit User"
|
||||
onSelect={() => setIsEditOpen(true)}
|
||||
/>
|
||||
<ActionDropdownDangerZone>
|
||||
{canExpelUser && (
|
||||
<ActionDropdownItem
|
||||
icon={<UserMinus aria-hidden="true" />}
|
||||
label="Expel from organization"
|
||||
destructive
|
||||
onSelect={() => setIsExpelOpen(true)}
|
||||
/>
|
||||
)}
|
||||
<ActionDropdownItem
|
||||
icon={<Trash2 aria-hidden="true" />}
|
||||
label="Delete User"
|
||||
destructive
|
||||
onSelect={() => setIsDeleteOpen(true)}
|
||||
/>
|
||||
</ActionDropdownDangerZone>
|
||||
{(canExpelUser || canDeleteUser) && (
|
||||
<ActionDropdownDangerZone>
|
||||
{canExpelUser && (
|
||||
<ActionDropdownItem
|
||||
icon={<UserMinus aria-hidden="true" />}
|
||||
label="Expel from organization"
|
||||
destructive
|
||||
onSelect={() => setIsExpelOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{canDeleteUser && (
|
||||
<ActionDropdownItem
|
||||
icon={<Trash2 aria-hidden="true" />}
|
||||
label="Delete User"
|
||||
destructive
|
||||
onSelect={() => setIsDeleteOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</ActionDropdownDangerZone>
|
||||
)}
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
</>
|
||||
|
||||
+12
-12
@@ -778,26 +778,26 @@
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "@vitest/browser",
|
||||
"from": "4.1.6",
|
||||
"to": "4.0.18",
|
||||
"from": "4.0.18",
|
||||
"to": "4.1.8",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-05-14T10:22:47.378Z"
|
||||
"generatedAt": "2026-06-02T11:34:46.264Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "@vitest/browser-playwright",
|
||||
"from": "4.1.6",
|
||||
"to": "4.0.18",
|
||||
"from": "4.0.18",
|
||||
"to": "4.1.8",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-05-14T10:22:47.378Z"
|
||||
"generatedAt": "2026-06-02T11:34:46.264Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "@vitest/coverage-v8",
|
||||
"from": "4.1.6",
|
||||
"to": "4.0.18",
|
||||
"from": "4.0.18",
|
||||
"to": "4.1.8",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-05-14T10:22:47.378Z"
|
||||
"generatedAt": "2026-06-02T11:34:46.264Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
@@ -978,10 +978,10 @@
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "vitest",
|
||||
"from": "4.1.6",
|
||||
"to": "4.0.18",
|
||||
"from": "4.0.18",
|
||||
"to": "4.1.8",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-05-14T10:22:47.378Z"
|
||||
"generatedAt": "2026-06-02T11:34:46.264Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const ADD_PROVIDER_SEARCH_PARAM = "addProvider";
|
||||
export const ADD_PROVIDER_SEARCH_VALUE = "true";
|
||||
export const ADD_PROVIDER_HREF = `/providers?${ADD_PROVIDER_SEARCH_PARAM}=${ADD_PROVIDER_SEARCH_VALUE}`;
|
||||
+4
-4
@@ -133,9 +133,9 @@
|
||||
"@typescript-eslint/eslint-plugin": "8.53.0",
|
||||
"@typescript-eslint/parser": "8.53.0",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"@vitest/browser": "4.0.18",
|
||||
"@vitest/browser-playwright": "4.0.18",
|
||||
"@vitest/coverage-v8": "4.0.18",
|
||||
"@vitest/browser": "4.1.8",
|
||||
"@vitest/browser-playwright": "4.1.8",
|
||||
"@vitest/coverage-v8": "4.1.8",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"dotenv": "16.6.1",
|
||||
"dotenv-expand": "12.0.3",
|
||||
@@ -158,7 +158,7 @@
|
||||
"prettier-plugin-tailwindcss": "0.6.14",
|
||||
"tailwindcss": "4.1.18",
|
||||
"typescript": "5.5.4",
|
||||
"vitest": "4.0.18",
|
||||
"vitest": "4.1.8",
|
||||
"vitest-browser-react": "2.0.4"
|
||||
},
|
||||
"packageManager": "pnpm@11.1.3+sha512.c85357fe17ca12dd23dd7071822666dfd7e3cb76fe214e3370b5ea2fb34f2a231185509b63e717f3cd0acb38dd3f8d82bcd5e8172400ae678b70ea4fbed0896d",
|
||||
|
||||
Generated
+103
-110
@@ -325,14 +325,14 @@ importers:
|
||||
specifier: 5.1.2
|
||||
version: 5.1.2(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
'@vitest/browser':
|
||||
specifier: 4.0.18
|
||||
version: 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
|
||||
specifier: 4.1.8
|
||||
version: 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
|
||||
'@vitest/browser-playwright':
|
||||
specifier: 4.0.18
|
||||
version: 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
|
||||
specifier: 4.1.8
|
||||
version: 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 4.0.18
|
||||
version: 4.0.18(@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18))(vitest@4.0.18)
|
||||
specifier: 4.1.8
|
||||
version: 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)
|
||||
babel-plugin-react-compiler:
|
||||
specifier: 1.0.0
|
||||
version: 1.0.0
|
||||
@@ -400,11 +400,11 @@ importers:
|
||||
specifier: 5.5.4
|
||||
version: 5.5.4
|
||||
vitest:
|
||||
specifier: 4.0.18
|
||||
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
|
||||
specifier: 4.1.8
|
||||
version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
vitest-browser-react:
|
||||
specifier: 2.0.4
|
||||
version: 2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.0.18)
|
||||
version: 2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.8)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -737,6 +737,9 @@ packages:
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@blazediff/core@1.9.1':
|
||||
resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==}
|
||||
|
||||
'@braintree/sanitize-url@7.1.1':
|
||||
resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==}
|
||||
|
||||
@@ -4795,54 +4798,54 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
|
||||
'@vitest/browser-playwright@4.0.18':
|
||||
resolution: {integrity: sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==}
|
||||
'@vitest/browser-playwright@4.1.8':
|
||||
resolution: {integrity: sha512-SR7FqgegaexEg73xvf3ArtygXegagMdXnL0EZMpxrWvvhQxvicD/E8p0ib0J91riPRtQUViyh67Xjw3NqvyhVg==}
|
||||
peerDependencies:
|
||||
playwright: '*'
|
||||
vitest: 4.0.18
|
||||
vitest: 4.1.8
|
||||
|
||||
'@vitest/browser@4.0.18':
|
||||
resolution: {integrity: sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==}
|
||||
'@vitest/browser@4.1.8':
|
||||
resolution: {integrity: sha512-u21VzX07HzlJYpFgkxmjEXar/tG2UqWGgyGG/46SrrPc7rSdCTPw5vuowopO9CIqF8UCUQzDFdbVnNpw6N0BfQ==}
|
||||
peerDependencies:
|
||||
vitest: 4.0.18
|
||||
vitest: 4.1.8
|
||||
|
||||
'@vitest/coverage-v8@4.0.18':
|
||||
resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==}
|
||||
'@vitest/coverage-v8@4.1.8':
|
||||
resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==}
|
||||
peerDependencies:
|
||||
'@vitest/browser': 4.0.18
|
||||
vitest: 4.0.18
|
||||
'@vitest/browser': 4.1.8
|
||||
vitest: 4.1.8
|
||||
peerDependenciesMeta:
|
||||
'@vitest/browser':
|
||||
optional: true
|
||||
|
||||
'@vitest/expect@4.0.18':
|
||||
resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==}
|
||||
'@vitest/expect@4.1.8':
|
||||
resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==}
|
||||
|
||||
'@vitest/mocker@4.0.18':
|
||||
resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==}
|
||||
'@vitest/mocker@4.1.8':
|
||||
resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^6.0.0 || ^7.0.0-0
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
msw:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/pretty-format@4.0.18':
|
||||
resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==}
|
||||
'@vitest/pretty-format@4.1.8':
|
||||
resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==}
|
||||
|
||||
'@vitest/runner@4.0.18':
|
||||
resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==}
|
||||
'@vitest/runner@4.1.8':
|
||||
resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==}
|
||||
|
||||
'@vitest/snapshot@4.0.18':
|
||||
resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==}
|
||||
'@vitest/snapshot@4.1.8':
|
||||
resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==}
|
||||
|
||||
'@vitest/spy@4.0.18':
|
||||
resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==}
|
||||
'@vitest/spy@4.1.8':
|
||||
resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==}
|
||||
|
||||
'@vitest/utils@4.0.18':
|
||||
resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==}
|
||||
'@vitest/utils@4.1.8':
|
||||
resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==}
|
||||
|
||||
'@webassemblyjs/ast@1.14.1':
|
||||
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
|
||||
@@ -5048,8 +5051,8 @@ packages:
|
||||
ast-types-flow@0.0.8:
|
||||
resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
|
||||
|
||||
ast-v8-to-istanbul@0.3.12:
|
||||
resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==}
|
||||
ast-v8-to-istanbul@1.0.3:
|
||||
resolution: {integrity: sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==}
|
||||
|
||||
async-function@1.0.0:
|
||||
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
|
||||
@@ -5650,9 +5653,6 @@ packages:
|
||||
resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-module-lexer@1.7.0:
|
||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||
|
||||
es-module-lexer@2.1.0:
|
||||
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
|
||||
|
||||
@@ -7246,10 +7246,6 @@ packages:
|
||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
pixelmatch@7.1.0:
|
||||
resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==}
|
||||
hasBin: true
|
||||
|
||||
pkce-challenge@5.0.1:
|
||||
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
@@ -7537,6 +7533,7 @@ packages:
|
||||
recharts@2.15.4:
|
||||
resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==}
|
||||
engines: {node: '>=14'}
|
||||
deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide
|
||||
peerDependencies:
|
||||
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
@@ -7829,8 +7826,8 @@ packages:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
std-env@3.10.0:
|
||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||
std-env@4.1.0:
|
||||
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
|
||||
|
||||
stop-iteration-iterator@1.1.0:
|
||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||
@@ -8347,20 +8344,23 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
vitest@4.0.18:
|
||||
resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==}
|
||||
vitest@4.1.8:
|
||||
resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==}
|
||||
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 4.0.18
|
||||
'@vitest/browser-preview': 4.0.18
|
||||
'@vitest/browser-webdriverio': 4.0.18
|
||||
'@vitest/ui': 4.0.18
|
||||
'@vitest/browser-playwright': 4.1.8
|
||||
'@vitest/browser-preview': 4.1.8
|
||||
'@vitest/browser-webdriverio': 4.1.8
|
||||
'@vitest/coverage-istanbul': 4.1.8
|
||||
'@vitest/coverage-v8': 4.1.8
|
||||
'@vitest/ui': 4.1.8
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
'@edge-runtime/vm':
|
||||
optional: true
|
||||
@@ -8374,6 +8374,10 @@ packages:
|
||||
optional: true
|
||||
'@vitest/browser-webdriverio':
|
||||
optional: true
|
||||
'@vitest/coverage-istanbul':
|
||||
optional: true
|
||||
'@vitest/coverage-v8':
|
||||
optional: true
|
||||
'@vitest/ui':
|
||||
optional: true
|
||||
happy-dom:
|
||||
@@ -9319,6 +9323,8 @@ snapshots:
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@blazediff/core@1.9.1': {}
|
||||
|
||||
'@braintree/sanitize-url@7.1.1': {}
|
||||
|
||||
'@cfworker/json-schema@4.1.1': {}
|
||||
@@ -14261,29 +14267,29 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitest/browser-playwright@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)':
|
||||
'@vitest/browser-playwright@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)':
|
||||
dependencies:
|
||||
'@vitest/browser': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
|
||||
'@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
'@vitest/browser': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
|
||||
'@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
playwright: 1.56.1
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
|
||||
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- msw
|
||||
- utf-8-validate
|
||||
- vite
|
||||
|
||||
'@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)':
|
||||
'@vitest/browser@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)':
|
||||
dependencies:
|
||||
'@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
'@vitest/utils': 4.0.18
|
||||
'@blazediff/core': 1.9.1
|
||||
'@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
'@vitest/utils': 4.1.8
|
||||
magic-string: 0.30.21
|
||||
pixelmatch: 7.1.0
|
||||
pngjs: 7.0.0
|
||||
sirv: 3.0.2
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
|
||||
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
ws: 8.20.1
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
@@ -14291,60 +14297,62 @@ snapshots:
|
||||
- utf-8-validate
|
||||
- vite
|
||||
|
||||
'@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18))(vitest@4.0.18)':
|
||||
'@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)':
|
||||
dependencies:
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
'@vitest/utils': 4.0.18
|
||||
ast-v8-to-istanbul: 0.3.12
|
||||
'@vitest/utils': 4.1.8
|
||||
ast-v8-to-istanbul: 1.0.3
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
istanbul-reports: 3.2.0
|
||||
magicast: 0.5.2
|
||||
obug: 2.1.1
|
||||
std-env: 3.10.0
|
||||
std-env: 4.1.0
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
|
||||
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
optionalDependencies:
|
||||
'@vitest/browser': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
|
||||
'@vitest/browser': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
|
||||
|
||||
'@vitest/expect@4.0.18':
|
||||
'@vitest/expect@4.1.8':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/spy': 4.0.18
|
||||
'@vitest/utils': 4.0.18
|
||||
'@vitest/spy': 4.1.8
|
||||
'@vitest/utils': 4.1.8
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/mocker@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))':
|
||||
'@vitest/mocker@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.0.18
|
||||
'@vitest/spy': 4.1.8
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
msw: 2.13.4(@types/node@24.10.8)(typescript@5.5.4)
|
||||
vite: 7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)
|
||||
|
||||
'@vitest/pretty-format@4.0.18':
|
||||
'@vitest/pretty-format@4.1.8':
|
||||
dependencies:
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/runner@4.0.18':
|
||||
'@vitest/runner@4.1.8':
|
||||
dependencies:
|
||||
'@vitest/utils': 4.0.18
|
||||
'@vitest/utils': 4.1.8
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/snapshot@4.0.18':
|
||||
'@vitest/snapshot@4.1.8':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.0.18
|
||||
'@vitest/pretty-format': 4.1.8
|
||||
'@vitest/utils': 4.1.8
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/spy@4.0.18': {}
|
||||
'@vitest/spy@4.1.8': {}
|
||||
|
||||
'@vitest/utils@4.0.18':
|
||||
'@vitest/utils@4.1.8':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.0.18
|
||||
'@vitest/pretty-format': 4.1.8
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@webassemblyjs/ast@1.14.1':
|
||||
@@ -14604,7 +14612,7 @@ snapshots:
|
||||
|
||||
ast-types-flow@0.0.8: {}
|
||||
|
||||
ast-v8-to-istanbul@0.3.12:
|
||||
ast-v8-to-istanbul@1.0.3:
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
estree-walker: 3.0.3
|
||||
@@ -15273,8 +15281,6 @@ snapshots:
|
||||
iterator.prototype: 1.1.5
|
||||
safe-array-concat: 1.1.3
|
||||
|
||||
es-module-lexer@1.7.0: {}
|
||||
|
||||
es-module-lexer@2.1.0: {}
|
||||
|
||||
es-object-atoms@1.1.1:
|
||||
@@ -17280,10 +17286,6 @@ snapshots:
|
||||
|
||||
picomatch@4.0.4: {}
|
||||
|
||||
pixelmatch@7.1.0:
|
||||
dependencies:
|
||||
pngjs: 7.0.0
|
||||
|
||||
pkce-challenge@5.0.1: {}
|
||||
|
||||
pkg-types@1.3.1:
|
||||
@@ -17980,7 +17982,7 @@ snapshots:
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
||||
std-env@3.10.0: {}
|
||||
std-env@4.1.0: {}
|
||||
|
||||
stop-iteration-iterator@1.1.0:
|
||||
dependencies:
|
||||
@@ -18487,31 +18489,31 @@ snapshots:
|
||||
terser: 5.47.1
|
||||
yaml: 2.9.0
|
||||
|
||||
vitest-browser-react@2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.0.18):
|
||||
vitest-browser-react@2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.8):
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
|
||||
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.8
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.8)
|
||||
|
||||
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0):
|
||||
vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.0.18
|
||||
'@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
'@vitest/pretty-format': 4.0.18
|
||||
'@vitest/runner': 4.0.18
|
||||
'@vitest/snapshot': 4.0.18
|
||||
'@vitest/spy': 4.0.18
|
||||
'@vitest/utils': 4.0.18
|
||||
es-module-lexer: 1.7.0
|
||||
'@vitest/expect': 4.1.8
|
||||
'@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
'@vitest/pretty-format': 4.1.8
|
||||
'@vitest/runner': 4.1.8
|
||||
'@vitest/snapshot': 4.1.8
|
||||
'@vitest/spy': 4.1.8
|
||||
'@vitest/utils': 4.1.8
|
||||
es-module-lexer: 2.1.0
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.1
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.4
|
||||
std-env: 3.10.0
|
||||
std-env: 4.1.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 1.1.2
|
||||
tinyglobby: 0.2.16
|
||||
@@ -18521,20 +18523,11 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@types/node': 24.10.8
|
||||
'@vitest/browser-playwright': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
|
||||
'@vitest/browser-playwright': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
|
||||
'@vitest/coverage-v8': 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)
|
||||
jsdom: 27.4.0
|
||||
transitivePeerDependencies:
|
||||
- jiti
|
||||
- less
|
||||
- lightningcss
|
||||
- msw
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- terser
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
|
||||
|
||||
+27
-4
@@ -132,6 +132,24 @@ export async function addAWSProvider(
|
||||
await scansPage.verifyPageLoaded();
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the providers page to settle and reports whether the data table is
|
||||
* present. With zero providers the page renders a full-page empty state
|
||||
* ("No Providers Configured") instead of the table, so callers must not assume
|
||||
* the table is always there.
|
||||
*/
|
||||
async function providersTableVisibleOrEmptyState(
|
||||
page: ProvidersPage,
|
||||
): Promise<boolean> {
|
||||
const emptyState = page.page.getByRole("region", {
|
||||
name: /no providers configured/i,
|
||||
});
|
||||
await expect(page.providersTable.or(emptyState)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
return page.providersTable.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
export async function deleteProviderIfExists(
|
||||
page: ProvidersPage,
|
||||
providerUID: string,
|
||||
@@ -140,7 +158,11 @@ export async function deleteProviderIfExists(
|
||||
|
||||
// Navigate to providers page
|
||||
await page.goto();
|
||||
await expect(page.providersTable).toBeVisible({ timeout: 10000 });
|
||||
// With zero providers the page shows the empty state, not the table, so there
|
||||
// is nothing to delete.
|
||||
if (!(await providersTableVisibleOrEmptyState(page))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allRows = page.providersTable.locator("tbody tr");
|
||||
|
||||
@@ -180,7 +202,7 @@ export async function deleteProviderIfExists(
|
||||
// Provider not found, nothing to delete
|
||||
// Navigate back to providers page to ensure clean state
|
||||
await page.goto();
|
||||
await expect(page.providersTable).toBeVisible({ timeout: 10000 });
|
||||
await providersTableVisibleOrEmptyState(page);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -217,7 +239,8 @@ export async function deleteProviderIfExists(
|
||||
// Wait for modal to close (this indicates deletion was initiated)
|
||||
await expect(modal).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Navigate back to providers page to ensure clean state
|
||||
// Navigate back to providers page to ensure clean state. Deleting the last
|
||||
// provider reveals the empty state instead of an empty table.
|
||||
await page.goto();
|
||||
await expect(page.providersTable).toBeVisible({ timeout: 10000 });
|
||||
await providersTableVisibleOrEmptyState(page);
|
||||
}
|
||||
|
||||
@@ -341,7 +341,10 @@ export class ProvidersPage extends BasePage {
|
||||
name: /Adding A Provider|Update Provider Credentials/i,
|
||||
});
|
||||
|
||||
// Button to add a new provider
|
||||
// Button to add a new provider. When providers exist this is the filter-bar
|
||||
// "Add Provider" control; with zero providers the page renders the empty
|
||||
// state whose CTA is labelled "Open Add Provider modal" (button on
|
||||
// /providers, link on /scans). Only one of these is ever in the DOM at once.
|
||||
this.addProviderButton = page
|
||||
.getByRole("button", {
|
||||
name: "Add Provider",
|
||||
@@ -352,7 +355,9 @@ export class ProvidersPage extends BasePage {
|
||||
name: "Add Provider",
|
||||
exact: true,
|
||||
}),
|
||||
);
|
||||
)
|
||||
.or(page.getByRole("button", { name: "Open Add Provider modal" }))
|
||||
.or(page.getByRole("link", { name: "Open Add Provider modal" }));
|
||||
|
||||
// Table displaying existing providers
|
||||
this.providersTable = page.getByRole("table");
|
||||
|
||||
Vendored
+125
@@ -0,0 +1,125 @@
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
// Runtime (Node / Next.js)
|
||||
NODE_ENV: "development" | "production" | "test";
|
||||
NEXT_RUNTIME?: "nodejs" | "edge";
|
||||
|
||||
// Public client config
|
||||
NEXT_PUBLIC_API_BASE_URL: string;
|
||||
NEXT_PUBLIC_API_DOCS_URL?: string;
|
||||
NEXT_PUBLIC_IS_CLOUD_ENV?: "true" | "false";
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION?: string;
|
||||
NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID?: string;
|
||||
NEXT_PUBLIC_SENTRY_DSN?: string;
|
||||
NEXT_PUBLIC_SENTRY_ENVIRONMENT?: string;
|
||||
|
||||
// Auth (NextAuth)
|
||||
AUTH_URL: string;
|
||||
AUTH_SECRET: string;
|
||||
AUTH_TRUST_HOST?: "true" | "false";
|
||||
NEXTAUTH_URL?: string;
|
||||
|
||||
// Sentry (server / build)
|
||||
SENTRY_DSN?: string;
|
||||
SENTRY_ENVIRONMENT?: string;
|
||||
SENTRY_RELEASE?: string;
|
||||
SENTRY_ORG?: string;
|
||||
SENTRY_PROJECT?: string;
|
||||
SENTRY_AUTH_TOKEN?: string;
|
||||
|
||||
// Social OAuth
|
||||
SOCIAL_GOOGLE_OAUTH_CLIENT_ID?: string;
|
||||
SOCIAL_GOOGLE_OAUTH_CLIENT_SECRET?: string;
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL?: string;
|
||||
SOCIAL_GITHUB_OAUTH_CLIENT_ID?: string;
|
||||
SOCIAL_GITHUB_OAUTH_CLIENT_SECRET?: string;
|
||||
SOCIAL_GITHUB_OAUTH_CALLBACK_URL?: string;
|
||||
|
||||
// Feature integrations
|
||||
PROWLER_MCP_SERVER_URL?: string;
|
||||
// JSON-encoded array, parsed in actions/feeds
|
||||
RSS_FEED_SOURCES?: string;
|
||||
|
||||
// Environment detection
|
||||
CI?: string;
|
||||
DOCKER?: string;
|
||||
KUBERNETES_SERVICE_HOST?: string;
|
||||
|
||||
// E2E test credentials (Playwright only)
|
||||
E2E_ADMIN_USER?: string;
|
||||
E2E_ADMIN_PASSWORD?: string;
|
||||
E2E_NEW_USER_PASSWORD?: string;
|
||||
E2E_MANAGE_CLOUD_PROVIDERS_USER?: string;
|
||||
E2E_MANAGE_CLOUD_PROVIDERS_PASSWORD?: string;
|
||||
E2E_INVITE_AND_MANAGE_USERS_USER?: string;
|
||||
E2E_INVITE_AND_MANAGE_USERS_PASSWORD?: string;
|
||||
E2E_UNLIMITED_VISIBILITY_USER?: string;
|
||||
E2E_UNLIMITED_VISIBILITY_PASSWORD?: string;
|
||||
E2E_MANAGE_INTEGRATIONS_USER?: string;
|
||||
E2E_MANAGE_INTEGRATIONS_PASSWORD?: string;
|
||||
E2E_MANAGE_ACCOUNT_USER?: string;
|
||||
E2E_MANAGE_ACCOUNT_PASSWORD?: string;
|
||||
E2E_MANAGE_SCANS_USER?: string;
|
||||
E2E_MANAGE_SCANS_PASSWORD?: string;
|
||||
E2E_ORGANIZATION_ID?: string;
|
||||
|
||||
// E2E AWS
|
||||
E2E_AWS_PROVIDER_ACCOUNT_ID?: string;
|
||||
E2E_AWS_PROVIDER_ACCESS_KEY?: string;
|
||||
E2E_AWS_PROVIDER_SECRET_KEY?: string;
|
||||
E2E_AWS_PROVIDER_ROLE_ARN?: string;
|
||||
E2E_AWS_ORGANIZATION_ID?: string;
|
||||
E2E_AWS_ORGANIZATION_ROLE_ARN?: string;
|
||||
|
||||
// E2E Azure
|
||||
E2E_AZURE_SUBSCRIPTION_ID?: string;
|
||||
E2E_AZURE_CLIENT_ID?: string;
|
||||
E2E_AZURE_SECRET_ID?: string;
|
||||
E2E_AZURE_TENANT_ID?: string;
|
||||
|
||||
// E2E Microsoft 365
|
||||
E2E_M365_DOMAIN_ID?: string;
|
||||
E2E_M365_CLIENT_ID?: string;
|
||||
E2E_M365_TENANT_ID?: string;
|
||||
E2E_M365_SECRET_ID?: string;
|
||||
E2E_M365_CERTIFICATE_CONTENT?: string;
|
||||
|
||||
// E2E GCP
|
||||
E2E_GCP_PROJECT_ID?: string;
|
||||
E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY?: string;
|
||||
|
||||
// E2E Kubernetes
|
||||
E2E_KUBERNETES_CONTEXT?: string;
|
||||
E2E_KUBERNETES_KUBECONFIG_PATH?: string;
|
||||
|
||||
// E2E GitHub
|
||||
E2E_GITHUB_USERNAME?: string;
|
||||
E2E_GITHUB_PERSONAL_ACCESS_TOKEN?: string;
|
||||
E2E_GITHUB_APP_ID?: string;
|
||||
E2E_GITHUB_BASE64_APP_PRIVATE_KEY?: string;
|
||||
E2E_GITHUB_ORGANIZATION?: string;
|
||||
E2E_GITHUB_ORGANIZATION_ACCESS_TOKEN?: string;
|
||||
|
||||
// E2E Oracle Cloud
|
||||
E2E_OCI_TENANCY_ID?: string;
|
||||
E2E_OCI_USER_ID?: string;
|
||||
E2E_OCI_FINGERPRINT?: string;
|
||||
E2E_OCI_KEY_CONTENT?: string;
|
||||
E2E_OCI_REGION?: string;
|
||||
|
||||
// E2E Alibaba Cloud
|
||||
E2E_ALIBABACLOUD_ACCOUNT_ID?: string;
|
||||
E2E_ALIBABACLOUD_ACCESS_KEY_ID?: string;
|
||||
E2E_ALIBABACLOUD_ACCESS_KEY_SECRET?: string;
|
||||
E2E_ALIBABACLOUD_ROLE_ARN?: string;
|
||||
|
||||
// E2E Google Workspace
|
||||
E2E_GOOGLEWORKSPACE_CUSTOMER_ID?: string;
|
||||
E2E_GOOGLEWORKSPACE_SERVICE_ACCOUNT_JSON?: string;
|
||||
E2E_GOOGLEWORKSPACE_DELEGATED_USER?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
Reference in New Issue
Block a user