mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
499fd637f3 | ||
|
|
60b936005c | ||
|
|
03502c2426 | ||
|
|
e6320b178a | ||
|
|
4195a4f818 | ||
|
|
8d003c60d0 | ||
|
|
d5136f364c | ||
|
|
453c953f37 | ||
|
|
c114aa304b |
@@ -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
@@ -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)",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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'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",
|
||||
() => ({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 />}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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};
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
Generated
-13
@@ -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
@@ -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
@@ -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 });
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+3
@@ -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";
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -147,7 +147,6 @@ export default defineConfig(() => {
|
||||
"next-themes",
|
||||
|
||||
// App component lib
|
||||
"@iconify/react",
|
||||
"react-day-picker",
|
||||
"posthog-js",
|
||||
"posthog-js/react",
|
||||
|
||||
Reference in New Issue
Block a user