Compare commits

...
Author SHA1 Message Date
Prowler Botandprowler-bot 499fd637f3 chore(api): Update prowler dependency to v5.44 for release 5.44.0 (#12901)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-09-29 13:44:54 +02:00
Alejandro Bailo 60b936005c test(ui): scope E2E delete dialog and scans table locators (#12898) 2026-09-29 12:32:55 +02:00
Pedro Martín 03502c2426 fix(api): require operation permission to revoke tasks (#12893) 2026-09-28 17:15:39 +02:00
Alejandro Bailo e6320b178a fix(ui): bundle all icons so the UI renders without internet access (#12892) 2026-09-28 15:58:32 +02:00
Alejandro Bailo 4195a4f818 test(ui): add AWS provider in one step in the E2E helper (#12895) 2026-09-28 15:38:47 +02:00
César Arroba 8d003c60d0 fix(api): skip unconfigured attack paths sinks on provider deletion (#12894)
Provider deletion now skips attack path graph cleanup for a sink whose connection settings have already been removed, instead of failing. The skip is logged as a warning, while the configured active sink still raises on error as before.
2026-09-28 14:33:44 +02:00
Alejandro Bailo d5136f364c perf(ui): stream the findings page and load the Finding Group filter on open (#12891) 2026-09-28 12:21:13 +02:00
Rubén De la Torre Vico 453c953f37 fix(api): avoid field-named annotation in attack surface aggregation (#12889) 2026-09-28 11:39:36 +02:00
César Arroba c114aa304b fix(api): stop locking the API key row on every authenticated request (#12882) 2026-09-28 11:16:13 +02:00
100 changed files with 2243 additions and 555 deletions
@@ -0,0 +1 @@
API key authentication no longer locks the key row on every request and now throttles `last_used_at` updates to once per 60 seconds, preventing a hot key from serializing all its requests onto a single locked row
@@ -0,0 +1 @@
Provider deletion no longer fails when the provider has Attack Paths scans recorded on a sink that is no longer configured, such as Neptune after moving back to Neo4j
@@ -0,0 +1 @@
`DELETE /api/v1/tasks/{id}` requires the permission of the operation that queued the task and rejects provider deletions, and `GET /api/v1/tasks` hides tasks of providers outside the visibility of the role
+1 -1
View File
@@ -45,7 +45,7 @@ dependencies = [
"gunicorn==26.0.0",
"uvloop==0.22.1",
"lxml==6.1.0",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.44",
"psycopg2-binary==2.9.9",
"pytest-celery[redis] (==1.3.0)",
"sentry-sdk[django] (==2.56.0)",
+45 -33
View File
@@ -1,4 +1,5 @@
import logging
from datetime import timedelta
from math import isfinite
from uuid import UUID
@@ -6,7 +7,7 @@ from api.db_router import MainRouter
from api.models import TenantAPIKey, TenantAPIKeyManager
from cryptography.fernet import InvalidToken
from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from drf_simple_apikey.backends import APIKeyAuthentication as BaseAPIKeyAuth
from drf_simple_apikey.crypto import get_crypto
@@ -18,12 +19,15 @@ from rest_framework_simplejwt.authentication import JWTAuthentication
logger = logging.getLogger(__name__)
# Writing on every request makes all requests of a busy key contend on one row
API_KEY_LAST_USED_AT_THROTTLE_SECONDS = 60
class OrphanedAPIKeyError(Exception):
"""Raised when an API key outlived the user that owns it.
Handled by `authenticate`, which commits the revocation written while detecting it
and then rejects the request with `AuthenticationFailed`.
The revocation is written by a plain `update()` before this is raised, so it is
already persisted by the time `authenticate` catches it and rejects the request.
"""
@@ -37,8 +41,9 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
"""
Override to use admin connection, bypassing RLS during authentication.
Returns the validated API key row, locked with `select_for_update`, so callers
must run inside `transaction.atomic(using=MainRouter.admin_db)`.
Returns the validated API key row from a single read. `authenticate` builds
the auth claims from that same row instead of looking it up again, so a key
revoked or orphaned right after validation can't still authenticate.
"""
try:
payload = self.key_crypto.decrypt(key)
@@ -67,9 +72,11 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
raise AuthenticationFailed("API Key has already expired.")
try:
# Loading `entity` in the same query keeps a user deleted after this read
# from turning the later `api_key.entity` access into a 500
api_key = (
self.model.objects.using(MainRouter.admin_db)
.select_for_update()
.select_related("entity")
.get(id=api_key_pk)
)
except ObjectDoesNotExist:
@@ -85,8 +92,9 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
# Revoke it as well, so it stops showing up as active and later attempts fail
# the `revoked` check above like any other revoked key.
if api_key.entity_id is None:
api_key.revoked = True
api_key.save(update_fields=["revoked"], using=MainRouter.admin_db)
self.model.objects.using(MainRouter.admin_db).filter(
id=api_key.id, revoked=False
).update(revoked=True)
logger.warning(
"Revoked orphaned API key: prefix=%s tenant=%s",
api_key.prefix,
@@ -112,34 +120,38 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
except ValueError:
raise AuthenticationFailed("Invalid API Key.")
# Validation, the `last_used_at` update and the auth claims all read the same
# row, locked until the transaction ends. Looking the key up a second time to
# build the claims used to leave a window where a key revoked or orphaned right
# after passing validation still authenticated.
with transaction.atomic(using=MainRouter.admin_db):
try:
api_key = self._authenticate_credentials(request, key)
except OrphanedAPIKeyError:
# Rejected below instead of here: leaving the block normally commits
# the revocation `_authenticate_credentials` wrote, while raising from
# inside would roll it back.
pass
else:
# The prefix used to be checked by the second lookup
if api_key.prefix != prefix:
raise AuthenticationFailed("Invalid API Key.")
try:
api_key = self._authenticate_credentials(request, key)
except OrphanedAPIKeyError:
raise AuthenticationFailed("No entity matching this api key.")
api_key.last_used_at = timezone.now()
api_key.save(update_fields=["last_used_at"], using=MainRouter.admin_db)
# The prefix used to be checked by the second lookup
if api_key.prefix != prefix:
raise AuthenticationFailed("Invalid API Key.")
entity = api_key.entity
return entity, {
"tenant_id": str(api_key.tenant_id),
"sub": str(entity.id),
"api_key_prefix": api_key.prefix,
}
self._throttled_touch_last_used_at(api_key)
raise AuthenticationFailed("No entity matching this api key.")
entity = api_key.entity
return entity, {
"tenant_id": str(api_key.tenant_id),
"sub": str(entity.id),
"api_key_prefix": api_key.prefix,
}
@staticmethod
def _throttled_touch_last_used_at(api_key: TenantAPIKey) -> None:
"""Write `last_used_at` at most once per throttle interval, without locking the row."""
now = timezone.now()
stale_before = now - timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS)
if api_key.last_used_at is not None and api_key.last_used_at >= stale_before:
return
TenantAPIKey.objects.using(MainRouter.admin_db).filter(
id=api_key.id, revoked=False
).filter(
Q(last_used_at__isnull=True) | Q(last_used_at__lt=stale_before)
).update(last_used_at=now)
class CombinedJWTOrAPIKeyAuthentication(BaseAuthentication):
+66 -11
View File
@@ -1,7 +1,7 @@
from enum import Enum
from api.db_router import MainRouter
from api.models import Integration, Provider, Role, User
from api.models import Integration, Provider, Role, Task, User
from django.db.models import Q, QuerySet
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import BasePermission
@@ -17,6 +17,50 @@ class Permissions(Enum):
UNLIMITED_VISIBILITY = "unlimited_visibility"
# Revoking a task needs the permission of the operation that queued it.
# None and unmapped names are not revocable; a revoked provider deletion
# would leave the provider soft-deleted with nothing re-queuing the cleanup.
TASK_REVOKE_PERMISSIONS: dict[str, list[Permissions] | None] = {
"provider-connection-check": [Permissions.MANAGE_PROVIDERS],
"provider-deletion": None,
"integration-connection-check": [Permissions.MANAGE_INTEGRATIONS],
"integration-s3": [Permissions.MANAGE_INTEGRATIONS],
"integration-security-hub": [Permissions.MANAGE_INTEGRATIONS],
"integration-jira": [Permissions.MANAGE_INTEGRATIONS],
"scan-perform": [Permissions.MANAGE_SCANS],
"scan-perform-scheduled": [Permissions.MANAGE_SCANS],
"scan-compliance-overviews": [Permissions.MANAGE_SCANS],
"scan-compliance-reports": [Permissions.MANAGE_SCANS],
"scan-finding-group-summaries": [Permissions.MANAGE_SCANS],
"scan-report": [Permissions.MANAGE_SCANS],
"attack-paths-scan-perform": [Permissions.MANAGE_SCANS],
"findings-mute-latest-scans": [Permissions.MANAGE_SCANS],
"lighthouse-connection-check": [],
"lighthouse-provider-connection-check": [],
"lighthouse-provider-models-refresh": [],
}
def get_user_roles(user: User, tenant_id: str) -> list[Role]:
"""Return every role assigned to the user in the tenant."""
return list(
User.objects.using(MainRouter.admin_db)
.get(id=user.id)
.roles.using(MainRouter.admin_db)
.filter(tenant_id=tenant_id)
)
def roles_have_permissions(
roles: list[Role], required_permissions: list[Permissions]
) -> bool:
"""Return True when every required permission is granted by at least one role."""
return all(
any(getattr(role, permission.value, False) for role in roles)
for permission in required_permissions
)
class HasPermissions(BasePermission):
"""
Custom permission to check if the user's role has the required permissions.
@@ -34,19 +78,11 @@ class HasPermissions(BasePermission):
if not tenant_id:
return False
user_roles = list(
User.objects.using(MainRouter.admin_db)
.get(id=request.user.id)
.roles.using(MainRouter.admin_db)
.filter(tenant_id=tenant_id)
)
user_roles = get_user_roles(request.user, tenant_id)
if not user_roles:
return False
return all(
any(getattr(role, permission.value, False) for role in user_roles)
for permission in required_permissions
)
return roles_have_permissions(user_roles, required_permissions)
def get_role(user: User, tenant_id: str) -> Role:
@@ -85,6 +121,25 @@ def get_providers(role: Role) -> QuerySet[Provider]:
).distinct()
def get_tasks(role: Role) -> QuerySet[Task]:
"""Return the tasks visible to the role: tenant-wide ones and those of its providers."""
queryset = Task.objects.filter(tenant_id=role.tenant_id)
if role.unlimited_visibility:
return queryset
# Task has no provider FK, so match provider ids inside the stored kwargs.
# all_objects keeps a soft-deleted provider visible to its own groups, so the
# role that queued its deletion can still follow the task.
hidden = Q()
for provider_id in (
Provider.all_objects.filter(tenant_id=role.tenant_id)
.exclude(provider_groups__in=role.provider_groups.all())
.values_list("id", flat=True)
):
hidden |= Q(task_runner_task__task_kwargs__contains=str(provider_id))
return queryset.exclude(hidden) if hidden else queryset
def get_integrations(
role: Role, providers: QuerySet[Provider] | None = None
) -> QuerySet[Integration]:
+8 -3
View File
@@ -14823,7 +14823,9 @@ paths:
get:
operationId: api_v1_tasks_list
description: Retrieve a list of all tasks with options for filtering by name,
state, and other criteria.
state, and other criteria. Tasks that reference a provider are only returned
when the role can access it; tasks without a provider reference are returned
for every role.
summary: List all tasks
parameters:
- in: query
@@ -14922,7 +14924,8 @@ paths:
/api/v1/tasks/{id}:
get:
operationId: api_v1_tasks_retrieve
description: Fetch detailed information about a specific task by its ID.
description: Fetch detailed information about a specific task by its ID. Tasks
tied to a provider outside the visibility of the role are not found.
summary: Retrieve data from a specific task
parameters:
- in: query
@@ -14963,7 +14966,9 @@ paths:
delete:
operationId: api_v1_tasks_destroy
description: Try to revoke a task using its ID. Only tasks that are not yet
in progress can be revoked.
in progress can be revoked, and the caller needs the same permission as the
operation that queued the task (for example MANAGE_SCANS for a scan). Provider
deletions cannot be revoked.
summary: Revoke a task
parameters:
- in: path
@@ -1,9 +1,9 @@
import json
import time
from datetime import UTC, datetime, timedelta
from uuid import uuid4
import pytest
from api.authentication import API_KEY_LAST_USED_AT_THROTTLE_SECONDS
from api.db_router import MainRouter
from api.models import Membership, Role, TenantAPIKey, User, UserRoleRelationship
from api.signals import revoke_membership_api_keys, revoke_user_api_keys
@@ -11,6 +11,7 @@ from conftest import TEST_PASSWORD, get_api_tokens, get_authorization_header
from django.db.utils import ConnectionDoesNotExist
from django.urls import reverse
from drf_simple_apikey.crypto import get_crypto
from freezegun import freeze_time
from rest_framework.test import APIClient
from rest_framework_simplejwt.token_blacklist.models import (
BlacklistedToken,
@@ -527,7 +528,7 @@ class TestAPIKeyAuthentication:
def test_last_used_at_tracking(
self, create_test_user, tenants_fixture, api_keys_fixture
):
"""Verify last_used_at timestamp updates on each authentication."""
"""Verify last_used_at timestamp is set on first use and throttled after that."""
client = APIClient()
api_key = api_keys_fixture[0]
@@ -536,7 +537,11 @@ class TestAPIKeyAuthentication:
# Use API key to authenticate
api_key_headers = get_api_key_header(api_key._raw_key)
first_response = client.get(reverse("provider-list"), headers=api_key_headers)
start = datetime.now(UTC)
with freeze_time(start):
first_response = client.get(
reverse("provider-list"), headers=api_key_headers
)
assert first_response.status_code == 200
# Reload from database and check last_used_at is set
@@ -544,17 +549,23 @@ class TestAPIKeyAuthentication:
first_used_at = api_key.last_used_at
assert first_used_at is not None
# Use the same key again after a small delay
time.sleep(0.1)
# Using the same key again within the throttle interval does not rewrite it
second_response = client.get(reverse("provider-list"), headers=api_key_headers)
assert second_response.status_code == 200
# Reload and verify last_used_at was updated
api_key.refresh_from_db()
second_used_at = api_key.last_used_at
assert second_used_at is not None
assert second_used_at > first_used_at
assert api_key.last_used_at == first_used_at
# Past the throttle interval, the next use refreshes it
later = start + timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS + 1)
with freeze_time(later):
third_response = client.get(
reverse("provider-list"), headers=api_key_headers
)
assert third_response.status_code == 200
api_key.refresh_from_db()
assert api_key.last_used_at > first_used_at
@pytest.mark.django_db
@@ -1441,6 +1452,7 @@ class TestAPIKeyRLSBypass:
The update to last_used_at during authentication must also use the
admin database since it occurs before RLS context is established.
Past the throttle interval, using the key again refreshes the timestamp.
"""
client = APIClient()
api_key = api_keys_fixture[0]
@@ -1448,7 +1460,11 @@ class TestAPIKeyRLSBypass:
assert api_key.last_used_at is None
api_key_headers = get_api_key_header(api_key._raw_key)
first_response = client.get(reverse("provider-list"), headers=api_key_headers)
start = datetime.now(UTC)
with freeze_time(start):
first_response = client.get(
reverse("provider-list"), headers=api_key_headers
)
assert first_response.status_code == 200
@@ -1456,9 +1472,11 @@ class TestAPIKeyRLSBypass:
first_timestamp = api_key.last_used_at
assert first_timestamp is not None
time.sleep(0.1)
second_response = client.get(reverse("provider-list"), headers=api_key_headers)
later = start + timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS + 1)
with freeze_time(later):
second_response = client.get(
reverse("provider-list"), headers=api_key_headers
)
assert second_response.status_code == 200
api_key.refresh_from_db()
+137 -24
View File
@@ -5,16 +5,18 @@ from uuid import uuid4
import pytest
from api.authentication import (
API_KEY_LAST_USED_AT_THROTTLE_SECONDS,
OrphanedAPIKeyError,
SSEAuthentication,
TenantAPIKeyAuthentication,
)
from api.db_router import MainRouter
from api.models import TenantAPIKey
from api.models import TenantAPIKey, User
from django.db import connections
from django.db.models.query import QuerySet
from django.test import RequestFactory
from django.test.utils import CaptureQueriesContext
from freezegun import freeze_time
from rest_framework.exceptions import AuthenticationFailed
@@ -286,14 +288,15 @@ class TestTenantAPIKeyAuthentication:
assert str(exc_info.value.detail) == "This API Key has been revoked."
def test_authenticate_reads_the_api_key_once_under_a_row_lock(
def test_authenticate_reads_the_api_key_once_without_a_row_lock(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test the API key is read a single time and the row is locked.
"""Test the API key is read a single time and no row is locked.
Validation, the `last_used_at` update and the claims must all come from the
same authoritative row: a second, unlocked lookup would reopen the window
where a key revoked in between still authenticates.
same authoritative row: a second lookup would reopen the window where a key
revoked in between still authenticates. `SELECT ... FOR UPDATE` serialized
every request for a hot key onto one locked row and is not used any more.
"""
api_key = api_keys_fixture[0]
@@ -310,33 +313,40 @@ class TestTenantAPIKeyAuthentication:
]
assert len(api_key_selects) == 1
assert "FOR UPDATE" in api_key_selects[0]
assert "FOR UPDATE" not in api_key_selects[0]
def test_authenticate_ignores_revocation_after_the_locked_read(
def test_authenticate_ignores_revocation_after_the_single_read(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test the claims describe the row that was validated, not a later state.
Regression test: the key used to be looked up again to build the auth dict,
without rechecking `revoked` or `entity`. A key revoked or orphaned between
both reads still authenticated, and the claims came from that stale row. With
a single locked read the write below cannot land mid-authentication, and the
revocation only takes effect on the next request.
both reads still authenticated, and the claims came from that stale row.
There is now only a single read, so this race is closed by construction and
the revocation only takes effect on the next request.
"""
api_key = api_keys_fixture[0]
entity_at_validation = api_key.entity
original_save = TenantAPIKey.save
original_authenticate_credentials = (
TenantAPIKeyAuthentication._authenticate_credentials
)
def revoke_and_orphan_before_saving(instance, *args, **kwargs):
# Runs after validation, right before the claims are built: the exact
# window a concurrent revocation or user deletion used to slip into
def revoke_and_orphan_after_reading(self, request, key):
# Runs right after the single read `authenticate` will use to build the
# claims: the exact window a concurrent revocation used to slip into
result = original_authenticate_credentials(self, request, key)
TenantAPIKey.objects.filter(id=api_key.id).update(revoked=True, entity=None)
return original_save(instance, *args, **kwargs)
return result
request = request_factory.get("/")
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {api_key._raw_key}"
with patch.object(TenantAPIKey, "save", revoke_and_orphan_before_saving):
with patch.object(
TenantAPIKeyAuthentication,
"_authenticate_credentials",
revoke_and_orphan_after_reading,
):
entity, auth_dict = auth_backend.authenticate(request)
assert entity == entity_at_validation
@@ -350,6 +360,43 @@ class TestTenantAPIKeyAuthentication:
assert str(exc_info.value.detail) == "This API Key has been revoked."
def test_authenticate_survives_owner_deleted_after_the_single_read(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test a user deleted right after the read does not turn into a 500.
Without the row lock a concurrent user deletion can land between the read
and building the claims. `entity` is loaded by the same query, so no later
lookup can raise `DoesNotExist`.
"""
api_key = api_keys_fixture[0]
owner_id = api_key.entity_id
original_authenticate_credentials = (
TenantAPIKeyAuthentication._authenticate_credentials
)
def delete_owner_after_reading(self, request, key):
result = original_authenticate_credentials(self, request, key)
User.objects.using(MainRouter.admin_db).filter(id=owner_id).delete()
return result
request = request_factory.get("/")
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {api_key._raw_key}"
with patch.object(
TenantAPIKeyAuthentication,
"_authenticate_credentials",
delete_owner_after_reading,
):
entity, auth_dict = auth_backend.authenticate(request)
assert auth_dict["sub"] == str(owner_id)
assert entity.id == owner_id
# From the next request on, the orphaned key is rejected with a 401
with pytest.raises(AuthenticationFailed):
auth_backend.authenticate(request)
def test_authenticate_expired_api_key(
self, auth_backend, create_test_user, tenants_fixture, request_factory
):
@@ -421,24 +468,90 @@ class TestTenantAPIKeyAuthentication:
if original_last_used:
assert api_key.last_used_at > original_last_used
def test_authenticate_saves_to_admin_database(
def test_authenticate_updates_last_used_at_on_admin_database(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test that the API key save operation uses admin database."""
"""Test that the `last_used_at` update runs against the admin database."""
api_key = api_keys_fixture[0]
raw_key = api_key._raw_key
request = request_factory.get("/")
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
# Mock the save method to verify it's called with using='admin'
with patch.object(TenantAPIKey, "save") as mock_save:
with CaptureQueriesContext(connections[MainRouter.admin_db]) as captured:
auth_backend.authenticate(request)
# Verify save was called with using=admin_db
mock_save.assert_called_once_with(
update_fields=["last_used_at"], using=MainRouter.admin_db
)
api_key_updates = [
query["sql"]
for query in captured.captured_queries
if query["sql"].startswith("UPDATE") and '"api_keys"' in query["sql"]
]
assert len(api_key_updates) == 1
assert "last_used_at" in api_key_updates[0]
def test_authenticate_does_not_rewrite_last_used_at_within_throttle_interval(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test that a second authentication within the throttle interval is a no-op write."""
api_key = api_keys_fixture[0]
raw_key = api_key._raw_key
request = request_factory.get("/")
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
# First call sets last_used_at
auth_backend.authenticate(request)
api_key.refresh_from_db()
first_used_at = api_key.last_used_at
assert first_used_at is not None
# Second call, still within the throttle interval, must issue no UPDATE
with CaptureQueriesContext(connections[MainRouter.admin_db]) as captured:
auth_backend.authenticate(request)
api_key_updates = [
query["sql"]
for query in captured.captured_queries
if query["sql"].startswith("UPDATE") and '"api_keys"' in query["sql"]
]
assert api_key_updates == []
api_key.refresh_from_db()
assert api_key.last_used_at == first_used_at
def test_authenticate_rewrites_last_used_at_after_throttle_interval(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test that `last_used_at` is refreshed once it is older than the throttle interval."""
api_key = api_keys_fixture[0]
raw_key = api_key._raw_key
request = request_factory.get("/")
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
start = datetime.now(UTC)
with freeze_time(start):
auth_backend.authenticate(request)
api_key.refresh_from_db()
first_used_at = api_key.last_used_at
assert first_used_at is not None
later = start + timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS + 1)
with freeze_time(later):
with CaptureQueriesContext(connections[MainRouter.admin_db]) as captured:
auth_backend.authenticate(request)
api_key_updates = [
query["sql"]
for query in captured.captured_queries
if query["sql"].startswith("UPDATE") and '"api_keys"' in query["sql"]
]
assert len(api_key_updates) == 1
api_key.refresh_from_db()
assert api_key.last_used_at > first_used_at
def test_authenticate_returns_correct_auth_dict(
self, auth_backend, api_keys_fixture, request_factory
+301
View File
@@ -60,6 +60,7 @@ from api.models import (
User,
UserRoleRelationship,
)
from api.rbac.permissions import TASK_REVOKE_PERMISSIONS
from api.rls import Tenant
from api.uuid_utils import datetime_to_uuid7
from api.v1.views import (
@@ -5239,6 +5240,7 @@ class TestTaskViewSet:
@patch("api.v1.views.AsyncResult", return_value=Mock())
def test_tasks_revoke(self, mock_async_result, authenticated_client, tasks_fixture):
_, task2 = tasks_fixture
self._set_task_name(task2, "scan-perform")
response = authenticated_client.delete(
reverse("task-detail", kwargs={"pk": task2.id})
)
@@ -5254,12 +5256,311 @@ class TestTaskViewSet:
def test_tasks_revoke_invalid_status(self, authenticated_client, tasks_fixture):
task1, _ = tasks_fixture
self._set_task_name(task1, "scan-perform")
response = authenticated_client.delete(
reverse("task-detail", kwargs={"pk": task1.id})
)
# Task status is SUCCESS
assert response.status_code == status.HTTP_400_BAD_REQUEST
@staticmethod
def _set_task_name(task, name):
task.task_runner_task.task_name = name
task.task_runner_task.save(update_fields=["task_name"])
@staticmethod
def _set_task_kwargs(task, kwargs):
task.task_runner_task.task_kwargs = json.dumps(repr(kwargs))
task.task_runner_task.save(update_fields=["task_kwargs"])
@staticmethod
def _client_with_role(tenant, factory, **permissions):
user = User.objects.create_user(
name=f"revoker-{uuid4()}",
email=f"revoker-{uuid4()}@prowler.com",
password=TEST_PASSWORD,
)
Membership.objects.create(
user=user, tenant=tenant, role=Membership.RoleChoices.MEMBER
)
flags = {
"manage_users": False,
"manage_account": False,
"manage_billing": False,
"manage_providers": False,
"manage_integrations": False,
"manage_scans": False,
"unlimited_visibility": True,
**permissions,
}
role = Role.objects.create(
name=f"revoker-{uuid4()}", tenant_id=tenant.id, **flags
)
UserRoleRelationship.objects.create(user=user, role=role, tenant_id=tenant.id)
return factory(user, tenant)
@patch("api.v1.views.AsyncResult")
def test_tasks_revoke_without_permission_is_forbidden(
self, mock_async_result, authenticated_client_no_permissions_rbac, tasks_fixture
):
_, pending_task = tasks_fixture
self._set_task_name(pending_task, "provider-connection-check")
response = authenticated_client_no_permissions_rbac.delete(
reverse("task-detail", kwargs={"pk": pending_task.id})
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_async_result.return_value.revoke.assert_not_called()
@pytest.mark.parametrize(
"task_name, permissions, expected_status",
[
(
"provider-connection-check",
{"manage_providers": True},
status.HTTP_202_ACCEPTED,
),
(
"provider-connection-check",
{"manage_scans": True},
status.HTTP_403_FORBIDDEN,
),
("scan-perform", {"manage_scans": True}, status.HTTP_202_ACCEPTED),
(
"scan-perform-scheduled",
{"manage_providers": True},
status.HTTP_403_FORBIDDEN,
),
(
"integration-jira",
{"manage_integrations": True},
status.HTTP_202_ACCEPTED,
),
("integration-jira", {"manage_providers": True}, status.HTTP_403_FORBIDDEN),
("lighthouse-connection-check", {}, status.HTTP_202_ACCEPTED),
],
)
@patch("api.v1.views.AsyncResult")
def test_tasks_revoke_requires_originating_operation_permission(
self,
mock_async_result,
authenticated_client_for_tenant_factory,
tenants_fixture,
tasks_fixture,
task_name,
permissions,
expected_status,
):
tenant, *_ = tenants_fixture
_, pending_task = tasks_fixture
self._set_task_name(pending_task, task_name)
client = self._client_with_role(
tenant, authenticated_client_for_tenant_factory, **permissions
)
response = client.delete(reverse("task-detail", kwargs={"pk": pending_task.id}))
assert response.status_code == expected_status
if expected_status == status.HTTP_202_ACCEPTED:
mock_async_result.return_value.revoke.assert_called_once()
else:
mock_async_result.return_value.revoke.assert_not_called()
@patch("api.v1.views.AsyncResult")
def test_tasks_revoke_provider_deletion_is_forbidden_even_for_admin(
self, mock_async_result, authenticated_client, tasks_fixture
):
_, pending_task = tasks_fixture
self._set_task_name(pending_task, "provider-deletion")
response = authenticated_client.delete(
reverse("task-detail", kwargs={"pk": pending_task.id})
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_async_result.return_value.revoke.assert_not_called()
@patch("api.v1.views.AsyncResult")
def test_tasks_revoke_unmapped_task_is_forbidden(
self, mock_async_result, authenticated_client, tasks_fixture
):
_, pending_task = tasks_fixture
assert pending_task.task_runner_task.task_name not in TASK_REVOKE_PERMISSIONS
response = authenticated_client.delete(
reverse("task-detail", kwargs={"pk": pending_task.id})
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_async_result.return_value.revoke.assert_not_called()
def test_every_rls_task_has_revoke_permissions(self):
from config.celery import RLSTask, celery_app
rls_task_names = {
name for name, task in celery_app.tasks.items() if isinstance(task, RLSTask)
}
assert rls_task_names
assert rls_task_names <= set(TASK_REVOKE_PERMISSIONS)
@patch("api.v1.views.AsyncResult")
def test_tasks_hidden_for_providers_outside_role_visibility(
self,
mock_async_result,
authenticated_client_no_permissions_rbac,
tasks_fixture,
aws_provider_pair,
):
client = authenticated_client_no_permissions_rbac
limited_user = client.user
tenant = Membership.objects.filter(user=limited_user).first().tenant
allowed_provider, denied_provider = aws_provider_pair
allowed_task, denied_task = tasks_fixture
self._set_task_kwargs(
allowed_task,
{"tenant_id": str(tenant.id), "provider_id": str(allowed_provider.id)},
)
self._set_task_name(denied_task, "provider-deletion")
self._set_task_kwargs(
denied_task,
{"tenant_id": str(tenant.id), "provider_id": str(denied_provider.id)},
)
provider_group = ProviderGroup.objects.create(
name="limited-task-group", tenant_id=tenant.id
)
ProviderGroupMembership.objects.create(
tenant_id=tenant.id,
provider_group=provider_group,
provider=allowed_provider,
)
RoleProviderGroupRelationship.objects.create(
tenant_id=tenant.id,
role=limited_user.roles.first(),
provider_group=provider_group,
)
response = client.get(reverse("task-list"))
assert response.status_code == status.HTTP_200_OK
assert [item["id"] for item in response.json()["data"]] == [
str(allowed_task.id)
]
response = client.get(reverse("task-detail", kwargs={"pk": denied_task.id}))
assert response.status_code == status.HTTP_404_NOT_FOUND
response = client.delete(reverse("task-detail", kwargs={"pk": denied_task.id}))
assert response.status_code == status.HTTP_404_NOT_FOUND
mock_async_result.return_value.revoke.assert_not_called()
@patch("api.v1.views.AsyncResult")
def test_tasks_of_soft_deleted_provider_stay_visible_to_its_groups(
self,
mock_async_result,
authenticated_client_for_tenant_factory,
tenants_fixture,
tasks_fixture,
aws_provider_pair,
):
tenant, *_ = tenants_fixture
provider, _ = aws_provider_pair
finished_task, pending_task = tasks_fixture
client = self._client_with_role(
tenant,
authenticated_client_for_tenant_factory,
manage_providers=True,
unlimited_visibility=False,
)
provider_group = ProviderGroup.objects.create(
name="own-group", tenant_id=tenant.id
)
ProviderGroupMembership.objects.create(
tenant_id=tenant.id, provider_group=provider_group, provider=provider
)
RoleProviderGroupRelationship.objects.create(
tenant_id=tenant.id,
role=client.user.roles.first(),
provider_group=provider_group,
)
for task, name in (
(finished_task, "provider-deletion"),
(pending_task, "provider-connection-check"),
):
self._set_task_name(task, name)
self._set_task_kwargs(
task, {"tenant_id": str(tenant.id), "provider_id": str(provider.id)}
)
provider.is_deleted = True
provider.save()
response = client.get(reverse("task-detail", kwargs={"pk": finished_task.id}))
assert response.status_code == status.HTTP_200_OK
response = client.delete(reverse("task-detail", kwargs={"pk": pending_task.id}))
assert response.status_code == status.HTTP_202_ACCEPTED
mock_async_result.return_value.revoke.assert_called_once()
def test_tasks_without_provider_stay_visible_for_limited_roles(
self, authenticated_client_no_permissions_rbac, tasks_fixture, aws_provider_pair
):
response = authenticated_client_no_permissions_rbac.get(reverse("task-list"))
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == len(tasks_fixture)
def test_tasks_list_without_role_is_forbidden(
self, authenticated_client_rbac_noroles, tasks_fixture
):
response = authenticated_client_rbac_noroles.get(reverse("task-list"))
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_tasks_revoke_without_permission_hides_task_status(
self, authenticated_client_no_permissions_rbac, tasks_fixture
):
finished_task, _ = tasks_fixture
self._set_task_name(finished_task, "provider-connection-check")
response = authenticated_client_no_permissions_rbac.delete(
reverse("task-detail", kwargs={"pk": finished_task.id})
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@patch("api.v1.views.AsyncResult")
def test_tasks_revoke_unauthenticated_returns_401(
self, mock_async_result, tasks_fixture
):
from rest_framework.test import APIClient
_, pending_task = tasks_fixture
self._set_task_name(pending_task, "scan-perform")
response = APIClient().delete(
reverse("task-detail", kwargs={"pk": pending_task.id})
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
mock_async_result.return_value.revoke.assert_not_called()
@patch("api.v1.views.AsyncResult")
def test_tasks_revoke_foreign_tenant_task_returns_404(
self,
mock_async_result,
authenticated_client_for_tenant_factory,
tenants_fixture,
tasks_fixture,
):
_, foreign_tenant, *_ = tenants_fixture
_, pending_task = tasks_fixture
self._set_task_name(pending_task, "scan-perform")
client = self._client_with_role(
foreign_tenant, authenticated_client_for_tenant_factory, manage_scans=True
)
response = client.delete(reverse("task-detail", kwargs={"pk": pending_task.id}))
assert response.status_code == status.HTTP_404_NOT_FOUND
mock_async_result.return_value.revoke.assert_not_called()
@pytest.mark.django_db
class TestAttackPathsScanViewSet:
+37 -8
View File
@@ -125,10 +125,14 @@ from api.models import (
)
from api.pagination import ComplianceOverviewPagination
from api.rbac.permissions import (
TASK_REVOKE_PERMISSIONS,
Permissions,
get_integrations,
get_providers,
get_role,
get_tasks,
get_user_roles,
roles_have_permissions,
)
from api.renderers import APIJSONRenderer, PlainTextRenderer
from api.rls import Tenant
@@ -2858,17 +2862,29 @@ class ScanViewSet(ProviderVisibilityMixin, BaseRLSViewSet):
list=extend_schema(
tags=["Task"],
summary="List all tasks",
description="Retrieve a list of all tasks with options for filtering by name, state, and other criteria.",
description=(
"Retrieve a list of all tasks with options for filtering by name, state, and other "
"criteria. Tasks that reference a provider are only returned when the role can "
"access it; tasks without a provider reference are returned for every role."
),
),
retrieve=extend_schema(
tags=["Task"],
summary="Retrieve data from a specific task",
description="Fetch detailed information about a specific task by its ID.",
description=(
"Fetch detailed information about a specific task by its ID. Tasks tied to a provider "
"outside the visibility of the role are not found."
),
),
destroy=extend_schema(
tags=["Task"],
summary="Revoke a task",
description="Try to revoke a task using its ID. Only tasks that are not yet in progress can be revoked.",
description=(
"Try to revoke a task using its ID. Only tasks that are not yet in progress can be "
"revoked, and the caller needs the same permission as the operation that queued "
"the task (for example MANAGE_SCANS for a scan). Provider deletions cannot be "
"revoked."
),
responses={202: OpenApiResponse(response=TaskSerializer)},
),
)
@@ -2884,13 +2900,26 @@ class TaskViewSet(BaseRLSViewSet):
required_permissions = []
def get_queryset(self):
return Task.objects.annotate(
name=F("task_runner_task__task_name"),
state=F("task_runner_task__status"),
).select_related("task_runner_task")
return (
get_tasks(self.user_role)
.annotate(
name=F("task_runner_task__task_name"),
state=F("task_runner_task__status"),
)
.select_related("task_runner_task")
)
def destroy(self, request, *args, pk=None, **kwargs):
task = get_object_or_404(Task, pk=pk)
task = self.get_object()
required_permissions = TASK_REVOKE_PERMISSIONS.get(
task.task_runner_task.task_name
)
# Same multi-role semantics as HasPermissions.
if required_permissions is None or not roles_have_permissions(
get_user_roles(request.user, request.tenant_id), required_permissions
):
raise PermissionDenied("You do not have permission to revoke this task.")
if task.task_runner_task.status not in ["PENDING", "RECEIVED"]:
serializer = TaskSerializer(task)
return Response(
+14 -3
View File
@@ -13,6 +13,7 @@ from api.models import (
Tenant,
)
from celery.utils.log import get_task_logger
from django.conf import settings
from django.db import DatabaseError
from tasks.jobs.queries import (
COMPLIANCE_DELETE_EMPTY_TENANT_SUMMARY_SQL,
@@ -106,9 +107,19 @@ def delete_provider(tenant_id: str, pk: str):
try:
if attack_paths_sink_backends:
for sink_backend in attack_paths_sink_backends:
sink_module.get_backend_for_name(sink_backend).drop_subgraph(
tenant_database_name, str(pk)
)
try:
backend = sink_module.get_backend_for_name(sink_backend)
except RuntimeError as sink_error:
# A retired sink has no connection settings left, and no graph left to drop
if sink_backend == settings.ATTACK_PATHS_SINK_DATABASE.lower():
raise
logger.warning(
f"Skipping graph cleanup on unconfigured sink {sink_backend}: {sink_error}"
)
continue
backend.drop_subgraph(tenant_database_name, str(pk))
else:
graph_database.drop_subgraph(tenant_database_name, str(pk))
+4 -2
View File
@@ -2113,7 +2113,9 @@ def aggregate_attack_surface(tenant_id: str, scan_id: str):
.annotate(
total=Count("id"),
failed=Count("id", filter=Q(status="FAIL", muted=False)),
muted=Count("id", filter=Q(status="FAIL", muted=True)),
# Not `muted`: an annotation named after a model field comes
# back as `muted_new` from the psqlextra queryset.
muted_count=Count("id", filter=Q(status="FAIL", muted=True)),
)
)
@@ -2124,7 +2126,7 @@ def aggregate_attack_surface(tenant_id: str, scan_id: str):
aggregated_counts[attack_surface_type]["total"] += stats["total"] or 0
aggregated_counts[attack_surface_type]["failed"] += stats["failed"] or 0
aggregated_counts[attack_surface_type]["muted"] += stats["muted"] or 0
aggregated_counts[attack_surface_type]["muted"] += stats["muted_count"] or 0
overview_objects = []
for attack_surface_type, counts in aggregated_counts.items():
+56 -1
View File
@@ -4,6 +4,7 @@ import pytest
from api.attack_paths import database as graph_database
from api.models import Provider, Tenant, TenantComplianceSummary
from django.core.exceptions import ObjectDoesNotExist
from django.test import override_settings
from tasks.jobs.deletion import delete_provider, delete_tenant
@@ -123,6 +124,60 @@ class TestDeleteProvider:
"tenant-db", str(instance.id)
)
@override_settings(ATTACK_PATHS_SINK_DATABASE="neo4j")
def test_delete_provider_skips_unconfigured_retired_sink(
self, aws_provider, create_attack_paths_scan
):
instance = aws_provider
tenant_id = str(instance.tenant_id)
create_attack_paths_scan(instance, sink_backend="neo4j")
create_attack_paths_scan(instance, sink_backend="neptune")
neo4j_backend = MagicMock()
def get_backend_for_name(name):
if name == "neptune":
raise RuntimeError("NEPTUNE_WRITER_ENDPOINT and AWS_REGION must be set")
return neo4j_backend
with (
patch(
"tasks.jobs.deletion.graph_database.get_database_name",
return_value="tenant-db",
),
patch(
"tasks.jobs.deletion.sink_module.get_backend_for_name",
side_effect=get_backend_for_name,
),
patch("tasks.jobs.deletion.graph_database.drop_database"),
):
result = delete_provider(tenant_id, instance.id)
assert result
assert not Provider.all_objects.filter(pk=instance.id).exists()
neo4j_backend.drop_subgraph.assert_called_once_with(
"tenant-db", str(instance.id)
)
@override_settings(ATTACK_PATHS_SINK_DATABASE="neo4j")
def test_delete_provider_raises_when_active_sink_unconfigured(
self, aws_provider, create_attack_paths_scan
):
instance = aws_provider
tenant_id = str(instance.tenant_id)
create_attack_paths_scan(instance, sink_backend="neo4j")
with (
patch(
"tasks.jobs.deletion.sink_module.get_backend_for_name",
side_effect=RuntimeError("NEO4J_HOST / NEO4J_PORT must be set"),
),
patch("tasks.jobs.deletion.graph_database.drop_database"),
pytest.raises(RuntimeError),
):
delete_provider(tenant_id, instance.id)
assert Provider.all_objects.filter(pk=instance.id).exists()
def test_delete_provider_continues_when_temp_db_drop_fails(
self, aws_provider, create_attack_paths_scan
):
@@ -149,10 +204,10 @@ class TestDeleteProvider:
assert result
assert not Provider.all_objects.filter(pk=instance.id).exists()
@pytest.mark.usefixtures("provider_compliance_scores_fixture")
def test_delete_provider_recalculates_tenant_compliance_summary(
self,
aws_provider_pair,
provider_compliance_scores_fixture,
):
instance = aws_provider_pair[0]
tenant_id = instance.tenant_id
+72 -5
View File
@@ -12,6 +12,7 @@ from api.db_router import MainRouter
from api.db_utils import rls_transaction
from api.exceptions import ProviderConnectionError, ProviderDeletedException
from api.models import (
AttackSurfaceOverview,
Finding,
MuteRule,
Provider,
@@ -5327,8 +5328,13 @@ class TestAggregateAttackSurface:
mock_queryset = MagicMock()
mock_queryset.values.return_value = mock_queryset
mock_queryset.annotate.return_value = [
{"check_id": "check_internet_1", "total": 10, "failed": 3, "muted": 1},
{"check_id": "check_secrets_1", "total": 5, "failed": 2, "muted": 0},
{
"check_id": "check_internet_1",
"total": 10,
"failed": 3,
"muted_count": 1,
},
{"check_id": "check_secrets_1", "total": 5, "failed": 2, "muted_count": 0},
]
ctx = MagicMock()
@@ -5377,7 +5383,7 @@ class TestAggregateAttackSurface:
mock_queryset = MagicMock()
mock_queryset.values.return_value = mock_queryset
mock_queryset.annotate.return_value = [
{"check_id": "check_internet_1", "total": 5, "failed": 1, "muted": 0},
{"check_id": "check_internet_1", "total": 5, "failed": 1, "muted_count": 0},
]
ctx = MagicMock()
@@ -5460,8 +5466,13 @@ class TestAggregateAttackSurface:
mock_queryset = MagicMock()
mock_queryset.values.return_value = mock_queryset
mock_queryset.annotate.return_value = [
{"check_id": "check_internet_1", "total": 10, "failed": 3, "muted": 1},
{"check_id": "check_internet_2", "total": 5, "failed": 2, "muted": 0},
{
"check_id": "check_internet_1",
"total": 10,
"failed": 3,
"muted_count": 1,
},
{"check_id": "check_internet_2", "total": 5, "failed": 2, "muted_count": 0},
]
ctx = MagicMock()
@@ -5482,6 +5493,62 @@ class TestAggregateAttackSurface:
assert overview.failed_findings == 5 # 3 + 2
assert overview.muted_failed_findings == 1 # 1 + 0
@patch("tasks.jobs.scan._get_attack_surface_mapping_from_provider")
def test_aggregate_attack_surface_counts_real_findings(
self, mock_get_mapping, tenants_fixture, scans_fixture
):
"""Run the aggregation query against real Finding rows.
The other tests mock the queryset, so they never execute the real
`annotate`. This one guards the row keys the query returns."""
tenant = tenants_fixture[0]
scan = scans_fixture[0]
mock_get_mapping.return_value = {
"privilege-escalation": {"check_privesc_1"},
"secrets": {"check_secrets_1"},
}
def create_finding(uid, check_id, status, muted):
Finding.objects.create(
tenant_id=tenant.id,
uid=uid,
scan=scan,
status=status,
status_extended="status extended",
impact=Severity.high,
severity=Severity.high,
raw_result={"status": status},
check_id=check_id,
check_metadata={"CheckId": check_id},
muted=muted,
first_seen_at="2024-01-02T00:00:00Z",
)
create_finding("privesc_fail", "check_privesc_1", Status.FAIL, False)
create_finding("privesc_fail_2", "check_privesc_1", Status.FAIL, False)
create_finding("privesc_fail_muted", "check_privesc_1", Status.FAIL, True)
create_finding("privesc_pass", "check_privesc_1", Status.PASS, False)
create_finding("secrets_pass_muted", "check_secrets_1", Status.PASS, True)
create_finding("unmapped_fail", "check_unmapped", Status.FAIL, False)
aggregate_attack_surface(str(tenant.id), str(scan.id))
overviews = {
overview.attack_surface_type: overview
for overview in AttackSurfaceOverview.objects.filter(
tenant_id=tenant.id, scan_id=scan.id
)
}
assert set(overviews) == {"privilege-escalation", "secrets"}
assert overviews["privilege-escalation"].total_findings == 4
assert overviews["privilege-escalation"].failed_findings == 2
assert overviews["privilege-escalation"].muted_failed_findings == 1
assert overviews["secrets"].total_findings == 1
assert overviews["secrets"].failed_findings == 0
assert overviews["secrets"].muted_failed_findings == 0
@patch("tasks.jobs.scan.Scan.all_objects.select_related")
@patch("tasks.jobs.scan.rls_transaction")
def test_aggregate_attack_surface_uses_select_related(
Generated
+15 -3
View File
@@ -3501,6 +3501,17 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/7d/721f162c46e3de604a73674223bf6c6bc6cf7ade25b3751a71288f4dd122/huaweicloudsdkrds-3.1.204-py3-none-any.whl", hash = "sha256:a790b5b3c457a608e5679c101f463b4d037dd9a8a66f6e46144a9e5a4b37780f", size = 1626906, upload-time = "2026-07-09T09:04:06.936Z" },
]
[[package]]
name = "huaweicloudsdksmn"
version = "3.1.204"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huaweicloudsdkcore" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/5d/c0de47d011f1932c9c0ddf669c238d9fad01653d438d38203703526799d7/huaweicloudsdksmn-3.1.204-py3-none-any.whl", hash = "sha256:b0818ea9293e27458c8fa1d2da021c98006cbf9ce01cf17a0f1df598c4da3a6c", size = 323674, upload-time = "2026-07-09T09:04:24.804Z" },
]
[[package]]
name = "huaweicloudsdkvpc"
version = "3.1.204"
@@ -4835,8 +4846,8 @@ wheels = [
[[package]]
name = "prowler"
version = "5.41.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#f05a490cd74a2c0f11a5d66d8ce29d03fa5c64a2" }
version = "5.44.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.44#60b936005cc109c611ad8b7f7c41cadb586fb4b6" }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },
{ name = "alibabacloud-credentials" },
@@ -4900,6 +4911,7 @@ dependencies = [
{ name = "huaweicloudsdkkms" },
{ name = "huaweicloudsdkobs" },
{ name = "huaweicloudsdkrds" },
{ name = "huaweicloudsdksmn" },
{ name = "huaweicloudsdkvpc" },
{ name = "huaweicloudsdkwaf" },
{ name = "jsonschema" },
@@ -5038,7 +5050,7 @@ requires-dist = [
{ name = "matplotlib", specifier = "==3.10.8" },
{ name = "neo4j", specifier = "==6.1.0" },
{ name = "openai", specifier = "==1.109.1" },
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=master" },
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=v5.44" },
{ name = "psycopg2-binary", specifier = "==2.9.9" },
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
{ name = "reportlab", specifier = "==4.4.10" },
@@ -47,6 +47,8 @@ The former build-time variables map to the new runtime variables as follows:
Roll out Registry only after the Registry backend dependency is deployed, intended roles have `manage_registry`, and acceptance with real credentials has exercised installation, provider account creation, credentials, connection, and scan launch. Deploy the UI with `UI_REGISTRY_ENABLED` unset or `"false"`; set it to `"true"` only in the prepared process environment, then restart or otherwise apply the environment update required by the platform. A Registry key must belong to the configured Registry environment; a production key does not authenticate against a development Registry.
The Registry links read `PROWLER_REGISTRY_INDEX_URL`, the same base URL the backend installs artifacts from. The Registry page and the credential banner link to it, and its origin is allowed for images. `UI_REGISTRY_MEDIA_URL` adds the Registry media service origin so artifact logos load. When `PROWLER_REGISTRY_INDEX_URL` is unset or invalid, the links are hidden instead of pointing to the public Prowler Registry, which keeps private and air-gapped deployments from sending users to an unreachable host. `UI_REGISTRY_URL` is no longer read.
The catalog displays all artifacts, including built-ins and packages containing only checks or compliance frameworks. Only external provider artifacts support Add. After confirmed installation, open Providers and select the option labeled Registry to configure an account. Creating accounts and running scans also require the corresponding provider and scan permissions. Removing an artifact keeps existing provider accounts, but future connections or scans can fail until the artifact is installed again.
To roll back, set `UI_REGISTRY_ENABLED=false` or remove it and apply the environment update. Proxy, page, and action checks deny on their next request. Navigation refreshes from server-authorized access when the page is requested again. Rollback does not delete Registry credentials, tenant artifact records, or provider accounts.
@@ -148,6 +148,12 @@ New roles have no provider visibility by default. Assign at least one Provider G
Integrations follow the visibility of the providers attached to them: a role can see an integration when it can access at least one of its providers, and only the providers visible to that role are listed on the integration. Editing or deleting an integration attached to providers outside the visibility of the role is not allowed. Integrations that are not attached to any provider, such as Jira, are tenant-wide and remain available to every role with the **Manage Integrations** permission.
#### Task Visibility and Revocation
<VersionBadge version="5.44.0" />
Background tasks, such as provider deletions, connection checks and scans, follow the visibility of the provider they belong to: a role can see a task when it can access its provider. Tasks that carry no provider reference are treated as tenant-wide and are visible to every role. Revoking a pending task requires the same permission as the operation that queued it, for example **Manage Scans** for a scan. Provider deletions cannot be revoked.
#### Creating a Provider Group
Follow these steps to create a provider group in your account:
@@ -14,6 +14,7 @@ import {
includesMutedFindings,
splitCsvFilterValues,
} from "@/lib";
import { getFindingGroupFilterOptions } from "@/lib/finding-group-filter-options";
import { appendSanitizedProviderFilters } from "@/lib/provider-filters";
import { handleApiResponse } from "@/lib/server-actions-helper";
@@ -151,6 +152,25 @@ export const getLatestFindingGroups = async (
params: FetchFindingGroupsParams = {},
) => fetchFindingGroupsEndpoint("finding-groups/latest", params);
/**
* Options for the "Finding Group" filter. Walks every finding-group page on the
* server, so the browser issues a single request instead of one per page
* (client-side Server Action calls are dispatched sequentially).
*/
export const getFindingGroupCheckOptions = async ({
filters,
hasHistoricalData,
}: {
filters: Record<string, string>;
hasHistoricalData: boolean;
}) =>
getFindingGroupFilterOptions({
fetchFindingGroups: hasHistoricalData
? getFindingGroups
: getLatestFindingGroups,
filters,
});
interface FetchFindingGroupResourcesParams {
checkId: string;
page?: number;
@@ -1,6 +1,6 @@
"use client";
import { Icon } from "@iconify/react";
import { Mail, TriangleAlert } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
@@ -12,6 +12,7 @@ import {
} from "@/app/(auth)/invitation/_lib/invitation-errors";
import { AuthBrand } from "@/components/auth/oss/auth-brand";
import { Button } from "@/components/shadcn";
import { Spinner } from "@/components/shadcn/spinner/spinner";
type AcceptState =
| { kind: "no-token" }
@@ -74,10 +75,9 @@ export function AcceptInvitationClient({
{/* No token */}
{state.kind === "no-token" && (
<div className="flex flex-col items-center gap-4">
<Icon
icon="solar:danger-triangle-bold"
className="text-text-warning-primary"
width={48}
<TriangleAlert
aria-hidden="true"
className="text-text-warning-primary size-12"
/>
<h1 className="text-xl font-semibold">Invalid Invitation Link</h1>
<p className="text-text-neutral-tertiary">
@@ -93,11 +93,7 @@ export function AcceptInvitationClient({
{/* Accepting */}
{state.kind === "accepting" && (
<div className="flex flex-col items-center gap-4">
<Icon
icon="eos-icons:loading"
className="text-text-neutral-tertiary"
width={48}
/>
<Spinner className="size-12" />
<h1 className="text-xl font-semibold">Accepting Invitation...</h1>
<p className="text-text-neutral-tertiary">
Please wait while we process your invitation.
@@ -108,10 +104,9 @@ export function AcceptInvitationClient({
{/* Error */}
{state.kind === "error" && (
<div className="flex flex-col items-center gap-4">
<Icon
icon="solar:danger-triangle-bold"
className="text-text-error-primary"
width={48}
<TriangleAlert
aria-hidden="true"
className="text-text-error-primary size-12"
/>
<h1 className="text-xl font-semibold">
Could Not Accept Invitation
@@ -129,11 +124,7 @@ export function AcceptInvitationClient({
{/* Choice page for unauthenticated users */}
{state.kind === "choose" && (
<div className="flex flex-col items-center gap-6">
<Icon
icon="solar:letter-bold"
className="text-button-primary"
width={48}
/>
<Mail aria-hidden="true" className="text-button-primary size-12" />
<div>
<h1 className="text-xl font-semibold">
You&apos;ve Been Invited
@@ -48,6 +48,11 @@ vi.mock(
vi.mock("@/app/(prowler)/alerts/_actions", () => alertsActionMocks);
// The findings filters lazily load check options through this Server Action.
vi.mock("@/actions/finding-groups", () => ({
getFindingGroupCheckOptions: vi.fn().mockResolvedValue([]),
}));
vi.mock(
"@/components/compliance/compliance-header/compliance-scan-info",
() => ({
+2 -1
View File
@@ -1,3 +1,4 @@
import { BellRing } from "lucide-react";
import { redirect } from "next/navigation";
import { getLatestMetadataInfo } from "@/actions/findings";
@@ -101,7 +102,7 @@ export default async function AlertsPage({ searchParams }: AlertsPageProps) {
: undefined;
return (
<ContentLayout title="Alerts" icon="lucide:bell-ring">
<ContentLayout title="Alerts" icon={<BellRing />}>
{!hasError ? (
<AlertsLighthouseContext
totalCount={apiMeta?.pagination?.count ?? alerts.length}
+3 -1
View File
@@ -1,3 +1,5 @@
import { GitBranch } from "lucide-react";
import { ContentLayout } from "@/components/shadcn/content-layout";
export default function AttackPathsLayout({
@@ -8,7 +10,7 @@ export default function AttackPathsLayout({
return (
<ContentLayout
title="Attack Paths"
icon="lucide:git-branch"
icon={<GitBranch />}
onboardingAction={{ flowId: "attack-paths" }}
>
{children}
+4 -4
View File
@@ -1,4 +1,4 @@
import { Info } from "lucide-react";
import { Info, ShieldCheck } from "lucide-react";
import { Suspense } from "react";
import {
@@ -105,7 +105,7 @@ export default async function Compliance({
return (
<ContentLayout
title="Compliance"
icon="lucide:shield-check"
icon={<ShieldCheck />}
onboardingAction={
hasCompletedScan
? { flowId: "view-compliance" }
@@ -172,7 +172,7 @@ export default async function Compliance({
return (
<ContentLayout
title="Compliance"
icon="lucide:shield-check"
icon={<ShieldCheck />}
onboardingAction={{
flowId: "view-compliance",
fallbackFlowId: "view-first-scan",
@@ -323,7 +323,7 @@ export default async function Compliance({
return (
<ContentLayout
title="Compliance"
icon="lucide:shield-check"
icon={<ShieldCheck />}
onboardingAction={onboardingAction}
>
<CompliancePageTabs
+13 -6
View File
@@ -1,7 +1,7 @@
"use client";
import { Icon } from "@iconify/react";
import * as Sentry from "@sentry/nextjs";
import { RefreshCw, ServerOff, TriangleAlert } from "lucide-react";
import { useEffect } from "react";
import { Button } from "@/components/shadcn";
@@ -84,10 +84,17 @@ export default function Error({
<Card variant="base" className="w-full max-w-lg">
<CardHeader>
<div className="flex items-start gap-3">
<Icon
icon={is500Error ? "tabler:server-off" : "tabler:rocket-off"}
className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-500"
/>
{is500Error ? (
<ServerOff
aria-hidden="true"
className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-500"
/>
) : (
<TriangleAlert
aria-hidden="true"
className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-500"
/>
)}
<div className="flex flex-col gap-2">
<CardTitle className="text-lg">
{is500Error
@@ -105,7 +112,7 @@ export default function Error({
<CardContent>
<div className="flex items-center justify-start gap-3">
<Button onClick={reset} size="sm" className="gap-2">
<Icon icon="tabler:refresh" className="h-4 w-4" />
<RefreshCw aria-hidden="true" className="h-4 w-4" />
Try Again
</Button>
<CustomLink href="/" target="_self" className="font-bold">
@@ -0,0 +1,103 @@
import {
getFindingGroups,
getLatestFindingGroups,
} from "@/actions/finding-groups";
import { getLatestMetadataInfo, getMetadataInfo } from "@/actions/findings";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
import { getAllProviders } from "@/actions/providers";
import { SeedFromFindingsButton } from "@/app/(prowler)/alerts/_components";
import { FindingsFilters } from "@/components/findings/findings-filters";
import { createScanDetailsMapping, splitCsvFilterValues } from "@/lib";
import { getSelectedFindingCheckOptions } from "@/lib/finding-group-filter-options";
import { isCloud } from "@/lib/shared/env";
import { ScanEntity, ScanProps } from "@/types";
interface FindingsFiltersSectionProps {
filters: Record<string, string>;
resolvedFilters: Record<string, string>;
hasHistoricalData: boolean;
query: string;
encodedSort?: string;
completedScans: ScanProps[];
}
/**
* Streams behind its own Suspense boundary: everything the filter controls
* need is fetched here, in parallel, so the table never waits for it.
*/
export async function FindingsFiltersSection({
filters,
resolvedFilters,
hasHistoricalData,
query,
encodedSort,
completedScans,
}: FindingsFiltersSectionProps) {
const selectedCheckIds = [
...splitCsvFilterValues(resolvedFilters["filter[check_id]"]),
...splitCsvFilterValues(resolvedFilters["filter[check_id__in]"]),
];
const [providersData, providerGroupsData, metadataInfoData, selectedChecks] =
await Promise.all([
getAllProviders(),
getAllProviderGroups(),
(hasHistoricalData ? getMetadataInfo : getLatestMetadataInfo)({
query,
sort: encodedSort,
filters: resolvedFilters,
}),
getSelectedFindingCheckOptions({
fetchFindingGroups: hasHistoricalData
? getFindingGroups
: getLatestFindingGroups,
filters: resolvedFilters,
selectedCheckIds,
}),
]);
const attributes = metadataInfoData?.data?.attributes;
const uniqueRegions = attributes?.regions || [];
const uniqueServices = attributes?.services || [];
const uniqueResourceTypes = attributes?.resource_types || [];
const uniqueCategories = attributes?.categories || [];
const uniqueGroups = attributes?.groups || [];
const providers = providersData?.data || [];
const scanDetails = createScanDetailsMapping(
completedScans,
providersData,
) as { [uid: string]: ScanEntity }[];
return (
<FindingsFilters
providers={providers}
providerGroups={providerGroupsData?.data || []}
completedScanIds={completedScans.map((scan) => scan.id)}
scanDetails={scanDetails}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
checkOptionsSource={{
filters: resolvedFilters,
hasHistoricalData,
initialOptions: selectedChecks,
}}
trailingControls={
<SeedFromFindingsButton
filterBag={filters}
providers={providers}
scans={scanDetails}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
isCloudEnabled={isCloud()}
/>
}
/>
);
}
@@ -0,0 +1,17 @@
import { FILTER_CONTROL_COLUMN_CLASS } from "@/components/findings/findings-filters.utils";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
const FILTER_CONTROL_PLACEHOLDERS = 5;
export const FindingsFiltersSkeleton = () => {
return (
<div className="flex flex-wrap items-center gap-3">
{Array.from({ length: FILTER_CONTROL_PLACEHOLDERS }, (_, index) => (
<Skeleton
key={index}
className={`h-[52px] rounded-lg ${FILTER_CONTROL_COLUMN_CLASS}`}
/>
))}
</div>
);
};
+21
View File
@@ -0,0 +1,21 @@
import { Tag } from "lucide-react";
import { SkeletonTableFindings } from "@/components/findings/table";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { FindingsFiltersSkeleton } from "./_components/findings-filters-skeleton";
export default function FindingsLoading() {
return (
<ContentLayout
title="Findings"
icon={<Tag />}
onboardingAction={{ flowId: "explore-findings" }}
>
<div className="mb-6">
<FindingsFiltersSkeleton />
</div>
<SkeletonTableFindings />
</ContentLayout>
);
}
+41 -83
View File
@@ -1,3 +1,4 @@
import { Tag } from "lucide-react";
import { Suspense } from "react";
import {
@@ -5,12 +6,7 @@ import {
getFindingGroups,
getLatestFindingGroups,
} from "@/actions/finding-groups";
import { getLatestMetadataInfo, getMetadataInfo } from "@/actions/findings";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
import { getAllProviders } from "@/actions/providers";
import { getScan, getScans } from "@/actions/scans";
import { SeedFromFindingsButton } from "@/app/(prowler)/alerts/_components";
import { FindingsFilters } from "@/components/findings/findings-filters";
import {
FindingsGroupTable,
SkeletonTableFindings,
@@ -19,17 +15,17 @@ import { ContentLayout } from "@/components/shadcn/content-layout";
import { FilterTransitionWrapper } from "@/contexts";
import {
applyDefaultMutedFilter,
createScanDetailsMapping,
extractFiltersAndQuery,
extractSortAndKey,
hasDateOrScanFilter,
} from "@/lib";
import { getFindingGroupFilterOptions } from "@/lib/finding-group-filter-options";
import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters";
import { isCloud } from "@/lib/shared/env";
import { ScanEntity, ScanProps } from "@/types";
import { ScanProps } from "@/types";
import { SearchParamsProps } from "@/types/components";
import { FindingsFiltersSection } from "./_components/findings-filters-section";
import { FindingsFiltersSkeleton } from "./_components/findings-filters-skeleton";
export default async function Findings({
searchParams,
}: {
@@ -39,54 +35,37 @@ export default async function Findings({
const { encodedSort } = extractSortAndKey(resolvedSearchParams);
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
const [providersData, providerGroupsData, scansData] = await Promise.all([
getAllProviders(),
getAllProviderGroups(),
getScans({ pageSize: 50 }),
// The page shell awaits only what both the filters and the table depend on:
// the completed scans (onboarding + scan filter) and the scan date range.
const [scansData, filtersWithScanDates] = await Promise.all([
getScans({
pageSize: 50,
filters: { "filter[state]": "completed" },
fields: {
scans: "name,state,unique_resource_count,completed_at,provider",
},
}),
resolveFindingScanDateFilters({
filters,
scans: [],
loadScan: async (scanId: string) => {
const response = await getScan(scanId);
return response?.data;
},
}),
]);
const filtersWithScanDates = await resolveFindingScanDateFilters({
filters,
scans: scansData?.data || [],
loadScan: async (scanId: string) => {
const response = await getScan(scanId);
return response?.data;
},
});
const resolvedFilters = applyDefaultMutedFilter(filtersWithScanDates);
const hasHistoricalData = hasDateOrScanFilter(filtersWithScanDates);
const metadataInfoData = await (
hasHistoricalData ? getMetadataInfo : getLatestMetadataInfo
)({
query,
sort: encodedSort,
filters: resolvedFilters,
});
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
const uniqueServices = metadataInfoData?.data?.attributes?.services || [];
const uniqueResourceTypes =
metadataInfoData?.data?.attributes?.resource_types || [];
const uniqueCategories = metadataInfoData?.data?.attributes?.categories || [];
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
const fetchFindingGroupFilterOptions = hasHistoricalData
? getFindingGroups
: getLatestFindingGroups;
const checkOptions = await getFindingGroupFilterOptions({
fetchFindingGroups: fetchFindingGroupFilterOptions,
filters: resolvedFilters,
});
const completedScans: ScanProps[] =
scansData?.data?.filter(
(scan: ScanProps) =>
scan.attributes.state === "completed" &&
scan.attributes.unique_resource_count > 1,
) || [];
const completedScans = scansData?.data?.filter(
(scan: ScanProps) =>
scan.attributes.state === "completed" &&
scan.attributes.unique_resource_count > 1,
);
const completedScanIds =
completedScans?.map((scan: ScanProps) => scan.id) || [];
const onboardingAction =
completedScanIds.length > 0
completedScans.length > 0
? { flowId: "explore-findings" }
: {
flowId: "explore-findings",
@@ -94,45 +73,24 @@ export default async function Findings({
useFallback: true,
};
const scanDetails = createScanDetailsMapping(
completedScans || [],
providersData,
) as { [uid: string]: ScanEntity }[];
const alertsEnabled = isCloud();
return (
<ContentLayout
title="Findings"
icon="lucide:tag"
icon={<Tag />}
onboardingAction={onboardingAction}
>
<FilterTransitionWrapper>
<div className="mb-6">
<FindingsFilters
providers={providersData?.data || []}
providerGroups={providerGroupsData?.data || []}
completedScanIds={completedScanIds}
scanDetails={scanDetails}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
checkOptions={checkOptions}
trailingControls={
<SeedFromFindingsButton
filterBag={filters}
providers={providersData?.data || []}
scans={scanDetails}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
isCloudEnabled={alertsEnabled}
/>
}
/>
<Suspense fallback={<FindingsFiltersSkeleton />}>
<FindingsFiltersSection
filters={filters}
resolvedFilters={resolvedFilters}
hasHistoricalData={hasHistoricalData}
query={query}
encodedSort={encodedSort}
completedScans={completedScans}
/>
</Suspense>
</div>
<Suspense fallback={<SkeletonTableFindings />}>
<SSRDataTable
+3 -1
View File
@@ -1,10 +1,12 @@
import { Puzzle } from "lucide-react";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { IntegrationsContent } from "./integrations-content";
export default async function Integrations() {
return (
<ContentLayout title="Integrations" icon="lucide:puzzle">
<ContentLayout title="Integrations" icon={<Puzzle />}>
<IntegrationsContent />
</ContentLayout>
);
@@ -1,5 +1,6 @@
import "@/styles/globals.css";
import { X } from "lucide-react";
import React from "react";
import { WorkflowSendInvite } from "@/components/invitations/workflow";
@@ -14,7 +15,7 @@ export default function InvitationLayout({ children }: InvitationLayoutProps) {
<>
<NavigationHeader
title="Send Invitation"
icon="icon-park-outline:close-small"
icon={<X />}
href="/invitations"
/>
<div className="h-16" />
+2 -1
View File
@@ -1,3 +1,4 @@
import { Mail } from "lucide-react";
import Link from "next/link";
import { Suspense } from "react";
@@ -22,7 +23,7 @@ export default async function Invitations({
const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
return (
<ContentLayout title="Invitations" icon="lucide:mail">
<ContentLayout title="Invitations" icon={<Mail />}>
<div className="flex flex-col gap-6">
<div className="flex flex-row items-end justify-between">
<DataTableFilterCustom
@@ -1,15 +1,15 @@
import { Icon } from "@iconify/react";
import {
LIGHTHOUSE_V2_PROVIDER_TYPE,
type LighthouseV2ProviderType,
} from "@/app/(prowler)/lighthouse/_types";
import { AmazonWebServicesIcon, OpenAIIcon } from "@/components/icons/Icons";
import type { IconComponent } from "@/types/components";
const LIGHTHOUSE_V2_PROVIDER_ICONS = {
[LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI]: "simple-icons:openai",
[LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK]: "simple-icons:amazonwebservices",
[LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE]: "simple-icons:openai",
} as const satisfies Record<LighthouseV2ProviderType, string>;
[LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI]: OpenAIIcon,
[LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK]: AmazonWebServicesIcon,
[LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE]: OpenAIIcon,
} as const satisfies Record<LighthouseV2ProviderType, IconComponent>;
export function ProviderIcon({
provider,
@@ -18,11 +18,6 @@ export function ProviderIcon({
provider: LighthouseV2ProviderType;
className?: string;
}) {
return (
<Icon
aria-hidden="true"
className={className}
icon={LIGHTHOUSE_V2_PROVIDER_ICONS[provider]}
/>
);
const Icon = LIGHTHOUSE_V2_PROVIDER_ICONS[provider];
return <Icon aria-hidden="true" className={className} />;
}
+2 -2
View File
@@ -56,7 +56,7 @@ export default async function AIChatbot({
const chatRouteKey = validSessionId ?? initialPrompt ?? "new";
return (
<ContentLayout title="Lighthouse AI" icon={<LighthouseIcon />}>
<ContentLayout title="Lighthouse AI" icon={<LighthouseIcon size={32} />}>
<AppSidebarModeSync mode={APP_SIDEBAR_MODE.CHAT} closeSidePanel />
{/* [contain:layout] traps streamdown's fixed fullscreen overlay inside
the chat area so it never covers the sidebar or navbar. */}
@@ -91,7 +91,7 @@ export default async function AIChatbot({
}
return (
<ContentLayout title="Lighthouse AI" icon={<LighthouseIcon />}>
<ContentLayout title="Lighthouse AI" icon={<LighthouseIcon size={32} />}>
<div className="-mx-6 -my-4 h-[calc(100dvh-4.5rem)] sm:-mx-8">
<Chat
hasConfig={hasConfig}
@@ -2,7 +2,7 @@
import "@/styles/globals.css";
import { Icon } from "@iconify/react";
import { Star, Trash2, X } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useEffect, useState } from "react";
@@ -76,7 +76,7 @@ export default function ConnectLLMLayout({ children }: ConnectLLMLayoutProps) {
<NavigationHeader
title={isEditMode ? "Configure LLM Provider" : "Connect LLM Provider"}
icon="icon-park-outline:close-small"
icon={<X />}
href={LIGHTHOUSE_ROUTE.SETTINGS}
/>
<div className="h-8" />
@@ -96,7 +96,7 @@ export default function ConnectLLMLayout({ children }: ConnectLLMLayoutProps) {
onClick={handleSetDefault}
className="w-full sm:w-auto"
>
<Icon icon="heroicons:star" className="h-4 w-4" />
<Star aria-hidden="true" className="h-4 w-4" />
Set as Default
</Button>
)}
@@ -108,7 +108,7 @@ export default function ConnectLLMLayout({ children }: ConnectLLMLayoutProps) {
onClick={() => setIsDeleteOpen(true)}
className="w-full sm:w-auto"
>
<Icon icon="heroicons:trash" className="h-4 w-4" />
<Trash2 aria-hidden="true" className="h-4 w-4" />
Delete Provider
</Button>
</div>
+2 -1
View File
@@ -1,5 +1,6 @@
import "@/styles/globals.css";
import { Group } from "lucide-react";
import React from "react";
import { ContentLayout } from "@/components/shadcn/content-layout";
@@ -10,7 +11,7 @@ interface ProviderLayoutProps {
export default function ProviderLayout({ children }: ProviderLayoutProps) {
return (
<ContentLayout title="Manage Groups" icon="lucide:group">
<ContentLayout title="Manage Groups" icon={<Group />}>
{children}
</ContentLayout>
);
+2 -1
View File
@@ -1,3 +1,4 @@
import { VolumeX } from "lucide-react";
import { Suspense } from "react";
import { ContentLayout } from "@/components/shadcn/content-layout";
@@ -15,7 +16,7 @@ export default async function MutelistPage({
const searchParamsKey = JSON.stringify(resolvedSearchParams);
return (
<ContentLayout title="Mutelist" icon="lucide:volume-x">
<ContentLayout title="Mutelist" icon={<VolumeX />}>
<MutelistTabs
simpleContent={
<Suspense key={searchParamsKey} fallback={<MuteRulesTableSkeleton />}>
+2 -1
View File
@@ -1,3 +1,4 @@
import { SquareChartGantt } from "lucide-react";
import { Suspense } from "react";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
@@ -59,7 +60,7 @@ export default async function Home({
]);
return (
<ContentLayout title="Overview" icon="lucide:square-chart-gantt">
<ContentLayout title="Overview" icon={<SquareChartGantt />}>
<AppSidebarModeSync mode={APP_SIDEBAR_MODE.BROWSE} />
<OverviewProviderContext
searchParams={resolvedSearchParams}
+2 -1
View File
@@ -1,3 +1,4 @@
import { Users } from "lucide-react";
import { Suspense } from "react";
import { getSamlConfig } from "@/actions/integrations/saml";
@@ -26,7 +27,7 @@ export default async function Profile({
const resolvedSearchParams = await searchParams;
return (
<ContentLayout title="User Profile" icon="lucide:users">
<ContentLayout title="User Profile" icon={<Users />}>
<Suspense fallback={<SkeletonUserInfo />}>
<SSRDataUser searchParams={resolvedSearchParams} />
</Suspense>
+2 -1
View File
@@ -1,3 +1,4 @@
import { CloudCog } from "lucide-react";
import { Suspense } from "react";
import { SkeletonTableProviders } from "@/components/providers/table";
@@ -35,7 +36,7 @@ export default async function Providers({
return (
<ContentLayout
title="Providers"
icon="lucide:cloud-cog"
icon={<CloudCog />}
onboardingAction={{ flowId: "add-provider" }}
>
{isCloudEnvironment && <CliImportBanner className="mb-6" />}
+4 -6
View File
@@ -1,10 +1,10 @@
import { Package } from "lucide-react";
import { redirect } from "next/navigation";
import { getRegistryBootstrap } from "@/actions/registry/registry";
import { RegistryExplorer } from "@/components/registry/registry-explorer";
import { ContentLayout } from "@/components/shadcn/content-layout/content-layout";
import { getRegistryPresentation } from "@/lib/registry/presentation";
import { readEnv } from "@/lib/runtime-env";
import { readRegistryPresentation } from "@/lib/registry/presentation";
import { REGISTRY_FAILURE } from "@/types/registry";
export const dynamic = "force-dynamic";
@@ -14,12 +14,10 @@ export default async function RegistryPage() {
if (bootstrap.status === REGISTRY_FAILURE.ACCESS_DENIED) redirect("/profile");
return (
<ContentLayout title="Registry" icon="lucide:package">
<ContentLayout title="Registry" icon={<Package />}>
<RegistryExplorer
initialState={bootstrap.state}
registryKeyUrl={
getRegistryPresentation(readEnv("UI_REGISTRY_URL")).keyUrl
}
registryUrl={readRegistryPresentation().registryUrl}
/>
</ContentLayout>
);
+2 -1
View File
@@ -1,3 +1,4 @@
import { Warehouse } from "lucide-react";
import { Suspense } from "react";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
@@ -80,7 +81,7 @@ export default async function Resources({
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
return (
<ContentLayout title="Resources" icon="lucide:warehouse">
<ContentLayout title="Resources" icon={<Warehouse />}>
<FilterTransitionWrapper>
<div className="mb-6">
<ResourcesFilters
+2 -5
View File
@@ -1,5 +1,6 @@
import "@/styles/globals.css";
import { X } from "lucide-react";
import React from "react";
import { WorkflowAddEditRole } from "@/components/roles/workflow";
@@ -12,11 +13,7 @@ interface RoleLayoutProps {
export default function RoleLayout({ children }: RoleLayoutProps) {
return (
<>
<NavigationHeader
title="Role Management"
icon="icon-park-outline:close-small"
href="/roles"
/>
<NavigationHeader title="Role Management" icon={<X />} href="/roles" />
<div className="h-16" />
<div className="grid grid-cols-1 gap-8 px-4 sm:px-6 lg:grid-cols-12 lg:px-0">
<div className="order-1 my-auto hidden h-full lg:col-span-4 lg:col-start-2 lg:block">
+2 -1
View File
@@ -1,3 +1,4 @@
import { UserCog } from "lucide-react";
import Link from "next/link";
import { Suspense } from "react";
@@ -18,7 +19,7 @@ export default async function Roles({
const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
return (
<ContentLayout title="Roles" icon="lucide:user-cog">
<ContentLayout title="Roles" icon={<UserCog />}>
<div className="flex flex-col gap-6">
<div className="flex flex-row items-end justify-between">
<DataTableFilterCustom
+2 -1
View File
@@ -1,3 +1,4 @@
import { SlidersVertical } from "lucide-react";
import { redirect } from "next/navigation";
import { getProviders } from "@/actions/providers";
@@ -28,7 +29,7 @@ export default async function ScanConfigPage() {
const richProviders = providersResponse.data;
return (
<ContentLayout title="Configuration" icon="lucide:sliders">
<ContentLayout title="Configuration" icon={<SlidersVertical />}>
<ScanConfigurationsManager
initialConfigs={configs}
richProviders={richProviders}
+2 -1
View File
@@ -1,3 +1,4 @@
import { Timer } from "lucide-react";
import { Suspense } from "react";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
@@ -218,7 +219,7 @@ export default async function Scans({
return (
<ContentLayout
title="Scans"
icon="lucide:timer"
icon={<Timer />}
onboardingAction={onboardingAction}
>
<ScansPageShell
+3 -4
View File
@@ -1,13 +1,12 @@
import { Server } from "lucide-react";
import { FilterControls } from "@/components/filters";
import { ContentLayout } from "@/components/shadcn/content-layout";
export default async function Services() {
// const searchParamsKey = JSON.stringify(searchParams || {});
return (
<ContentLayout
title="Services"
icon="material-symbols:linked-services-outline"
>
<ContentLayout title="Services" icon={<Server />}>
<div className="h-4" />
<FilterControls />
<div className="h-4" />
+2 -1
View File
@@ -1,3 +1,4 @@
import { User } from "lucide-react";
import Link from "next/link";
import { Suspense } from "react";
@@ -20,7 +21,7 @@ export default async function Users({
const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
return (
<ContentLayout title="Users" icon="lucide:user">
<ContentLayout title="Users" icon={<User />}>
<div className="flex flex-col gap-6">
<div className="flex flex-row items-end justify-end">
<Button asChild>
+3 -1
View File
@@ -1,8 +1,10 @@
import { Tags } from "lucide-react";
import { ContentLayout } from "@/components/shadcn/content-layout";
export default async function Workloads() {
return (
<ContentLayout title="Workloads" icon="lucide:tags">
<ContentLayout title="Workloads" icon={<Tags />}>
<p>Workloads</p>
</ContentLayout>
);
@@ -0,0 +1 @@
Icons now ship in the UI bundle instead of being fetched from `api.iconify.design`, so pages render correctly in air-gapped deployments
@@ -0,0 +1 @@
Findings page paints a skeleton at once and streams the table before the filters; the "Finding Group" options load in a single request when the dropdown opens
+3 -4
View File
@@ -1,7 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Icon } from "@iconify/react";
import { KeyRound } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
@@ -241,10 +241,9 @@ export const SignInForm = ({
form.setValue("isSamlMode", true);
}}
>
<Icon
<KeyRound
aria-hidden="true"
className="text-text-neutral-tertiary"
icon="mdi:shield-key"
width={24}
/>
</Button>
</TooltipTrigger>
+18 -8
View File
@@ -1,15 +1,8 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { SocialButtons } from "./social-buttons";
// Stub Iconify: the real <Icon> fetches icon data over the network and its
// retry timers can fire after the jsdom environment is torn down, crashing the
// worker with "window is not defined".
vi.mock("@iconify/react", () => ({
Icon: ({ icon }: { icon: string }) => <span aria-label={icon} />,
}));
describe("SocialButtons", () => {
it("renders icon-only provider links that keep their accessible names", () => {
render(
@@ -28,6 +21,23 @@ describe("SocialButtons", () => {
expect(github.textContent).toBe("");
});
it("renders bundled provider logos without fetching icon data", () => {
render(
<SocialButtons
googleAuthUrl="https://accounts.google.com/auth"
githubAuthUrl="https://github.com/login/oauth"
isGoogleOAuthEnabled
isGithubOAuthEnabled
/>,
);
const google = screen.getByRole("link", { name: "Continue with Google" });
const github = screen.getByRole("link", { name: "Continue with Github" });
expect(google.querySelector("svg path")).toBeInTheDocument();
expect(github.querySelector("svg path")).toBeInTheDocument();
});
it("keeps accessible names on disabled providers", () => {
render(<SocialButtons />);
+18 -16
View File
@@ -1,6 +1,10 @@
import { Icon } from "@iconify/react";
import type { ReactNode } from "react";
import {
GithubIcon,
GoogleIcon,
GoogleMonoIcon,
} from "@/components/icons/Icons";
import {
Button,
Tooltip,
@@ -9,14 +13,15 @@ import {
} from "@/components/shadcn";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
import { appendCallbackState } from "@/lib/auth-callback-url";
import type { IconComponent } from "@/types/components";
type SocialProvider = {
key: string;
label: string;
url?: string;
isOAuthEnabled?: boolean;
enabledIcon: string;
disabledIcon: string;
enabledIcon: IconComponent;
disabledIcon: IconComponent;
disabledDocs: {
message: string;
href: string;
@@ -42,18 +47,15 @@ const SocialButton = ({
>
{isDisabled ? (
<span className="flex items-center justify-center">
<Icon
icon={
provider.isOAuthEnabled
? provider.enabledIcon
: provider.disabledIcon
}
width={24}
/>
{provider.isOAuthEnabled ? (
<provider.enabledIcon aria-hidden="true" />
) : (
<provider.disabledIcon aria-hidden="true" />
)}
</span>
) : (
<a href={provider.url} className="flex items-center justify-center">
<Icon icon={provider.enabledIcon} width={24} />
<provider.enabledIcon aria-hidden="true" />
</a>
)}
</Button>
@@ -121,8 +123,8 @@ export const SocialButtons = ({
label: "Continue with Google",
url: googleUrl,
isOAuthEnabled: isGoogleOAuthEnabled,
enabledIcon: "flat-color-icons:google",
disabledIcon: "simple-icons:google",
enabledIcon: GoogleIcon,
disabledIcon: GoogleMonoIcon,
disabledDocs: {
message: "Social Login with Google is not enabled.",
href: "https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-social-login/#google-oauth-configuration",
@@ -133,8 +135,8 @@ export const SocialButtons = ({
label: "Continue with Github",
url: githubUrl,
isOAuthEnabled: isGithubOAuthEnabled,
enabledIcon: "simple-icons:github",
disabledIcon: "simple-icons:github",
enabledIcon: GithubIcon,
disabledIcon: GithubIcon,
disabledDocs: {
message: "Social Login with Github is not enabled.",
href: "https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-social-login/#github-oauth-configuration",
@@ -1,6 +1,6 @@
"use client";
import { Icon } from "@iconify/react";
import { Clock, RefreshCw } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/shadcn/button/button";
@@ -16,8 +16,8 @@ export const ComplianceWarming = () => {
<CardContent>
<div className="flex w-full items-center justify-between gap-6">
<div className="flex items-start gap-4">
<Icon
icon="tabler:clock"
<Clock
aria-hidden="true"
className="mt-1 h-5 w-5 text-gray-400 dark:text-gray-300"
/>
<div>
@@ -37,7 +37,7 @@ export const ComplianceWarming = () => {
onClick={() => router.refresh()}
aria-label="Reload compliance data"
>
<Icon icon="tabler:refresh" className="h-4 w-4" />
<RefreshCw aria-hidden="true" className="h-4 w-4" />
Try Again
</Button>
</div>
+16 -5
View File
@@ -28,9 +28,13 @@ import { ProviderProps } from "@/types/providers";
import {
buildFindingGroupFilterOption,
buildFindingsFilterChips,
type FindingCheckFilterOption,
FILTER_CONTROL_COLUMN_CLASS,
getFindingsFilterDisplayValue,
} from "./findings-filters.utils";
import {
type FindingCheckOptionsSource,
useFindingCheckOptions,
} from "./use-finding-check-options";
interface FindingsFiltersProps {
/** Provider data for provider/account filter controls. */
@@ -44,7 +48,8 @@ interface FindingsFiltersProps {
uniqueResourceTypes: string[];
uniqueCategories: string[];
uniqueGroups: string[];
checkOptions?: FindingCheckFilterOption[];
/** Enables the lazily loaded Finding Group filter. */
checkOptionsSource?: FindingCheckOptionsSource;
trailingControls?: ReactNode;
variant?: "default" | "alerts-edit";
}
@@ -71,8 +76,6 @@ const countVisibleFilterKeys = (filters: Record<string, string[]>): number =>
return true;
}).length;
const FILTER_CONTROL_COLUMN_CLASS =
"min-w-0 flex-none basis-full sm:basis-[calc((100%_-_0.75rem)/2)] lg:basis-[calc((100%_-_1.5rem)/3)] xl:basis-[calc((100%_-_2.25rem)/4)] 2xl:basis-[calc((100%_-_3rem)/5)]";
const FILTER_GRID_ITEM_CLASS = "min-w-0";
const FINDING_GROUP_FILTER_KEYS = ["filter[check_id]", "filter[check_id__in]"];
@@ -89,7 +92,7 @@ export const FindingsFilterBatchControls = ({
uniqueResourceTypes,
uniqueCategories,
uniqueGroups,
checkOptions = [],
checkOptionsSource,
trailingControls,
appliedFilters,
pendingFilters,
@@ -107,6 +110,11 @@ export const FindingsFilterBatchControls = ({
}: FindingsFilterBatchControlsProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const isAlertsEdit = variant === "alerts-edit";
const {
options: checkOptions,
isLoading: isLoadingCheckOptions,
loadAll: loadCheckOptions,
} = useFindingCheckOptions({ source: checkOptionsSource });
const checkTitles = Object.fromEntries(
checkOptions.map(({ checkId, checkTitle }) => [
checkId,
@@ -118,6 +126,9 @@ export const FindingsFilterBatchControls = ({
selectedCheckIds: getFilterValue("filter[check_id]"),
selectedCheckIdsIn: getFilterValue("filter[check_id__in]"),
checkTitles,
lazy: checkOptionsSource
? { onOpen: loadCheckOptions, isLoading: isLoadingCheckOptions }
: undefined,
});
const customFilters = [
@@ -431,4 +431,26 @@ describe("buildFindingGroupFilterOption", () => {
}),
).toBeNull();
});
it("keeps the Finding Group filter visible with lazy loading hooks when nothing is loaded yet", () => {
// Given
const onOpen = () => undefined;
// When
const filter = buildFindingGroupFilterOption({
checkOptions: [],
selectedCheckIds: [],
selectedCheckIdsIn: [],
checkTitles: {},
lazy: { onOpen, isLoading: true },
});
// Then
expect(filter).toMatchObject({
key: "check_id__in",
values: [],
onOpen,
isLoading: true,
});
});
});
@@ -17,6 +17,14 @@ export interface FindingCheckFilterOption {
checkTitle?: string;
}
export interface FindingGroupLazyOptions {
onOpen: () => void;
isLoading: boolean;
}
export const FILTER_CONTROL_COLUMN_CLASS =
"min-w-0 flex-none basis-full sm:basis-[calc((100%_-_0.75rem)/2)] lg:basis-[calc((100%_-_1.5rem)/3)] xl:basis-[calc((100%_-_2.25rem)/4)] 2xl:basis-[calc((100%_-_3rem)/5)]";
interface GetFindingsFilterDisplayValueOptions {
providers?: ProviderProps[];
scans?: Array<{ [scanId: string]: ScanEntity }>;
@@ -122,11 +130,14 @@ export function buildFindingGroupFilterOption({
selectedCheckIds,
selectedCheckIdsIn,
checkTitles,
lazy,
}: {
checkOptions: FindingCheckFilterOption[];
selectedCheckIds: string[];
selectedCheckIdsIn: string[];
checkTitles: Record<string, string>;
/** Keeps the dropdown visible with no values so they can load on open. */
lazy?: FindingGroupLazyOptions;
}): FilterOption | null {
const values = uniqueNonEmptyValues([
...checkOptions.map((option) => option.checkId),
@@ -134,7 +145,7 @@ export function buildFindingGroupFilterOption({
...selectedCheckIdsIn,
]);
if (values.length === 0) {
if (values.length === 0 && !lazy) {
return null;
}
@@ -147,6 +158,7 @@ export function buildFindingGroupFilterOption({
checkTitles,
}),
index: 3,
...(lazy && { onOpen: lazy.onOpen, isLoading: lazy.isLoading }),
};
}
@@ -0,0 +1,170 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
getFindingGroupCheckOptions: vi.fn(),
}));
vi.mock("@/actions/finding-groups", () => ({
getFindingGroupCheckOptions: mocks.getFindingGroupCheckOptions,
}));
import { useFindingCheckOptions } from "./use-finding-check-options";
const filters: Record<string, string> = {
"filter[severity__in]": "high",
"filter[check_id__in]": "check-a",
};
const selected = [{ checkId: "check-a", checkTitle: "Check A" }];
const source = { filters, hasHistoricalData: false, initialOptions: selected };
describe("useFindingCheckOptions", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getFindingGroupCheckOptions.mockResolvedValue([]);
});
it("should do nothing without a source", () => {
// When
const { result } = renderHook(() =>
useFindingCheckOptions({ source: undefined }),
);
act(() => result.current.loadAll());
// Then
expect(mocks.getFindingGroupCheckOptions).not.toHaveBeenCalled();
expect(result.current.options).toEqual([]);
});
it("should expose the selected titles without fetching anything", () => {
// When
const { result } = renderHook(() => useFindingCheckOptions({ source }));
// Then
expect(result.current.options).toEqual(selected);
expect(mocks.getFindingGroupCheckOptions).not.toHaveBeenCalled();
});
it("should load every option in one request on first open and keep the selected titles", async () => {
// Given
mocks.getFindingGroupCheckOptions.mockResolvedValue([
{ checkId: "check-b", checkTitle: "Check B" },
]);
const { result } = renderHook(() => useFindingCheckOptions({ source }));
// When
act(() => result.current.loadAll());
expect(result.current.isLoading).toBe(true);
act(() => result.current.loadAll());
// Then
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(mocks.getFindingGroupCheckOptions).toHaveBeenCalledTimes(1);
expect(mocks.getFindingGroupCheckOptions).toHaveBeenCalledWith({
filters,
hasHistoricalData: false,
});
expect(result.current.options).toEqual([
...selected,
{ checkId: "check-b", checkTitle: "Check B" },
]);
});
it("should not reload when only the check selection changes", async () => {
// Given
const { result, rerender } = renderHook(
({ filters }) =>
useFindingCheckOptions({
source: { filters, hasHistoricalData: false, initialOptions: [] },
}),
{ initialProps: { filters } },
);
act(() => result.current.loadAll());
await waitFor(() => expect(result.current.isLoading).toBe(false));
// When
rerender({ filters: { ...filters, "filter[check_id__in]": "check-b" } });
act(() => result.current.loadAll());
// Then
expect(mocks.getFindingGroupCheckOptions).toHaveBeenCalledTimes(1);
});
it("should forget loaded options when the surrounding filters change", async () => {
// Given
mocks.getFindingGroupCheckOptions.mockResolvedValue([
{ checkId: "check-b", checkTitle: "Check B" },
]);
const { result, rerender } = renderHook(
({ filters }) =>
useFindingCheckOptions({
source: { filters, hasHistoricalData: false, initialOptions: [] },
}),
{ initialProps: { filters } },
);
act(() => result.current.loadAll());
await waitFor(() => expect(result.current.options).toHaveLength(1));
// When
rerender({ filters: { "filter[severity__in]": "low" } });
// Then
expect(result.current.options).toEqual([]);
act(() => result.current.loadAll());
await waitFor(() =>
expect(mocks.getFindingGroupCheckOptions).toHaveBeenCalledTimes(2),
);
});
it("should drop a load that finishes after the filters changed", async () => {
// Given
let resolveStale: (options: unknown) => void = () => undefined;
mocks.getFindingGroupCheckOptions.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveStale = resolve;
}),
);
const { result, rerender } = renderHook(
({ filters }) =>
useFindingCheckOptions({
source: { filters, hasHistoricalData: false, initialOptions: [] },
}),
{ initialProps: { filters } },
);
act(() => result.current.loadAll());
rerender({ filters: { "filter[severity__in]": "low" } });
// When
await act(async () => {
resolveStale([{ checkId: "stale", checkTitle: "Stale" }]);
});
// Then
expect(result.current.options).toEqual([]);
expect(result.current.isLoading).toBe(false);
act(() => result.current.loadAll());
await waitFor(() =>
expect(mocks.getFindingGroupCheckOptions).toHaveBeenCalledTimes(2),
);
});
it("should allow retrying after a failed load", async () => {
// Given
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
mocks.getFindingGroupCheckOptions.mockRejectedValueOnce(new Error("boom"));
const { result } = renderHook(() => useFindingCheckOptions({ source }));
// When
act(() => result.current.loadAll());
await waitFor(() => expect(result.current.isLoading).toBe(false));
act(() => result.current.loadAll());
// Then
expect(consoleError).toHaveBeenCalledTimes(1);
expect(mocks.getFindingGroupCheckOptions).toHaveBeenCalledTimes(2);
consoleError.mockRestore();
});
});
@@ -0,0 +1,95 @@
"use client";
import { useState } from "react";
import { getFindingGroupCheckOptions } from "@/actions/finding-groups";
import { excludeFindingGroupOwnFilters } from "@/lib/finding-group-filter-options";
import type { FindingCheckFilterOption } from "./findings-filters.utils";
const LOAD_STATUS = {
IDLE: "idle",
LOADING: "loading",
LOADED: "loaded",
} as const;
type LoadStatus = (typeof LOAD_STATUS)[keyof typeof LOAD_STATUS];
export interface FindingCheckOptionsSource {
/** Applied filters; the check filter itself is ignored when loading options. */
filters: Record<string, string>;
hasHistoricalData: boolean;
/** Titles of the checks already selected, so their chips read well before the list loads. */
initialOptions: FindingCheckFilterOption[];
}
interface LoadState {
/** The filters the options were loaded for; a mismatch means they are stale. */
key: string;
status: LoadStatus;
options: FindingCheckFilterOption[];
}
interface UseFindingCheckOptionsParams {
/** Undefined disables lazy loading. */
source?: FindingCheckOptionsSource;
}
const idleState = (key: string): LoadState => ({
key,
status: LOAD_STATUS.IDLE,
options: [],
});
const toFiltersKey = (filters: Record<string, string>) =>
JSON.stringify(Object.entries(excludeFindingGroupOwnFilters(filters)).sort());
function mergeOptions(
current: FindingCheckFilterOption[],
incoming: FindingCheckFilterOption[],
): FindingCheckFilterOption[] {
const byId = new Map(current.map((option) => [option.checkId, option]));
for (const option of incoming) byId.set(option.checkId, option);
return Array.from(byId.values());
}
/** Loads the check filter options the first time the dropdown opens. */
export function useFindingCheckOptions({
source,
}: UseFindingCheckOptionsParams) {
const filtersKey = source ? toFiltersKey(source.filters) : "";
// Local state needed: options load on demand, after the first open.
const [load, setLoad] = useState<LoadState>(() => idleState(filtersKey));
// Derived: a load for other filters counts as nothing loaded.
const current = load.key === filtersKey ? load : idleState(filtersKey);
const loadAll = () => {
if (!source || current.status !== LOAD_STATUS.IDLE) return;
const requestKey = filtersKey;
setLoad({ key: requestKey, status: LOAD_STATUS.LOADING, options: [] });
getFindingGroupCheckOptions({
filters: source.filters,
hasHistoricalData: source.hasHistoricalData,
})
.then((options) => {
setLoad((previous) =>
previous.key === requestKey
? { key: requestKey, status: LOAD_STATUS.LOADED, options }
: previous,
);
})
.catch((error) => {
console.error("Error fetching finding group filter options:", error);
setLoad((previous) =>
previous.key === requestKey ? idleState(requestKey) : previous,
);
});
};
return {
options: mergeOptions(source?.initialOptions ?? [], current.options),
isLoading: current.status === LOAD_STATUS.LOADING,
loadAll,
};
}
+104
View File
@@ -46,6 +46,110 @@ export const GithubIcon: React.FC<IconSvgProps> = ({
);
};
// Multicolor Google "G" (flat-color-icons, MIT).
export const GoogleIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 48 48"
width={size || width}
height={size || height}
{...props}
>
<path
fill="#FFC107"
d="M43.611 20.083H42V20H24v8h11.303c-1.649 4.657-6.08 8-11.303 8c-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4C12.955 4 4 12.955 4 24s8.955 20 20 20s20-8.955 20-20c0-1.341-.138-2.65-.389-3.917"
/>
<path
fill="#FF3D00"
d="m6.306 14.691l6.571 4.819C14.655 15.108 18.961 12 24 12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4C16.318 4 9.656 8.337 6.306 14.691"
/>
<path
fill="#4CAF50"
d="M24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238A11.9 11.9 0 0 1 24 36c-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025C9.505 39.556 16.227 44 24 44"
/>
<path
fill="#1976D2"
d="M43.611 20.083H42V20H24v8h11.303a12.04 12.04 0 0 1-4.087 5.571l.003-.002l6.19 5.238C36.971 39.205 44 34 44 24c0-1.341-.138-2.65-.389-3.917"
/>
</svg>
);
};
// Monochrome Google mark (simple-icons, CC0).
export const GoogleMonoIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width={size || width}
height={size || height}
{...props}
>
<path
fill="currentColor"
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133c-1.147 1.147-2.933 2.4-6.053 2.4c-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0C5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36c2.16-2.16 2.84-5.213 2.84-7.667c0-.76-.053-1.467-.173-2.053z"
/>
</svg>
);
};
// OpenAI mark (simple-icons, CC0).
export const OpenAIIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width={size || width}
height={size || height}
{...props}
>
<path
fill="currentColor"
d="M22.282 9.821a6 6 0 0 0-.516-4.91a6.05 6.05 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a6 6 0 0 0-3.998 2.9a6.05 6.05 0 0 0 .743 7.097a5.98 5.98 0 0 0 .51 4.911a6.05 6.05 0 0 0 6.515 2.9A6 6 0 0 0 13.26 24a6.06 6.06 0 0 0 5.772-4.206a6 6 0 0 0 3.997-2.9a6.06 6.06 0 0 0-.747-7.073M13.26 22.43a4.48 4.48 0 0 1-2.876-1.04l.141-.081l4.779-2.758a.8.8 0 0 0 .392-.681v-6.737l2.02 1.168a.07.07 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494M3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085l4.783 2.759a.77.77 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646M2.34 7.896a4.5 4.5 0 0 1 2.366-1.973V11.6a.77.77 0 0 0 .388.677l5.815 3.354l-2.02 1.168a.08.08 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.08.08 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667m2.01-3.023l-.141-.085l-4.774-2.782a.78.78 0 0 0-.785 0L9.409 9.23V6.897a.07.07 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.8.8 0 0 0-.393.681zm1.097-2.365l2.602-1.5l2.607 1.5v2.999l-2.597 1.5l-2.607-1.5Z"
/>
</svg>
);
};
// AWS wordmark (simple-icons, CC0).
export const AmazonWebServicesIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width={size || width}
height={size || height}
{...props}
>
<path
fill="currentColor"
d="M6.763 10.036q.002.446.088.71c.064.176.144.368.256.576c.04.063.056.127.056.183q.002.12-.152.24l-.503.335a.4.4 0 0 1-.208.072q-.12-.002-.239-.112a2.5 2.5 0 0 1-.287-.375a6 6 0 0 1-.248-.471q-.934 1.101-2.347 1.101c-.67 0-1.205-.191-1.596-.574q-.588-.575-.59-1.533c0-.678.239-1.23.726-1.644c.487-.415 1.133-.623 1.955-.623c.272 0 .551.024.846.064c.296.04.6.104.918.176v-.583q-.001-.909-.375-1.277c-.255-.248-.686-.367-1.3-.367c-.28 0-.568.031-.863.103q-.443.106-.862.272a2 2 0 0 1-.28.104a.5.5 0 0 1-.127.023q-.168.002-.168-.247v-.391c0-.128.016-.224.056-.28a.6.6 0 0 1 .224-.167a4.6 4.6 0 0 1 1.005-.36a4.8 4.8 0 0 1 1.246-.151c.95 0 1.644.216 2.091.647q.66.645.662 1.963v2.586zm-3.24 1.214c.263 0 .534-.048.822-.144a1.8 1.8 0 0 0 .758-.51a1.3 1.3 0 0 0 .272-.512c.047-.191.08-.423.08-.694v-.335a7 7 0 0 0-.735-.136a6 6 0 0 0-.75-.048c-.535 0-.926.104-1.19.32c-.263.215-.39.518-.39.917c0 .375.095.655.295.846c.191.2.47.296.838.296m6.41.862c-.144 0-.24-.024-.304-.08c-.064-.048-.12-.16-.168-.311L7.586 5.55a1.4 1.4 0 0 1-.072-.32c0-.128.064-.2.191-.2h.783q.227-.001.31.08c.065.048.113.16.16.312l1.342 5.284l1.245-5.284q.058-.24.151-.312a.55.55 0 0 1 .32-.08h.638c.152 0 .256.025.32.08c.063.048.12.16.151.312l1.261 5.348l1.381-5.348q.074-.24.16-.312a.52.52 0 0 1 .311-.08h.743c.127 0 .2.065.2.2c0 .04-.009.08-.017.128a1 1 0 0 1-.056.2l-1.923 6.17q-.072.24-.168.311a.5.5 0 0 1-.303.08h-.687c-.151 0-.255-.024-.32-.08c-.063-.056-.119-.16-.15-.32l-1.238-5.148l-1.23 5.14c-.04.16-.087.264-.15.32c-.065.056-.177.08-.32.08zm10.256.215c-.415 0-.83-.048-1.229-.143c-.399-.096-.71-.2-.918-.32c-.128-.071-.215-.151-.247-.223a.6.6 0 0 1-.048-.224v-.407c0-.167.064-.247.183-.247q.072 0 .144.024c.048.016.12.048.2.08q.408.181.878.279c.319.064.63.096.95.096c.502 0 .894-.088 1.165-.264a.86.86 0 0 0 .415-.758a.78.78 0 0 0-.215-.559c-.144-.151-.416-.287-.807-.415l-1.157-.36c-.583-.183-1.014-.454-1.277-.813a1.9 1.9 0 0 1-.4-1.158q0-.502.216-.886c.144-.255.335-.479.575-.654c.24-.184.51-.32.83-.415c.32-.096.655-.136 1.006-.136c.175 0 .359.008.535.032c.183.024.35.056.518.088q.24.058.455.127q.216.072.336.144a.7.7 0 0 1 .24.2a.43.43 0 0 1 .071.263v.375q-.002.254-.184.256a.8.8 0 0 1-.303-.096a3.65 3.65 0 0 0-1.532-.311c-.455 0-.815.071-1.062.223s-.375.383-.375.71c0 .224.08.416.24.567c.159.152.454.304.877.44l1.134.358c.574.184.99.44 1.237.767s.367.702.367 1.117c0 .343-.072.655-.207.926a2.2 2.2 0 0 1-.583.703c-.248.2-.543.343-.886.447c-.36.111-.734.167-1.142.167m1.509 3.88c-2.626 1.94-6.442 2.969-9.722 2.969c-4.598 0-8.74-1.7-11.87-4.526c-.247-.223-.024-.527.272-.351c3.384 1.963 7.559 3.153 11.877 3.153c2.914 0 6.114-.607 9.06-1.852c.439-.2.814.287.383.607m1.094-1.246c-.336-.43-2.22-.207-3.074-.103c-.255.032-.295-.192-.063-.36c1.5-1.053 3.967-.75 4.254-.399c.287.36-.08 2.826-1.485 4.007c-.215.184-.423.088-.327-.151c.32-.79 1.03-2.57.695-2.994"
/>
</svg>
);
};
export const MoonFilledIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
@@ -2,7 +2,7 @@
import { BellRing, Info } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { ReactNode, Suspense } from "react";
import { ReactNode, Suspense, type ReactElement } from "react";
import { MobileAppSidebar } from "@/components/layout/app-sidebar";
import {
@@ -31,7 +31,7 @@ export interface OnboardingActionConfig {
interface NavbarClientProps {
title: string;
icon?: string | ReactNode;
icon?: ReactElement;
onboardingAction?: OnboardingActionConfig;
feedsSlot?: ReactNode;
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { ReactNode, Suspense } from "react";
import { Suspense, type ReactElement } from "react";
import { FeedsServer } from "@/components/feeds";
@@ -12,7 +12,7 @@ export type { OnboardingActionConfig };
interface NavbarProps {
title: string;
icon?: string | ReactNode;
icon?: ReactElement;
onboardingAction?: OnboardingActionConfig;
}
@@ -1,7 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Icon } from "@iconify/react";
import { RefreshCw } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
@@ -110,8 +110,8 @@ export const LighthouseSettings = () => {
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-12">
<Icon
icon="heroicons:arrow-path"
<RefreshCw
aria-hidden="true"
className="h-8 w-8 animate-spin text-gray-400"
/>
</div>
@@ -1,5 +1,7 @@
"use client";
import { AmazonWebServicesIcon, OpenAIIcon } from "@/components/icons/Icons";
import type { IconComponent } from "@/types/components";
import type { LighthouseProvider } from "@/types/lighthouse-v1";
export type LLMProviderFieldType = "text" | "password";
@@ -17,7 +19,7 @@ export interface LLMProviderConfig {
id: LighthouseProvider;
name: string;
description: string;
icon: string;
icon: IconComponent;
fields: LLMProviderField[];
}
@@ -29,7 +31,7 @@ export const LLM_PROVIDER_REGISTRY: Record<
id: "openai",
name: "OpenAI",
description: "Industry-leading GPT models for general-purpose AI",
icon: "simple-icons:openai",
icon: OpenAIIcon,
fields: [
{
name: "api_key",
@@ -45,7 +47,7 @@ export const LLM_PROVIDER_REGISTRY: Record<
id: "bedrock",
name: "Amazon Bedrock",
description: "AWS-managed AI with Claude, Llama, Titan & more",
icon: "simple-icons:amazonwebservices",
icon: AmazonWebServicesIcon,
fields: [
{
name: "access_key_id",
@@ -77,7 +79,7 @@ export const LLM_PROVIDER_REGISTRY: Record<
id: "openai_compatible",
name: "OpenAI Compatible",
description: "Connect to custom OpenAI-compatible endpoints",
icon: "simple-icons:openai",
icon: OpenAIIcon,
fields: [
{
name: "api_key",
@@ -1,6 +1,5 @@
"use client";
import { Icon } from "@iconify/react";
import Link from "next/link";
import { useEffect, useState } from "react";
@@ -9,6 +8,7 @@ import {
getTenantConfig,
} from "@/actions/lighthouse-v1/lighthouse";
import { Button, Card, CardContent, CardHeader } from "@/components/shadcn";
import type { IconComponent } from "@/types/components";
import { getAllProviders } from "./llm-provider-registry";
@@ -25,7 +25,7 @@ type LLMProvider = {
provider: string;
description: string;
defaultModel: string;
icon: string;
icon: IconComponent;
isConnected: boolean;
isActive: boolean;
isDefaultProvider: boolean;
@@ -156,7 +156,7 @@ export const LLMProvidersTable = () => {
{/* Header */}
<CardHeader>
<div className="flex items-center gap-3">
<Icon icon={provider.icon} width={40} height={40} />
<provider.icon aria-hidden="true" size={40} />
<div className="flex flex-1 flex-col">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold">
+11 -8
View File
@@ -1,6 +1,6 @@
"use client";
import { Icon } from "@iconify/react";
import { RefreshCw, Search, Star } from "lucide-react";
import { useEffect, useState } from "react";
import {
@@ -167,8 +167,8 @@ export const SelectModel = ({
className="text-text-neutral-secondary hover:bg-bg-neutral-tertiary flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50"
aria-label="Refresh models"
>
<Icon
icon="heroicons:arrow-path"
<RefreshCw
aria-hidden="true"
className={`h-5 w-5 ${isLoading ? "animate-spin" : ""}`}
/>
<span>{isLoading ? "Refreshing..." : "Refresh"}</span>
@@ -183,8 +183,8 @@ export const SelectModel = ({
{!isLoading && models.length > 0 && (
<div className="relative">
<Icon
icon="heroicons:magnifying-glass"
<Search
aria-hidden="true"
className="text-text-neutral-tertiary pointer-events-none absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2"
/>
<input
@@ -199,8 +199,8 @@ export const SelectModel = ({
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Icon
icon="heroicons:arrow-path"
<RefreshCw
aria-hidden="true"
className="text-text-neutral-tertiary h-8 w-8 animate-spin"
/>
</div>
@@ -242,7 +242,10 @@ export const SelectModel = ({
<span className="text-sm font-medium">{model.name}</span>
{isRecommended(model.id) && (
<span className="bg-bg-pass-secondary text-text-success-primary inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium">
<Icon icon="heroicons:star-solid" className="h-3 w-3" />
<Star
aria-hidden="true"
className="h-3 w-3 fill-current"
/>
Recommended
</span>
)}
@@ -1,6 +1,6 @@
"use client";
import { Icon } from "@iconify/react";
import { Eye, EyeOff } from "lucide-react";
import { InputHTMLAttributes, useState } from "react";
import { Control, FieldPath, FieldValues } from "react-hook-form";
@@ -149,16 +149,19 @@ export const WizardInputField = <T extends FieldValues>({
: "Hide password"
}
>
<Icon
className="pointer-events-none text-xl"
icon={
(password && isPasswordVisible) ||
(confirmPassword && isConfirmPasswordVisible) ||
(type === "password" && isPasswordVisible)
? "solar:eye-closed-linear"
: "solar:eye-bold"
}
/>
{(password && isPasswordVisible) ||
(confirmPassword && isConfirmPasswordVisible) ||
(type === "password" && isPasswordVisible) ? (
<EyeOff
aria-hidden="true"
className="pointer-events-none size-5"
/>
) : (
<Eye
aria-hidden="true"
className="pointer-events-none size-5"
/>
)}
</button>
)}
</div>
@@ -1,8 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Icon } from "@iconify/react";
import { Loader2 } from "lucide-react";
import { CircleAlert, Loader2 } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
@@ -176,8 +175,7 @@ export const TestConnectionForm = ({
className="border-border-error flex items-start gap-4 rounded-lg border p-4"
>
<div className="flex shrink-0 items-center">
<Icon
icon="heroicons:exclamation-circle"
<CircleAlert
className="text-text-error-primary h-5 w-5"
aria-hidden="true"
/>
@@ -9,7 +9,7 @@ import { Modal } from "@/components/shadcn/modal/modal";
interface RegistryAccessDialogCommonProps {
errorMessage?: string;
registryKeyUrl?: string;
registryUrl?: string;
onOpenChange: (open: boolean) => void;
onSubmit: (key: string) => Promise<void>;
open: boolean;
@@ -33,7 +33,7 @@ type RegistryAccessDialogProps =
export function RegistryAccessDialog({
errorMessage,
registryKeyUrl,
registryUrl,
mode,
onDisconnect,
onOpenChange,
@@ -112,18 +112,14 @@ export function RegistryAccessDialog({
{errorMessage}
</p>
)}
{registryKeyUrl && (
{registryUrl && (
<Button
asChild
className="self-start"
size="link-sm"
variant="link"
>
<a
href={registryKeyUrl}
rel="noopener noreferrer"
target="_blank"
>
<a href={registryUrl} rel="noopener noreferrer" target="_blank">
Where do I find my key?
</a>
</Button>
@@ -7,6 +7,7 @@ import { Card } from "@/components/shadcn/card/card";
interface RegistryCredentialBannerProps {
connectButtonRef?: Ref<HTMLButtonElement>;
onConnect: () => void;
registryUrl?: string;
tenantArtifactCount: number;
validationPending: boolean;
}
@@ -14,6 +15,7 @@ interface RegistryCredentialBannerProps {
export function RegistryCredentialBanner({
connectButtonRef,
onConnect,
registryUrl,
tenantArtifactCount,
validationPending,
}: RegistryCredentialBannerProps) {
@@ -46,16 +48,18 @@ export function RegistryCredentialBanner({
<Button onClick={onConnect} ref={connectButtonRef} type="button">
Connect API key
</Button>
<Button asChild variant="outline">
<a
aria-label="Explore Prowler Registry (opens in a new tab)"
href="https://registry.prowler.com"
rel="noopener noreferrer"
target="_blank"
>
Explore Prowler Registry
</a>
</Button>
{registryUrl && (
<Button asChild variant="outline">
<a
aria-label="Explore Prowler Registry (opens in a new tab)"
href={registryUrl}
rel="noopener noreferrer"
target="_blank"
>
Explore Prowler Registry
</a>
</Button>
)}
</div>
</div>
</div>
@@ -752,10 +752,31 @@ describe("RegistryExplorer", () => {
expect(document.body.textContent).not.toContain(
"preserved tenant artifact",
);
expect(document.body.textContent).toContain("Explore Prowler Registry");
expect(document.body.textContent).not.toContain(
"Explore Prowler Registry",
);
expect(document.body.textContent).not.toContain("Search artifacts");
});
it("links the banner to the configured Registry", async () => {
// Given
const screen = await render(
<RegistryExplorer
initialState={onboardingState}
registryUrl="https://registry.internal.test/"
/>,
);
// Then
await expect
.element(
screen.getByRole("link", {
name: "Explore Prowler Registry (opens in a new tab)",
}),
)
.toHaveAttribute("href", "https://registry.internal.test/");
});
it("lets a replacement key supersede a pending validation from the banner", async () => {
// Given: a validation that never settled must not dead-end the user
submitRegistryCredentialMock.mockResolvedValue(submittedResult(true));
@@ -825,7 +846,7 @@ describe("RegistryExplorer", () => {
const screen = await render(
<RegistryExplorer
initialState={onboardingState}
registryKeyUrl="https://registry.private.test/keys"
registryUrl="https://registry.private.test/"
/>,
);
@@ -840,7 +861,7 @@ describe("RegistryExplorer", () => {
.toBeVisible();
await expect
.element(screen.getByRole("link", { name: "Where do I find my key?" }))
.toHaveAttribute("href", "https://registry.private.test/keys");
.toHaveAttribute("href", "https://registry.private.test/");
// When
await screen.getByRole("button", { name: "Cancel", exact: true }).click();
+4 -3
View File
@@ -107,12 +107,12 @@ function mutationFailureMessage(result: RegistryMutationResult) {
interface RegistryExplorerProps {
initialState: RegistryBootstrapState;
registryKeyUrl?: string;
registryUrl?: string;
}
export function RegistryExplorer({
initialState,
registryKeyUrl,
registryUrl,
}: RegistryExplorerProps) {
// The API is the sole access authority: a denied action result routes to
// Profile once, and the navigation unmounts this component with its state.
@@ -464,7 +464,7 @@ export function RegistryExplorer({
}
const accessDialogProps = {
registryKeyUrl,
registryUrl,
errorMessage: operationMessage,
onOpenChange: (open: boolean) => {
if (!open && pendingOperation !== REGISTRY_PENDING_OPERATION.CREDENTIAL) {
@@ -504,6 +504,7 @@ export function RegistryExplorer({
)}
<RegistryCredentialBanner
connectButtonRef={connectButtonRef}
registryUrl={registryUrl}
onConnect={() =>
setAccessDialogMode(REGISTRY_ACCESS_DIALOG_MODE.CONNECT)
}
@@ -1,6 +1,6 @@
"use client";
import { Icon } from "@iconify/react";
import type { ReactElement } from "react";
import {
Card,
@@ -40,7 +40,7 @@ const COLOR_STYLES = {
} as const;
export type ActionCardProps = CardProps & {
icon: string;
icon: ReactElement;
title: string;
color?: "success" | "secondary" | "warning" | "fail";
description: string;
@@ -76,7 +76,12 @@ export const ActionCard = ({
colors.iconWrapper,
)}
>
<Icon className={colors.icon} icon={icon} width={24} />
<span
aria-hidden="true"
className={cn("flex size-6 *:size-full", colors.icon)}
>
{icon}
</span>
</div>
<div className="flex flex-col">
<p className="text-md">{title}</p>
@@ -12,10 +12,6 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@iconify/react", () => ({
Icon: ({ icon }: { icon: string }) => <span aria-label={icon} />,
}));
describe("BreadcrumbNavigation", () => {
afterEach(() => {
navigationMock.pathname = "/findings";
@@ -40,22 +36,36 @@ describe("BreadcrumbNavigation", () => {
).toBeInTheDocument();
});
it("does not render icons for secondary breadcrumb items", () => {
// Given
navigationMock.pathname = "/scans/config";
// When
it("renders the page icon next to a top-level title", () => {
// Given / When
render(
<BreadcrumbNavigation
mode="auto"
title="Configuration"
icon="lucide:sliders"
title="Findings"
icon={<svg data-testid="page-icon" />}
/>,
);
// Then
expect(screen.getByLabelText("lucide:timer")).toBeInTheDocument();
expect(screen.queryByLabelText("lucide:sliders")).not.toBeInTheDocument();
expect(screen.getByTestId("page-icon")).toBeInTheDocument();
});
it("shows the bundled section icon only on the first breadcrumb", () => {
// Given
navigationMock.pathname = "/scans/config";
// When
const { container } = render(
<BreadcrumbNavigation
mode="auto"
title="Configuration"
icon={<svg data-testid="page-icon" />}
/>,
);
// Then
expect(container.querySelector("svg.lucide-timer")).toBeInTheDocument();
expect(screen.queryByTestId("page-icon")).not.toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Configuration" }),
).toBeInTheDocument();
@@ -1,9 +1,23 @@
"use client";
import { Icon } from "@iconify/react";
import {
BellRing,
Cloud,
Database,
GitBranch,
Key,
Layers,
Puzzle,
Search,
Server,
ShieldCheck,
Timer,
Users,
UsersRound,
} from "lucide-react";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { ReactNode } from "react";
import type { ReactElement, ReactNode } from "react";
import { LighthouseIcon } from "@/components/icons/Icons";
import { buildPerScanComplianceHref } from "@/lib/compliance/compliance-tab-url";
@@ -12,7 +26,7 @@ import { cn } from "@/lib/utils";
export interface CustomBreadcrumbItem {
name: string;
path?: string;
icon?: string | ReactNode;
icon?: ReactElement;
isLast?: boolean;
isClickable?: boolean;
onClick?: () => void;
@@ -21,7 +35,7 @@ export interface CustomBreadcrumbItem {
interface BreadcrumbNavigationProps {
mode?: "auto" | "custom" | "hybrid";
title?: string;
icon?: string | ReactNode;
icon?: ReactElement;
titleAction?: ReactNode;
customItems?: CustomBreadcrumbItem[];
className?: string;
@@ -43,21 +57,21 @@ export function BreadcrumbNavigation({
const searchParams = useSearchParams();
const generateAutoBreadcrumbs = (): CustomBreadcrumbItem[] => {
const pathIconMapping: Record<string, string | ReactNode> = {
"/integrations": "lucide:puzzle",
"/alerts": "lucide:bell-ring",
"/providers": "lucide:cloud",
"/users": "lucide:users",
"/compliance": "lucide:shield-check",
"/findings": "lucide:search",
"/scans": "lucide:timer",
"/roles": "lucide:key",
"/resources": "lucide:database",
const pathIconMapping: Record<string, ReactElement> = {
"/integrations": <Puzzle />,
"/alerts": <BellRing />,
"/providers": <Cloud />,
"/users": <Users />,
"/compliance": <ShieldCheck />,
"/findings": <Search />,
"/scans": <Timer />,
"/roles": <Key />,
"/resources": <Database />,
"/lighthouse": <LighthouseIcon />,
"/manage-groups": "lucide:users-2",
"/services": "lucide:server",
"/workloads": "lucide:layers",
"/attack-paths": "lucide:git-branch",
"/manage-groups": <UsersRound />,
"/services": <Server />,
"/workloads": <Layers />,
"/attack-paths": <GitBranch />,
};
const pathSegments = pathname
@@ -116,15 +130,8 @@ export function BreadcrumbNavigation({
showIcon: boolean = true,
) => (
<div className="flex items-center gap-2">
{showIcon && typeof icon === "string" ? (
<Icon
className="text-text-neutral-primary"
height={24}
icon={icon}
width={24}
/>
) : showIcon && icon ? (
<div className="flex h-8 w-8 items-center justify-center *:h-full *:w-full">
{showIcon && icon ? (
<div className="text-text-neutral-primary flex shrink-0 items-center justify-center">
{icon}
</div>
) : null}
@@ -170,20 +177,10 @@ export function BreadcrumbNavigation({
href={buildNavigationUrl(breadcrumb.path)}
className="flex cursor-pointer items-center gap-2"
>
{index === 0 &&
breadcrumb.icon &&
typeof breadcrumb.icon === "string" ? (
<Icon
aria-hidden="true"
className="text-text-neutral-primary"
height={24}
icon={breadcrumb.icon}
width={24}
/>
) : index === 0 && breadcrumb.icon ? (
<div className="flex h-6 w-6 items-center justify-center *:h-full *:w-full">
{index === 0 && breadcrumb.icon ? (
<BreadcrumbIcon className="text-text-neutral-primary">
{breadcrumb.icon}
</div>
</BreadcrumbIcon>
) : null}
<span className="text-text-neutral-primary hover:text-button-primary max-w-[150px] truncate text-sm font-bold transition-colors sm:max-w-none">
{breadcrumb.name}
@@ -194,20 +191,10 @@ export function BreadcrumbNavigation({
onClick={breadcrumb.onClick}
className="text-text-neutral-primary hover:text-text-neutral-primary-hover flex cursor-pointer items-center gap-2 text-sm font-medium transition-colors"
>
{index === 0 &&
breadcrumb.icon &&
typeof breadcrumb.icon === "string" ? (
<Icon
aria-hidden="true"
className="text-text-neutral-primary"
height={24}
icon={breadcrumb.icon}
width={24}
/>
) : index === 0 && breadcrumb.icon ? (
<div className="flex h-6 w-6 items-center justify-center *:h-full *:w-full">
{index === 0 && breadcrumb.icon ? (
<BreadcrumbIcon className="text-text-neutral-primary">
{breadcrumb.icon}
</div>
</BreadcrumbIcon>
) : null}
<span className="max-w-[150px] truncate sm:max-w-none">
{breadcrumb.name}
@@ -215,20 +202,10 @@ export function BreadcrumbNavigation({
</button>
) : (
<div className="flex items-center gap-2">
{index === 0 &&
breadcrumb.icon &&
typeof breadcrumb.icon === "string" ? (
<Icon
aria-hidden="true"
className="text-text-neutral-tertiary"
height={24}
icon={breadcrumb.icon}
width={24}
/>
) : index === 0 && breadcrumb.icon ? (
<div className="flex h-6 w-6 items-center justify-center *:h-full *:w-full">
{index === 0 && breadcrumb.icon ? (
<BreadcrumbIcon className="text-text-neutral-tertiary">
{breadcrumb.icon}
</div>
</BreadcrumbIcon>
) : null}
<span className="max-w-[150px] truncate text-sm font-medium text-gray-900 sm:max-w-none dark:text-gray-100">
{breadcrumb.name}
@@ -250,3 +227,23 @@ export function BreadcrumbNavigation({
</div>
);
}
function BreadcrumbIcon({
children,
className,
}: {
children: ReactNode;
className: string;
}) {
return (
<div
aria-hidden="true"
className={cn(
"flex h-6 w-6 items-center justify-center *:h-full *:w-full",
className,
)}
>
{children}
</div>
);
}
@@ -1,4 +1,4 @@
import { ReactNode } from "react";
import { type ReactElement } from "react";
import {
Navbar,
@@ -7,7 +7,7 @@ import {
interface ContentLayoutProps {
title: string;
icon?: string | ReactNode;
icon?: ReactElement;
onboardingAction?: OnboardingActionConfig;
children: React.ReactNode;
}
+13 -10
View File
@@ -1,6 +1,6 @@
"use client";
import { Icon } from "@iconify/react";
import { Eye, EyeOff } from "lucide-react";
import { useState } from "react";
import { Control, FieldPath, FieldValues } from "react-hook-form";
@@ -127,15 +127,18 @@ export const CustomInput = <T extends FieldValues>({
inputType === "password" ? "Show password" : "Hide password"
}
>
<Icon
className="text-text-neutral-tertiary pointer-events-none text-2xl"
icon={
(password && isPasswordVisible) ||
(confirmPassword && isConfirmPasswordVisible)
? "solar:eye-closed-linear"
: "solar:eye-bold"
}
/>
{(password && isPasswordVisible) ||
(confirmPassword && isConfirmPasswordVisible) ? (
<EyeOff
aria-hidden="true"
className="text-text-neutral-tertiary pointer-events-none size-6"
/>
) : (
<Eye
aria-hidden="true"
className="text-text-neutral-tertiary pointer-events-none size-6"
/>
)}
</button>
)}
</div>
@@ -1,12 +1,12 @@
import { Icon } from "@iconify/react";
import Link from "next/link";
import type { ReactElement } from "react";
import { Button } from "@/components/shadcn/button/button";
import { Separator } from "@/components/shadcn/separator/separator";
interface NavigationHeaderProps {
title: string;
icon: string;
icon: ReactElement;
href?: string;
}
@@ -25,8 +25,8 @@ export const NavigationHeader = ({
size="icon"
asChild
>
<Link href={href || ""}>
<Icon icon={icon} className="text-text-neutral-secondary" />
<Link href={href || ""} className="text-text-neutral-secondary">
{icon}
</Link>
</Button>
<Separator orientation="vertical" className="h-6" />
@@ -384,6 +384,22 @@ export function MultiSelectContent({
);
}
/** Status row shown under the items while more values are still loading. */
export function MultiSelectLoading({
children,
}: {
children: React.ReactNode;
}) {
return (
<div
role="status"
className="text-bg-button-secondary py-2 text-center text-sm"
>
{children}
</div>
);
}
export function MultiSelectItem({
value,
children,
@@ -31,12 +31,23 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
children,
values,
onValuesChange,
open,
onOpenChange,
}: {
children: React.ReactNode;
values?: string[];
onValuesChange?: (values: string[]) => void;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) => (
<div data-testid="multiselect" data-values={JSON.stringify(values ?? [])}>
<div
data-testid="multiselect"
data-values={JSON.stringify(values ?? [])}
data-open={String(Boolean(open))}
>
<button type="button" onClick={() => onOpenChange?.(!open)}>
toggle
</button>
{children}
{/* expose a select to drive value changes in tests */}
<select
@@ -77,10 +88,16 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
data-search-placeholder={
typeof search === "object" ? search.placeholder : String(search)
}
data-empty-message={
typeof search === "object" ? search.emptyMessage : undefined
}
>
{children}
</div>
),
MultiSelectLoading: ({ children }: { children: React.ReactNode }) => (
<div role="status">{children}</div>
),
MultiSelectSelectAll: ({ children }: { children: React.ReactNode }) => (
<button type="button">{children}</button>
),
@@ -376,4 +393,77 @@ describe("DataTableFilterCustom — batch vs instant mode", () => {
);
});
});
// ── Lazily loaded options ────────────────────────────────────────────────
describe("lazy options", () => {
const lazyFilter = (overrides: Partial<FilterOption>): FilterOption => ({
key: "check_id__in",
labelCheckboxGroup: "Finding Group",
values: [],
...overrides,
});
it("should call onOpen when the dropdown opens, never on close", async () => {
// Given
const user = userEvent.setup();
const onOpen = vi.fn();
render(<DataTableFilterCustom filters={[lazyFilter({ onOpen })]} />);
// When
await user.click(screen.getByRole("button", { name: "toggle" }));
await user.click(screen.getByRole("button", { name: "toggle" }));
// Then
expect(onOpen).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("multiselect")).toHaveAttribute(
"data-open",
"false",
);
});
it("should show a loading message while the list is still empty", () => {
// When
render(
<DataTableFilterCustom filters={[lazyFilter({ isLoading: true })]} />,
);
// Then
expect(screen.getByTestId("multiselect-content")).toHaveAttribute(
"data-empty-message",
"Loading finding group...",
);
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
it("should show a loading row under the values already available", () => {
// When
render(
<DataTableFilterCustom
filters={[lazyFilter({ isLoading: true, values: ["check-a"] })]}
/>,
);
// Then
expect(screen.getByRole("status")).toHaveTextContent(
"Loading finding group...",
);
expect(screen.getByTestId("multiselect-content")).toHaveAttribute(
"data-empty-message",
"No finding group found.",
);
});
it("should not render a loading row once the values are loaded", () => {
// When
render(
<DataTableFilterCustom
filters={[lazyFilter({ isLoading: false, values: ["check-a"] })]}
/>,
);
// Then
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
});
});
@@ -10,6 +10,7 @@ import {
MultiSelect,
MultiSelectContent,
MultiSelectItem,
MultiSelectLoading,
MultiSelectSelectAll,
MultiSelectSeparator,
MultiSelectTrigger,
@@ -89,12 +90,20 @@ export const DataTableFilterCustom = ({
const buildSearchConfig = (filter: FilterOption) => {
const label = filter.labelCheckboxGroup.toLowerCase();
const isLoadingEmptyList = filter.isLoading && filter.values.length === 0;
return {
placeholder: `Search ${label}...`,
emptyMessage: `No ${label} found.`,
emptyMessage: isLoadingEmptyList
? `Loading ${label}...`
: `No ${label} found.`,
};
};
const handleOpenChange = (filter: FilterOption, open: boolean) => {
setOpenFilterKey(open ? filter.key : null);
if (open) filter.onOpen?.();
};
// Helper function to get entity from valueLabelMapping
const getEntityForValue = (
filter: FilterOption,
@@ -286,7 +295,7 @@ export const DataTableFilterCustom = ({
<MultiSelect
key={filter.key}
open={openFilterKey === filter.key}
onOpenChange={(open) => setOpenFilterKey(open ? filter.key : null)}
onOpenChange={(open) => handleOpenChange(filter, open)}
values={selectedValues}
onValuesChange={(values) => pushDropdownFilter(filter, values)}
>
@@ -317,6 +326,11 @@ export const DataTableFilterCustom = ({
</MultiSelectItem>
);
})}
{filter.isLoading && filter.values.length > 0 && (
<MultiSelectLoading>
Loading {filter.labelCheckboxGroup.toLowerCase()}...
</MultiSelectLoading>
)}
</MultiSelectContent>
</MultiSelect>
);
-8
View File
@@ -663,14 +663,6 @@
"strategy": "installed",
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
"name": "@iconify/react",
"from": "5.2.1",
"to": "5.2.1",
"strategy": "installed",
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
"name": "@next/eslint-plugin-next",
+15
View File
@@ -105,6 +105,21 @@ export default tseslint.config(
"security/detect-object-injection": "off",
// Icons must ship in the bundle: air-gapped deployments cannot reach
// runtime icon APIs.
"no-restricted-imports": [
"error",
{
patterns: [
{
group: ["@iconify/*"],
message:
"Iconify loads icons over the network. Use lucide-react or components/icons.",
},
],
},
],
"eol-last": ["error", "always"],
"import-x/order": [
+1 -1
View File
@@ -67,7 +67,7 @@ export function getCspHeader({
return `
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com https://www.googletagmanager.com https://browser.sentry-cdn.com${posthogSource}${toolbarUiSource};
connect-src 'self' https://api.iconify.design https://api.simplesvg.com https://api.unisvg.com https://js.stripe.com https://www.googletagmanager.com https://*.sentry.io https://*.ingest.sentry.io${posthogSource}${toolbarUiSource};
connect-src 'self' https://js.stripe.com https://www.googletagmanager.com https://*.sentry.io https://*.ingest.sentry.io${posthogSource}${toolbarUiSource};
img-src 'self' https://www.google-analytics.com https://www.googletagmanager.com${registryImageOrigins.map((origin) => ` ${origin}`).join("")}${posthogSource}${toolbarUiSource};
font-src 'self'${toolbarPosthogSource};
style-src 'self' 'unsafe-inline'${toolbarPosthogSource};
+175 -1
View File
@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { getFindingGroupFilterOptions } from "./finding-group-filter-options";
import {
getFindingGroupFilterOptions,
getSelectedFindingCheckOptions,
} from "./finding-group-filter-options";
function makeResponse(
pageCount: number,
@@ -62,15 +65,186 @@ describe("getFindingGroupFilterOptions", () => {
filters: { "filter[severity__in]": "high" },
page: 1,
pageSize: 100,
sort: "check_id",
});
expect(fetchFindingGroups).toHaveBeenNthCalledWith(2, {
filters: { "filter[severity__in]": "high" },
page: 2,
pageSize: 100,
sort: "check_id",
});
expect(options).toEqual([
{ checkId: "check-a", checkTitle: "Check A updated" },
{ checkId: "check-b", checkTitle: "Check B" },
]);
});
it("requests the remaining pages concurrently once the page count is known", async () => {
// Given
const pending: Array<(value: unknown) => void> = [];
const fetchFindingGroups = vi.fn(
({ page }: { page: number }) =>
new Promise((resolve) => {
if (page === 1) {
resolve(makeResponse(3, [{ id: "check-a", title: "Check A" }]));
return;
}
pending.push(resolve);
}),
);
// When
const optionsPromise = getFindingGroupFilterOptions({
fetchFindingGroups,
filters: {},
});
await vi.waitFor(() => expect(fetchFindingGroups).toHaveBeenCalledTimes(3));
pending[0](makeResponse(3, [{ id: "check-b", title: "Check B" }]));
pending[1](makeResponse(3, [{ id: "check-c", title: "Check C" }]));
const options = await optionsPromise;
// Then
expect(
fetchFindingGroups.mock.calls.map(([params]) => params.page),
).toEqual([1, 2, 3]);
expect(options.map((option) => option.checkId)).toEqual([
"check-a",
"check-b",
"check-c",
]);
});
it("caps how many pages are in flight at the same time", async () => {
// Given
const resolvers: Array<() => void> = [];
const fetchFindingGroups = vi.fn(({ page }: { page: number }) => {
const response = makeResponse(12, [
{ id: `check-${page}`, title: `Check ${page}` },
]);
if (page === 1) return Promise.resolve(response);
return new Promise((resolve) => {
resolvers.push(() => resolve(response));
});
});
// When
const optionsPromise = getFindingGroupFilterOptions({
fetchFindingGroups,
filters: {},
});
await vi.waitFor(() => expect(fetchFindingGroups).toHaveBeenCalledTimes(5));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(fetchFindingGroups).toHaveBeenCalledTimes(5);
while (fetchFindingGroups.mock.calls.length < 12 || resolvers.length > 0) {
await vi.waitFor(() => expect(resolvers.length).toBeGreaterThan(0));
resolvers.pop()?.();
}
const options = await optionsPromise;
// Then
expect(fetchFindingGroups).toHaveBeenCalledTimes(12);
expect(options.map((option) => option.checkId)).toEqual(
Array.from({ length: 12 }, (_, index) => `check-${index + 1}`),
);
});
it("stops dequeuing pages once one of them rejects", async () => {
// Given
let rejectPage: (error: Error) => void = () => undefined;
const heldPages: Array<() => void> = [];
const fetchFindingGroups = vi.fn(({ page }: { page: number }) => {
const response = makeResponse(12, [
{ id: `check-${page}`, title: `Check ${page}` },
]);
if (page === 1) return Promise.resolve(response);
if (page === 2) {
return new Promise((_, reject) => {
rejectPage = reject;
});
}
return new Promise((resolve) => {
heldPages.push(() => resolve(response));
});
});
// When
const optionsPromise = getFindingGroupFilterOptions({
fetchFindingGroups,
filters: {},
});
await vi.waitFor(() => expect(fetchFindingGroups).toHaveBeenCalledTimes(5));
rejectPage(new Error("auth failed"));
await expect(optionsPromise).rejects.toThrow("auth failed");
// The other workers finish their current page after the failure.
heldPages.forEach((release) => release());
await new Promise((resolve) => setTimeout(resolve, 0));
// Then
expect(fetchFindingGroups).toHaveBeenCalledTimes(5);
});
it("stops after the first page when the response has no pagination", async () => {
// Given
const fetchFindingGroups = vi.fn().mockResolvedValue(undefined);
// When
const options = await getFindingGroupFilterOptions({
fetchFindingGroups,
filters: {},
});
// Then
expect(fetchFindingGroups).toHaveBeenCalledTimes(1);
expect(options).toEqual([]);
});
});
describe("getSelectedFindingCheckOptions", () => {
it("resolves the selected titles in one request, replacing the filter's own selection", async () => {
// Given
const fetchFindingGroups = vi
.fn()
.mockResolvedValue(
makeResponse(1, [{ id: "check-a", title: "Check A" }]),
);
// When
const options = await getSelectedFindingCheckOptions({
fetchFindingGroups,
filters: {
"filter[check_id__in]": "check-a",
"filter[severity__in]": "high",
},
selectedCheckIds: ["check-a", "check-b", "check-a"],
});
// Then
expect(fetchFindingGroups).toHaveBeenCalledTimes(1);
expect(fetchFindingGroups).toHaveBeenCalledWith({
filters: {
"filter[severity__in]": "high",
"filter[check_id__in]": "check-a,check-b",
},
page: 1,
pageSize: 100,
sort: "check_id",
});
expect(options).toEqual([{ checkId: "check-a", checkTitle: "Check A" }]);
});
it("requests nothing when no check is selected", async () => {
// Given
const fetchFindingGroups = vi.fn();
// When
const options = await getSelectedFindingCheckOptions({
fetchFindingGroups,
filters: {},
selectedCheckIds: [],
});
// Then
expect(fetchFindingGroups).not.toHaveBeenCalled();
expect(options).toEqual([]);
});
});
+90 -19
View File
@@ -1,24 +1,35 @@
import { adaptFindingGroupsResponse } from "@/actions/finding-groups/finding-groups.adapter";
const FINDING_GROUP_FILTER_OPTION_PAGE_SIZE = 100;
// Options only need a stable page order; the table's composite sort costs an
// extra aggregation per page on the API.
const FINDING_GROUP_FILTER_OPTION_SORT = "check_id";
// Each page can hit the raw findings aggregation, so bound the DB fan-out.
const FINDING_GROUP_FILTER_OPTION_CONCURRENCY = 4;
const FINDING_GROUP_OWN_FILTER_KEYS = new Set([
"filter[check_id]",
"filter[check_id__in]",
]);
type FindingGroupFilters = Record<string, string | string[] | undefined>;
interface FindingGroupFilterFetcherParams {
page: number;
pageSize: number;
filters: Record<string, string | string[] | undefined>;
sort: string;
filters: FindingGroupFilters;
}
type FindingGroupFilterFetcher = (
export type FindingGroupFilterFetcher = (
params: FindingGroupFilterFetcherParams,
) => Promise<unknown>;
function excludeFindingGroupOwnFilters(
filters: Record<string, string | string[] | undefined>,
) {
export interface FindingGroupCheckOption {
checkId: string;
checkTitle: string;
}
export function excludeFindingGroupOwnFilters(filters: FindingGroupFilters) {
return Object.fromEntries(
Object.entries(filters).filter(
([key]) => !FINDING_GROUP_OWN_FILTER_KEYS.has(key),
@@ -48,33 +59,93 @@ function getTotalPages(response: unknown, currentPage: number): number {
return typeof pagination.pages === "number" ? pagination.pages : currentPage;
}
function toCheckOptions(response: unknown): FindingGroupCheckOption[] {
return adaptFindingGroupsResponse(response).map((group) => ({
checkId: group.checkId,
checkTitle: group.checkTitle,
}));
}
/** Titles for the checks already selected in the URL: one request, none without a selection. */
export async function getSelectedFindingCheckOptions({
fetchFindingGroups,
filters,
selectedCheckIds,
}: {
fetchFindingGroups: FindingGroupFilterFetcher;
filters: FindingGroupFilters;
selectedCheckIds: string[];
}): Promise<FindingGroupCheckOption[]> {
const uniqueIds = Array.from(new Set(selectedCheckIds.filter(Boolean)));
if (uniqueIds.length === 0) return [];
const response = await fetchFindingGroups({
filters: {
...excludeFindingGroupOwnFilters(filters),
"filter[check_id__in]": uniqueIds.join(","),
},
page: 1,
pageSize: FINDING_GROUP_FILTER_OPTION_PAGE_SIZE,
sort: FINDING_GROUP_FILTER_OPTION_SORT,
});
return toCheckOptions(response);
}
/** Every check for the given filters, walking the pages a few at a time. */
export async function getFindingGroupFilterOptions({
fetchFindingGroups,
filters,
}: {
fetchFindingGroups: FindingGroupFilterFetcher;
filters: Record<string, string | string[] | undefined>;
}) {
filters: FindingGroupFilters;
}): Promise<FindingGroupCheckOption[]> {
const optionFilters = excludeFindingGroupOwnFilters(filters);
const options = new Map<string, { checkId: string; checkTitle: string }>();
let page = 1;
while (true) {
const response = await fetchFindingGroups({
const fetchPage = (page: number) =>
fetchFindingGroups({
filters: optionFilters,
page,
pageSize: FINDING_GROUP_FILTER_OPTION_PAGE_SIZE,
sort: FINDING_GROUP_FILTER_OPTION_SORT,
});
for (const group of adaptFindingGroupsResponse(response)) {
options.set(group.checkId, {
checkId: group.checkId,
checkTitle: group.checkTitle,
});
const firstPage = await fetchPage(1);
const totalPages = getTotalPages(firstPage, 1);
const pendingPages = Array.from(
{ length: Math.max(totalPages - 1, 0) },
(_, index) => index + 2,
);
const remainingPages: unknown[] = [];
// One rejection fails the whole walk, so the other workers stop dequeuing.
let failed = false;
const drainPendingPages = async () => {
while (!failed && pendingPages.length > 0) {
const page = pendingPages.shift() as number;
try {
remainingPages[page - 2] = await fetchPage(page);
} catch (error) {
failed = true;
throw error;
}
}
};
await Promise.all(
Array.from(
{
length: Math.min(
FINDING_GROUP_FILTER_OPTION_CONCURRENCY,
pendingPages.length,
),
},
drainPendingPages,
),
);
if (page >= getTotalPages(response, page)) break;
page += 1;
const options = new Map<string, FindingGroupCheckOption>();
for (const response of [firstPage, ...remainingPages]) {
for (const option of toCheckOptions(response)) {
options.set(option.checkId, option);
}
}
return Array.from(options.values());
+43 -6
View File
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getRegistryPresentation } from "./presentation";
import {
getRegistryPresentation,
readRegistryPresentation,
} from "./presentation";
describe("Registry presentation configuration", () => {
const urlWithCredentials = new URL("https://registry.test");
@@ -10,11 +13,11 @@ describe("Registry presentation configuration", () => {
it("uses the configured Registry and media origins", () => {
expect(
getRegistryPresentation(
"https://registry.private.test/keys",
"https://registry.private.test/",
"https://assets.private.test/media/",
),
).toEqual({
keyUrl: "https://registry.private.test/keys",
registryUrl: "https://registry.private.test/",
imageOrigins: [
"https://registry.private.test",
"https://assets.private.test",
@@ -24,7 +27,7 @@ describe("Registry presentation configuration", () => {
it("does not guess a Registry environment when configuration is missing", () => {
expect(getRegistryPresentation()).toEqual({
keyUrl: undefined,
registryUrl: undefined,
imageOrigins: [],
});
});
@@ -36,7 +39,41 @@ describe("Registry presentation configuration", () => {
"invalid",
])("rejects unsafe configuration: %s", (value) => {
expect(getRegistryPresentation(value, value)).toEqual({
keyUrl: undefined,
registryUrl: undefined,
imageOrigins: [],
});
});
});
describe("Registry presentation from the runtime environment", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("links to the Registry the backend installs from", () => {
// Given
vi.stubEnv("PROWLER_REGISTRY_INDEX_URL", "https://registry.internal.test");
vi.stubEnv("UI_REGISTRY_MEDIA_URL", "https://media.internal.test");
// When / Then
expect(readRegistryPresentation()).toEqual({
registryUrl: "https://registry.internal.test/",
imageOrigins: [
"https://registry.internal.test",
"https://media.internal.test",
],
});
});
it("ignores the retired UI_REGISTRY_URL variable", () => {
// Given
vi.stubEnv("PROWLER_REGISTRY_INDEX_URL", "");
vi.stubEnv("UI_REGISTRY_MEDIA_URL", "");
vi.stubEnv("UI_REGISTRY_URL", "https://registry.prowler.com");
// When / Then
expect(readRegistryPresentation()).toEqual({
registryUrl: undefined,
imageOrigins: [],
});
});
+11 -1
View File
@@ -1,3 +1,5 @@
import { readEnv } from "@/lib/runtime-env";
/** Accept public HTTP URLs only; never expose URL credentials or CSP syntax. */
function parsePublicUrl(value?: string | null): URL | undefined {
if (!value || /[\s;]/.test(value)) return;
@@ -21,7 +23,7 @@ export function getRegistryPresentation(
const registry = parsePublicUrl(registryUrl);
const media = parsePublicUrl(mediaUrl);
return {
keyUrl: registry?.href,
registryUrl: registry?.href,
imageOrigins: Array.from(
new Set(
[registry?.origin, media?.origin].filter((origin): origin is string =>
@@ -31,3 +33,11 @@ export function getRegistryPresentation(
),
};
}
/** Same Registry base URL the backend installs artifacts from. */
export function readRegistryPresentation() {
return getRegistryPresentation(
readEnv("PROWLER_REGISTRY_INDEX_URL"),
readEnv("UI_REGISTRY_MEDIA_URL"),
);
}
-3
View File
@@ -33,9 +33,6 @@ const BASELINE_CSP = {
],
"connect-src": [
"'self'",
"https://api.iconify.design",
"https://api.simplesvg.com",
"https://api.unisvg.com",
"https://js.stripe.com",
"https://www.googletagmanager.com",
"https://*.sentry.io",
-1
View File
@@ -121,7 +121,6 @@
"zustand": "5.0.8"
},
"devDependencies": {
"@iconify/react": "5.2.1",
"@next/eslint-plugin-next": "16.2.9",
"@playwright/test": "1.56.1",
"@testing-library/jest-dom": "6.9.1",
+1 -1
View File
@@ -25,7 +25,7 @@ const registryFixtureUiServer = (
UI_API_BASE_URL: registryFixtureApiUrl,
UI_CLOUD_ENABLED: String(cloudEnabled),
UI_REGISTRY_ENABLED: String(registryEnabled),
UI_REGISTRY_URL: "https://registry.dev.prowler.com",
PROWLER_REGISTRY_INDEX_URL: "https://registry.dev.prowler.com",
UI_REGISTRY_MEDIA_URL: "https://media.registry.dev.prowler.com",
CLOUD_BILLING_ENABLED: "false",
},
-13
View File
@@ -301,9 +301,6 @@ importers:
specifier: 5.0.8
version: 5.0.8(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7))
devDependencies:
'@iconify/react':
specifier: 5.2.1
version: 5.2.1(react@19.2.7)
'@next/eslint-plugin-next':
specifier: 16.2.9
version: 16.2.9
@@ -1094,11 +1091,6 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@iconify/react@5.2.1':
resolution: {integrity: sha512-37GDR3fYDZmnmUn9RagyaX+zca24jfVOMY8E1IXTqJuE8pxNtN51KWPQe3VODOWvuUurq7q9uUu3CFrpqj5Iqg==}
peerDependencies:
react: '>=16'
'@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
@@ -8308,11 +8300,6 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
'@iconify/react@5.2.1(react@19.2.7)':
dependencies:
'@iconify/types': 2.0.0
react: 19.2.7
'@iconify/types@2.0.0': {}
'@iconify/utils@3.1.0':
+2 -5
View File
@@ -14,7 +14,7 @@ import {
} from "@/lib/integrations/slack-connect-status";
import { REGISTRY_ACCESS } from "@/lib/registry/access";
import { evaluateRegistryAccess } from "@/lib/registry/access.server";
import { getRegistryPresentation } from "@/lib/registry/presentation";
import { readRegistryPresentation } from "@/lib/registry/presentation";
import { readEnv } from "@/lib/runtime-env";
import { isCloud } from "@/lib/shared/env";
import { copyAttributionParams } from "@/lib/utm";
@@ -38,10 +38,7 @@ const withSecurityHeaders = (response: NextResponse): NextResponse => {
"Content-Security-Policy",
getCspHeader({
cloudEnabled: isCloud(),
registryImageOrigins: getRegistryPresentation(
readEnv("UI_REGISTRY_URL"),
readEnv("UI_REGISTRY_MEDIA_URL"),
).imageOrigins,
registryImageOrigins: readRegistryPresentation().imageOrigins,
posthogEnabled: isGatedIntegrationEnabled(GATED_INTEGRATIONS.posthog),
posthogKey: readGatedEnv(
"UI_POSTHOG_ENABLED",
+8 -17
View File
@@ -107,17 +107,12 @@ export async function addAWSProvider(
// Select AWS provider
await providersPage.selectAWSProvider();
// Fill provider details
await providersPage.fillAWSProviderDetails(awsProviderData);
await providersPage.clickNext();
// Verify credentials page is loaded
await providersPage.verifyCredentialsPageLoaded();
// Select static credentials type
await providersPage.selectCredentialsType(
// AWS registers the account and its credentials in a single step
await providersPage.selectAwsAccessMethod(
AWS_CREDENTIAL_OPTIONS.AWS_CREDENTIALS,
);
await providersPage.fillAWSProviderDetails(awsProviderData);
// Fill static credentials
await providersPage.fillStaticCredentials(staticCredentials);
await providersPage.clickNext();
@@ -193,14 +188,10 @@ export async function deleteProviderIfExists(
await expect(deleteMenuItem).toBeVisible({ timeout: 5000 });
await deleteMenuItem.click();
// Wait for confirmation modal to appear. Exclude the Next.js dev error
// overlay, which is also role="dialog" and would otherwise be matched first,
// making the assertion wait on the wrong (hidden) element.
const modal = page.page
.locator(
'[role="dialog"]:not([data-nextjs-dialog="true"]), .modal, [data-testid*="modal"]',
)
.first();
// Match the delete dialog by name; other dialogs (Lighthouse callout, Next.js overlay) may be open
const modal = page.page.getByRole("dialog", {
name: "Are you absolutely sure?",
});
await expect(modal).toBeVisible({ timeout: 10000 });
+6 -6
View File
@@ -31,14 +31,14 @@ See [the scenario catalog](registry.md) and [the Add Provider tour report](add-p
Set these runtime variables on the UI service to match the Registry used by the backend:
| Variable | Purpose | Development Example |
| ----------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
| `UI_REGISTRY_URL` | Public Registry website or key-management page. Supplies the help link and permits images from its origin. | `https://registry.dev.prowler.com` |
| `UI_REGISTRY_MEDIA_URL` | Registry media service. Only its HTTP(S) origin is added to `img-src`. | `https://media.registry.dev.prowler.com` |
| Variable | Purpose | Development Example |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
| `PROWLER_REGISTRY_INDEX_URL` | Registry base URL shared with the backend installer. Supplies the help links and permits images from its origin. | `https://registry.dev.prowler.com` |
| `UI_REGISTRY_MEDIA_URL` | Registry media service. Only its HTTP(S) origin is added to `img-src`. | `https://media.registry.dev.prowler.com` |
For production, use `https://registry.prowler.com` and `https://media.registry.prowler.com`. For a private Registry, use its website and media service URLs. These settings do not change the backend's Registry API endpoint. Keep both services aligned in deployment configuration: the current backend contract does not expose its Registry website URL to the UI.
For production, use `https://registry.prowler.com` and `https://media.registry.prowler.com`. For a private Registry, use its website and media service URLs. `PROWLER_REGISTRY_INDEX_URL` is the same variable the backend reads, so one value in the shared `.env` configures both services.
The help link is hidden when its URL is missing or invalid, so the UI cannot send a private Registry user to production by default. URLs containing credentials, non-HTTP schemes, or CSP separators are rejected. Unconfigured external images fall back to the owner initial.
The help links are hidden when the URL is missing or invalid, so the UI cannot send a private Registry user to production by default. URLs containing credentials, non-HTTP schemes, or CSP separators are rejected. Unconfigured external images fall back to the owner initial.
Acceptance profiles and fixture servers live in `playwright.registry.config.ts`, with common defaults in `playwright.base.ts`. The existing `pnpm run test:e2e:registry` command selects that configuration. The general Playwright configuration runs the ordinary suites without Registry fixtures.
+4 -2
View File
@@ -32,7 +32,8 @@ export class ScansPage extends BasePage {
this.launchScanButton = page
.getByRole("group", { name: /scan tabs/i })
.getByRole("button", { name: /^Launch Scan$/i });
this.launchScanDialog = page.getByRole("dialog");
// By name: in Cloud the Lighthouse callout is also a dialog
this.launchScanDialog = page.getByRole("dialog", { name: "Launch A Scan" });
// The modal renders the providers picker as the shared MultiSelect-based
// AccountsSelector (used in single-select mode via closeOnSelect). Scoping
// to the dialog avoids matching the search combobox that appears in the
@@ -68,7 +69,8 @@ export class ScansPage extends BasePage {
});
// Main content elements
this.scanTable = page.locator("table");
// getByRole skips the hidden <table> shells React leaves while streaming rows
this.scanTable = page.getByRole("table");
// The scans view renders each tab with its own empty state, so a <table>
// is NOT guaranteed (an empty tab shows a NoScansEmptyState card instead).
// The tabs group is always present once providers exist, so it is the
+3
View File
@@ -31,6 +31,9 @@ declare global {
// Prowler Cloud deployment flag — runtime read (server env, client island).
UI_CLOUD_ENABLED?: "true" | "false";
UI_REGISTRY_ENABLED?: "true" | "false";
// Registry base URL, shared with the backend installer.
PROWLER_REGISTRY_INDEX_URL?: string;
UI_REGISTRY_MEDIA_URL?: string;
CLOUD_BILLING_ENABLED?: "legacy" | "metronome" | "false";
+3
View File
@@ -31,6 +31,9 @@ export interface FilterOption {
showSelectAll?: boolean;
defaultToSelectAll?: boolean;
defaultValues?: string[];
/** Called each time the dropdown opens, so values can load lazily. */
onOpen?: () => void;
isLoading?: boolean;
}
export interface CustomDropdownFilterProps {
-1
View File
@@ -147,7 +147,6 @@ export default defineConfig(() => {
"next-themes",
// App component lib
"@iconify/react",
"react-day-picker",
"posthog-js",
"posthog-js/react",