Compare commits

...
Author SHA1 Message Date
Prowler BotandPedro Martín cca0d283f6 chore(changelog): prepare for 5.32.1 (#11857)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2026-07-06 17:37:23 +02:00
3935466e63 fix: handle invitations in social and SAML auth (#11852)
Co-authored-by: Adrián Peña <adrianjpr@gmail.com>
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2026-07-06 16:20:13 +02:00
Prowler BotandPedro Martín 2745ad3d01 chore(changelog): prepare for 5.32.1 (#11855)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2026-07-06 16:04:21 +02:00
Prowler BotandAlejandro Bailo 266ee5a6c6 fix(ui): enable triage editing in compliance findings table (#11847)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
2026-07-06 13:44:48 +02:00
33ec0a8ad3 fix(api): restrict user profile updates to self (#11833)
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
Co-authored-by: Josema Camacho <josema@prowler.com>
2026-07-03 12:07:05 +01:00
8e56ee982a fix(compliance): skip MANUAL findings in section tally to avoid KeyError (#11831)
Co-authored-by: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com>
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-07-03 12:59:02 +02:00
Prowler BotandJosema Camacho d71f9c9af1 fix(api): add attack paths scan DB defaults (#11830)
Co-authored-by: Josema Camacho <josema@prowler.com>
2026-07-03 11:55:39 +02:00
Prowler Botandprowler-bot 3ff4aacd8d chore(release): Bump versions to v5.32.1 (#11819)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-07-02 17:22:39 +02:00
Prowler Botandprowler-bot 447bbd5777 chore(api): Update prowler dependency to v5.32 for release 5.32.0 (#11806)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-07-02 13:59:39 +02:00
49 changed files with 1988 additions and 395 deletions
+1 -1
View File
@@ -157,7 +157,7 @@ SENTRY_RELEASE=local
# REO_DEV_CLIENT_ID=
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.32.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.32.1
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
+13 -3
View File
@@ -2,6 +2,19 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.33.1] (Prowler v5.32.1)
### 🐞 Fixed
- Attack Paths: Scan rows now have database defaults for `is_migrated` and `sink_backend` so `scan-perform-scheduled` inserts survive deploy skew [(#11826)](https://github.com/prowler-cloud/prowler/pull/11826)
- Invited users now keep their invitation context when completing authentication with Google, GitHub, or SAML, so the invitation is accepted during login [(#11752)](https://github.com/prowler-cloud/prowler/pull/11752)
### 🔐 Security
- User profile updates now allow users to update their own account while requiring user-management permissions to update other users in the same tenant [(#11792)](https://github.com/prowler-cloud/prowler/pull/11792)
---
## [1.33.0] (Prowler v5.32.0)
### 🚀 Added
@@ -19,9 +32,6 @@ All notable changes to the **Prowler API** are documented in this file.
- Attack Paths: Provider graph cleanup now deletes Neo4j and Neptune relationships in directed batches before deleting nodes [(#11755)](https://github.com/prowler-cloud/prowler/pull/11755)
- `scan-perform` no longer reports an error when a provider is deleted during a running scan [(#11696)](https://github.com/prowler-cloud/prowler/pull/11696)
---
## [1.32.1] (Prowler v5.31.1)
### 🐞 Fixed
+2 -2
View File
@@ -45,7 +45,7 @@ dependencies = [
"gunicorn==26.0.0",
"uvloop==0.22.1",
"lxml==6.1.0",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.32",
"psycopg2-binary==2.9.9",
"pytest-celery[redis] (==1.3.0)",
"sentry-sdk[django] (==2.56.0)",
@@ -71,7 +71,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.33.0"
version = "1.33.1"
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
# target-version tracks this project's lowest supported Python.
+47 -21
View File
@@ -9,6 +9,7 @@ from api.models import (
User,
UserRoleRelationship,
)
from api.utils import accept_invitation_for_user
from django.db import transaction
@@ -20,6 +21,22 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
except User.DoesNotExist:
return None
@staticmethod
def _get_invitation_token(request):
for source_name in ("data", "POST"):
data = getattr(request, source_name, None) or {}
if not hasattr(data, "get"):
continue
invitation_token = data.get("invitation_token")
if invitation_token:
return invitation_token
wrapped_request = getattr(request, "_request", None)
if wrapped_request and wrapped_request is not request:
return ProwlerSocialAccountAdapter._get_invitation_token(wrapped_request)
return None
def pre_social_login(self, request, sociallogin):
# Link existing accounts with the same email address
email = sociallogin.account.extra_data.get("email")
@@ -83,29 +100,38 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
user.name = social_account_name
user.save(using=MainRouter.admin_db)
tenant = Tenant.objects.using(MainRouter.admin_db).create(
name=f"{user.email.split('@')[0]} default tenant"
)
with rls_transaction(str(tenant.id)):
Membership.objects.using(MainRouter.admin_db).create(
user=user, tenant=tenant, role=Membership.RoleChoices.OWNER
)
role = Role.objects.using(MainRouter.admin_db).create(
name="admin",
tenant_id=tenant.id,
manage_users=True,
manage_account=True,
manage_billing=True,
manage_providers=True,
manage_integrations=True,
manage_scans=True,
unlimited_visibility=True,
)
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
invitation_token = self._get_invitation_token(request)
if invitation_token:
invitation, _ = accept_invitation_for_user(
user=user,
role=role,
tenant_id=tenant.id,
invitation_token=invitation_token,
)
request.prowler_invitation_token = invitation_token
request.prowler_invitation_tenant_id = str(invitation.tenant_id)
else:
tenant = Tenant.objects.using(MainRouter.admin_db).create(
name=f"{user.email.split('@')[0]} default tenant"
)
with rls_transaction(str(tenant.id)):
Membership.objects.using(MainRouter.admin_db).create(
user=user, tenant=tenant, role=Membership.RoleChoices.OWNER
)
role = Role.objects.using(MainRouter.admin_db).create(
name="admin",
tenant_id=tenant.id,
manage_users=True,
manage_account=True,
manage_billing=True,
manage_providers=True,
manage_integrations=True,
manage_scans=True,
unlimited_visibility=True,
)
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
user=user,
role=role,
tenant_id=tenant.id,
)
else:
request.session["saml_user_created"] = str(user.id)
@@ -0,0 +1,25 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("api", "0096_attack_paths_scan_is_migrated"),
]
operations = [
migrations.AlterField(
model_name="attackpathsscan",
name="is_migrated",
field=models.BooleanField(db_default=False, default=False),
),
migrations.AlterField(
model_name="attackpathsscan",
name="sink_backend",
field=models.CharField(
choices=[("neo4j", "Neo4j"), ("neptune", "Neptune")],
db_default="neo4j",
default="neo4j",
max_length=16,
),
),
]
+2 -1
View File
@@ -814,9 +814,10 @@ class AttackPathsScan(RowLevelSecurityProtectedModel):
# still using the previous graph shape. Query catalog selection uses this
# flag; physical read routing uses sink_backend below.
# TODO: drop after Neptune cutover
is_migrated = models.BooleanField(default=False)
is_migrated = models.BooleanField(default=False, db_default=False)
sink_backend = models.CharField(
choices=SinkBackendChoices.choices,
db_default=SinkBackendChoices.NEO4J,
default=SinkBackendChoices.NEO4J,
max_length=16,
)
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.33.0
version: 1.33.1
description: |-
Prowler API specification.
+39 -1
View File
@@ -5,7 +5,7 @@ import pytest
from allauth.socialaccount.models import SocialLogin
from api.adapters import ProwlerSocialAccountAdapter
from api.db_router import MainRouter
from api.models import SAMLConfiguration
from api.models import Invitation, Membership, SAMLConfiguration, Tenant
from django.contrib.auth import get_user_model
User = get_user_model()
@@ -188,6 +188,44 @@ class TestProwlerSocialAccountAdapter:
_, called_user = call_args[0]
assert called_user.email == create_test_user.email
def test_save_user_social_with_invitation_joins_invited_tenant(
self, rf, create_test_user, tenants_fixture
):
adapter = ProwlerSocialAccountAdapter()
invited_tenant = tenants_fixture[2]
invited_email = "frank-invited@example.com"
invitation = Invitation.objects.create(
tenant=invited_tenant,
email=invited_email,
inviter=create_test_user,
)
request = rf.post("/", data={"invitation_token": invitation.token})
request.session = {}
sociallogin = MagicMock(spec=SocialLogin)
sociallogin.provider = MagicMock()
sociallogin.provider.id = "google"
sociallogin.account = MagicMock()
sociallogin.account.extra_data = {"name": "Frank"}
real_user = User.objects.create_user(
name="Frank", email=invited_email, password="Secret123!"
)
tenants_before = Tenant.objects.count()
with patch("api.adapters.super") as mock_super:
mock_super.return_value.save_user.return_value = real_user
adapter.save_user(request, sociallogin)
invitation.refresh_from_db()
assert invitation.state == Invitation.State.ACCEPTED
assert Tenant.objects.count() == tenants_before
assert Membership.objects.filter(
user=real_user,
tenant=invited_tenant,
role=Membership.RoleChoices.MEMBER,
).exists()
def test_save_user_saml_sets_session_flag(self, rf):
adapter = ProwlerSocialAccountAdapter()
request = rf.get("/")
+72 -10
View File
@@ -103,20 +103,84 @@ class TestUserViewSet:
assert response.json()["data"]["attributes"]["name"] == "Updated Name"
def test_partial_update_user_with_no_permissions(
self, authenticated_client_no_permissions_rbac, create_test_user
self, authenticated_client_no_permissions_rbac, create_test_user_rbac_limited
):
updated_data = {
"data": {
"type": "users",
"id": str(create_test_user_rbac_limited.id),
"attributes": {"name": "Updated Name"},
}
}
response = authenticated_client_no_permissions_rbac.patch(
reverse("user-detail", kwargs={"pk": create_test_user.id}),
reverse("user-detail", kwargs={"pk": create_test_user_rbac_limited.id}),
data=updated_data,
format="vnd.api+json",
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.status_code == status.HTTP_200_OK
assert response.json()["data"]["attributes"]["name"] == "Updated Name"
def test_partial_update_other_user_with_no_permissions_denied(
self, authenticated_client_no_permissions_rbac, tenants_fixture
):
original_email = "target-rbac-update@example.com"
original_password = "OriginalPassword123@"
target_user = User.objects.create_user(
name="target_rbac_update",
email=original_email,
password=original_password,
)
Membership.objects.create(user=target_user, tenant=tenants_fixture[0])
updated_data = {
"data": {
"type": "users",
"id": str(target_user.id),
"attributes": {
"email": "updated-target-rbac@example.com",
"password": "UpdatedPassword123@",
},
}
}
response = authenticated_client_no_permissions_rbac.patch(
reverse("user-detail", kwargs={"pk": target_user.id}),
data=updated_data,
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
target_user.refresh_from_db()
assert target_user.email == original_email
assert target_user.check_password(original_password)
def test_partial_update_other_user_with_manage_users_allowed(
self, authenticated_client_rbac_manage_users_only
):
user = authenticated_client_rbac_manage_users_only.user
tenant = Membership.objects.filter(user=user).first().tenant
target_user = User.objects.create_user(
name="target_manage_users_update",
email="target-manage-users-update@example.com",
password="Password123@",
)
Membership.objects.create(user=target_user, tenant=tenant)
updated_data = {
"data": {
"type": "users",
"id": str(target_user.id),
"attributes": {"name": "Updated Target Name"},
}
}
response = authenticated_client_rbac_manage_users_only.patch(
reverse("user-detail", kwargs={"pk": target_user.id}),
data=updated_data,
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_200_OK
target_user.refresh_from_db()
assert target_user.name == "Updated Target Name"
def test_delete_user_with_all_permissions(
self, authenticated_client_rbac, create_test_user_rbac
@@ -540,9 +604,7 @@ class TestLimitedVisibility:
TEST_PASSWORD = "Thisisapassword123@"
@pytest.fixture
def limited_admin_user(
self, django_db_setup, django_db_blocker, tenants_fixture, providers_fixture
):
def limited_admin_user(self, django_db_blocker, tenants_fixture, providers_fixture):
with django_db_blocker.unblock():
tenant = tenants_fixture[0]
provider = providers_fixture[0]
@@ -626,10 +688,10 @@ class TestLimitedVisibility:
response.json()["data"]["relationships"]["providers"]["meta"]["count"] == 1
)
@pytest.mark.usefixtures("scan_summaries_fixture")
def test_overviews_providers(
self,
authenticated_client_rbac_limited,
scan_summaries_fixture,
providers_fixture,
):
# By default, the associated provider is the one which has the overview data
@@ -648,6 +710,7 @@ class TestLimitedVisibility:
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == 0
@pytest.mark.usefixtures("scan_summaries_fixture")
@pytest.mark.parametrize(
"endpoint_name",
[
@@ -659,7 +722,6 @@ class TestLimitedVisibility:
self,
endpoint_name,
authenticated_client_rbac_limited,
scan_summaries_fixture,
providers_fixture,
):
# By default, the associated provider is the one which has the overview data
@@ -684,10 +746,10 @@ class TestLimitedVisibility:
data = response.json()["data"]["attributes"].values()
assert all(value == 0 for value in data)
@pytest.mark.usefixtures("scan_summaries_fixture")
def test_overviews_services(
self,
authenticated_client_rbac_limited,
scan_summaries_fixture,
providers_fixture,
):
# By default, the associated provider is the one which has the overview data
+210 -71
View File
@@ -59,7 +59,11 @@ from api.models import (
from api.rls import Tenant
from api.uuid_utils import datetime_to_uuid7
from api.v1.serializers import TokenSerializer
from api.v1.views import ComplianceOverviewViewSet, TenantFinishACSView
from api.v1.views import (
ComplianceOverviewViewSet,
CustomSAMLLoginView,
TenantFinishACSView,
)
from botocore.exceptions import ClientError, NoCredentialsError
from conftest import (
API_JSON_CONTENT_TYPE,
@@ -244,6 +248,63 @@ class TestUserViewSet:
create_test_user.refresh_from_db()
assert create_test_user.company_name == new_company_name
def test_users_partial_update_same_tenant_other_user_password_denied(
self, authenticated_client_no_permissions_rbac, tenants_fixture
):
original_password = "OriginalPassword123@"
new_password = "UpdatedPassword123@"
target_user = User.objects.create_user(
password=original_password,
email="target-password-update@example.com",
)
Membership.objects.create(user=target_user, tenant=tenants_fixture[0])
payload = {
"data": {
"type": "users",
"id": str(target_user.id),
"attributes": {"password": new_password},
},
}
response = authenticated_client_no_permissions_rbac.patch(
reverse("user-detail", kwargs={"pk": target_user.id}),
data=payload,
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
target_user.refresh_from_db()
assert target_user.check_password(original_password)
assert not target_user.check_password(new_password)
def test_users_partial_update_same_tenant_other_user_email_denied(
self, authenticated_client_no_permissions_rbac, tenants_fixture
):
original_email = "target-email-update@example.com"
new_email = "updated-target-email@example.com"
target_user = User.objects.create_user(
password="OriginalPassword123@",
email=original_email,
)
Membership.objects.create(user=target_user, tenant=tenants_fixture[0])
payload = {
"data": {
"type": "users",
"id": str(target_user.id),
"attributes": {"email": new_email},
},
}
response = authenticated_client_no_permissions_rbac.patch(
reverse("user-detail", kwargs={"pk": target_user.id}),
data=payload,
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
target_user.refresh_from_db()
assert target_user.email == original_email
def test_users_partial_update_invalid_content_type(
self, authenticated_client, create_test_user
):
@@ -1491,13 +1552,13 @@ class TestProviderViewSet:
("provider_groups", ["provider-groups"]),
],
)
@pytest.mark.usefixtures("create_provider_group_relationship")
def test_providers_list_include(
self,
include_values,
expected_resources,
authenticated_client,
providers_fixture,
create_provider_group_relationship,
):
response = authenticated_client.get(
reverse("provider-list"), {"include": include_values}
@@ -3542,7 +3603,7 @@ class TestScanViewSet:
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.parametrize(
"scan_json_payload, expected_scanner_args",
"scan_json_payload, _expected_scanner_args",
[
# Case 1: No scanner_args in payload (should use provider's scanner_args)
(
@@ -3591,7 +3652,7 @@ class TestScanViewSet:
mock_task_get,
authenticated_client,
scan_json_payload,
expected_scanner_args,
_expected_scanner_args,
providers_fixture,
tasks_fixture,
):
@@ -4070,7 +4131,7 @@ class TestScanViewSet:
monkeypatch.setattr(
"api.v1.views.env",
type("env", (), {"str": lambda self, *args, **kwargs: "test-bucket"})(),
type("env", (), {"str": lambda self, *_args, **_kwargs: "test-bucket"})(),
)
presigned_url = (
@@ -4176,7 +4237,7 @@ class TestScanViewSet:
monkeypatch.setattr(
"api.v1.views.TaskSerializer",
lambda *args, **kwargs: type("S", (), {"data": dummy}),
lambda *_args, **_kwargs: type("S", (), {"data": dummy}),
)
framework = get_compliance_frameworks(scan.provider.provider)[0]
@@ -4234,7 +4295,7 @@ class TestScanViewSet:
monkeypatch.setattr(
"api.v1.views.env",
type("env", (), {"str": lambda self, *args, **kwargs: "test-bucket"})(),
type("env", (), {"str": lambda self, *_args, **_kwargs: "test-bucket"})(),
)
match_key = "path/compliance/mitre_attack_aws.csv"
@@ -4245,6 +4306,7 @@ class TestScanViewSet:
class FakeS3Client:
def list_objects_v2(self, Bucket, Prefix):
del Prefix
return {"Contents": [{"Key": match_key}]}
def generate_presigned_url(self, ClientMethod, Params, ExpiresIn):
@@ -4276,7 +4338,7 @@ class TestScanViewSet:
monkeypatch.setattr(
"api.v1.views.env",
type("env", (), {"str": lambda self, *args, **kwargs: "test-bucket"})(),
type("env", (), {"str": lambda self, *_args, **_kwargs: "test-bucket"})(),
)
old_key = "path/compliance/prowler-output-aws-20240101000000_cis_1.4_aws.csv"
@@ -4284,6 +4346,7 @@ class TestScanViewSet:
class FakeS3Client:
def list_objects_v2(self, Bucket, Prefix):
del Prefix
return {
"Contents": [
{
@@ -4357,11 +4420,12 @@ class TestScanViewSet:
monkeypatch.setattr(
"api.v1.views.env",
type("env", (), {"str": lambda self, *args, **kwargs: "test-bucket"})(),
type("env", (), {"str": lambda self, *_args, **_kwargs: "test-bucket"})(),
)
class FakeS3Client:
def list_objects_v2(self, Bucket, Prefix):
del Prefix
return {"Contents": []}
def get_object(self, Bucket, Key):
@@ -4547,7 +4611,7 @@ class TestScanViewSet:
inserted_at=base + timedelta(hours=1)
)
mock_task_serializer.side_effect = lambda instance, *a, **k: SimpleNamespace(
mock_task_serializer.side_effect = lambda instance, *_a, **_k: SimpleNamespace(
data={"id": str(instance.id), "state": StateChoices.EXECUTING}
)
@@ -6279,9 +6343,8 @@ class TestResourceViewSet:
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_resources_metadata_retrieve(
self, authenticated_client, resources_fixture, backfill_scan_metadata_fixture
):
@pytest.mark.usefixtures("backfill_scan_metadata_fixture")
def test_resources_metadata_retrieve(self, authenticated_client, resources_fixture):
resource_1, *_ = resources_fixture
response = authenticated_client.get(
reverse("resource-metadata"),
@@ -6301,8 +6364,9 @@ class TestResourceViewSet:
assert set(data["data"]["attributes"]["types"]) == expected_resource_types
assert set(data["data"]["attributes"]["groups"]) == expected_groups
@pytest.mark.usefixtures("backfill_scan_metadata_fixture")
def test_resources_metadata_resource_filter_retrieve(
self, authenticated_client, resources_fixture, backfill_scan_metadata_fixture
self, authenticated_client, resources_fixture
):
resource_1, *_ = resources_fixture
response = authenticated_client.get(
@@ -7796,9 +7860,8 @@ class TestFindingViewSet:
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_findings_metadata_retrieve(
self, authenticated_client, findings_fixture, backfill_scan_metadata_fixture
):
@pytest.mark.usefixtures("backfill_scan_metadata_fixture")
def test_findings_metadata_retrieve(self, authenticated_client, findings_fixture):
finding_1, *_ = findings_fixture
response = authenticated_client.get(
reverse("finding-metadata"),
@@ -7821,8 +7884,9 @@ class TestFindingViewSet:
)
# assert data["data"]["attributes"]["tags"] == expected_tags
@pytest.mark.usefixtures("backfill_scan_metadata_fixture")
def test_findings_metadata_resource_filter_retrieve(
self, authenticated_client, findings_fixture, backfill_scan_metadata_fixture
self, authenticated_client, findings_fixture
):
finding_1, *_ = findings_fixture
response = authenticated_client.get(
@@ -8008,9 +8072,8 @@ class TestFindingViewSet:
attributes = response.json()["data"]["attributes"]
assert set(attributes["categories"]) == {"gen-ai", "security"}
def test_findings_metadata_latest_categories(
self, authenticated_client, latest_scan_finding_with_categories
):
@pytest.mark.usefixtures("latest_scan_finding_with_categories")
def test_findings_metadata_latest_categories(self, authenticated_client):
response = authenticated_client.get(
reverse("finding-metadata_latest"),
)
@@ -8018,9 +8081,8 @@ class TestFindingViewSet:
attributes = response.json()["data"]["attributes"]
assert set(attributes["categories"]) == {"gen-ai", "iam"}
def test_findings_metadata_latest_groups(
self, authenticated_client, latest_scan_finding_with_categories
):
@pytest.mark.usefixtures("latest_scan_finding_with_categories")
def test_findings_metadata_latest_groups(self, authenticated_client):
response = authenticated_client.get(
reverse("finding-metadata_latest"),
)
@@ -8540,16 +8602,14 @@ class TestInvitationViewSet:
expires_at=self.TOMORROW,
)
data = {
"invitation_token": invitation.token,
}
data = {"invitation_token": invitation.token}
assert not Membership.objects.filter(
user__email__iexact=user.email, tenant=tenant
).exists()
response = authenticated_client.post(
reverse("invitation-accept"), data=data, format="json"
reverse("invitation-accept"), data=data, format="vnd.api+json"
)
assert response.status_code == status.HTTP_201_CREATED
invitation.refresh_from_db()
@@ -8558,13 +8618,46 @@ class TestInvitationViewSet:
).exists()
assert invitation.state == Invitation.State.ACCEPTED.value
def test_invitations_accept_invitation_invalid_token(self, authenticated_client):
data = {
"invitation_token": "invalid_token",
}
def test_invitations_accept_invitation_existing_membership(
self,
authenticated_client,
create_test_user,
tenants_fixture,
):
*_, tenant = tenants_fixture
user = create_test_user
invitation = Invitation.objects.create(
tenant=tenant,
email=TEST_USER,
inviter=user,
expires_at=self.TOMORROW,
)
Membership.objects.create(user=user, tenant=tenant)
data = {"invitation_token": invitation.token}
response = authenticated_client.post(
reverse("invitation-accept"), data=data, format="json"
reverse("invitation-accept"),
data=data,
format="vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
invitation.refresh_from_db()
assert invitation.state == Invitation.State.ACCEPTED.value
assert (
Membership.objects.filter(
user__email__iexact=user.email, tenant=tenant
).count()
== 1
)
def test_invitations_accept_invitation_invalid_token(self, authenticated_client):
data = {"invitation_token": "invalid_token"}
response = authenticated_client.post(
reverse("invitation-accept"), data=data, format="vnd.api+json"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -8578,12 +8671,10 @@ class TestInvitationViewSet:
invitation.email = TEST_USER
invitation.save()
data = {
"invitation_token": invitation.token,
}
data = {"invitation_token": invitation.token}
response = authenticated_client.post(
reverse("invitation-accept"), data=data, format="json"
reverse("invitation-accept"), data=data, format="vnd.api+json"
)
assert response.status_code == status.HTTP_410_GONE
@@ -8619,12 +8710,10 @@ class TestInvitationViewSet:
invitation.email = TEST_USER
invitation.save()
data = {
"invitation_token": invitation.token,
}
data = {"invitation_token": invitation.token}
response = authenticated_client.post(
reverse("invitation-accept"), data=data, format="json"
reverse("invitation-accept"), data=data, format="vnd.api+json"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -8642,12 +8731,10 @@ class TestInvitationViewSet:
invitation.email = TEST_USER
invitation.save()
data = {
"invitation_token": invitation.token,
}
data = {"invitation_token": invitation.token}
response = authenticated_client.post(
reverse("invitation-accept"), data=data, format="json"
reverse("invitation-accept"), data=data, format="vnd.api+json"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -10723,9 +10810,8 @@ class TestOverviewViewSet:
response = authenticated_client.put(reverse("overview-list"))
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
def test_overview_providers_list(
self, authenticated_client, scan_summaries_fixture, resources_fixture
):
@pytest.mark.usefixtures("scan_summaries_fixture")
def test_overview_providers_list(self, authenticated_client, resources_fixture):
response = authenticated_client.get(reverse("overview-providers"))
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == 1
@@ -10736,10 +10822,10 @@ class TestOverviewViewSet:
# Aggregated resources include all AWS providers present in the tenant
assert response.json()["data"][0]["attributes"]["resources"]["total"] == 3
@pytest.mark.usefixtures("scan_summaries_fixture")
def test_overview_providers_aggregates_same_provider_type(
self,
authenticated_client,
scan_summaries_fixture,
resources_fixture,
providers_fixture,
tenants_fixture,
@@ -10790,10 +10876,10 @@ class TestOverviewViewSet:
assert attributes["findings"]["muted"] == 7
assert attributes["resources"]["total"] == 4
@pytest.mark.usefixtures("scan_summaries_fixture")
def test_overview_providers_count(
self,
authenticated_client,
scan_summaries_fixture,
resources_fixture,
providers_fixture,
tenants_fixture,
@@ -11259,15 +11345,15 @@ class TestOverviewViewSet:
assert data[0]["id"] == str(snapshot1.id)
assert data[0]["attributes"]["overall_score"] == "55.55"
def test_overview_services_list_no_required_filters(
self, authenticated_client, scan_summaries_fixture
):
@pytest.mark.usefixtures("scan_summaries_fixture")
def test_overview_services_list_no_required_filters(self, authenticated_client):
response = authenticated_client.get(reverse("overview-services"))
assert response.status_code == status.HTTP_200_OK
# Should return services from latest scans
assert len(response.json()["data"]) == 2
def test_overview_regions_list(self, authenticated_client, scan_summaries_fixture):
@pytest.mark.usefixtures("scan_summaries_fixture")
def test_overview_regions_list(self, authenticated_client):
response = authenticated_client.get(
reverse("overview-regions"), {"filter[inserted_at]": TODAY}
)
@@ -11293,7 +11379,8 @@ class TestOverviewViewSet:
assert regions["aws:region2"]["fail"] == 1
assert regions["aws:region2"]["muted"] == 3
def test_overview_services_list(self, authenticated_client, scan_summaries_fixture):
@pytest.mark.usefixtures("scan_summaries_fixture")
def test_overview_services_list(self, authenticated_client):
response = authenticated_client.get(
reverse("overview-services"), {"filter[inserted_at]": TODAY}
)
@@ -11941,9 +12028,8 @@ class TestOverviewViewSet:
assert results_by_type["internet-exposed"]["total_findings"] == 10
assert results_by_type["internet-exposed"]["failed_findings"] == 5
def test_overview_services_region_filter(
self, authenticated_client, scan_summaries_fixture
):
@pytest.mark.usefixtures("scan_summaries_fixture")
def test_overview_services_region_filter(self, authenticated_client):
response = authenticated_client.get(
reverse("overview-services"),
{"filter[region]": "region1"},
@@ -12011,7 +12097,7 @@ class TestOverviewViewSet:
assert "gcp-service" not in service_ids
@pytest.mark.parametrize(
"status_filter,field_to_check",
"status_filter,_field_to_check",
[
("FAIL", "fail"),
("PASS", "_pass"),
@@ -12023,7 +12109,7 @@ class TestOverviewViewSet:
tenants_fixture,
providers_fixture,
status_filter,
field_to_check,
_field_to_check,
):
tenant = tenants_fixture[0]
provider = providers_fixture[0]
@@ -12663,8 +12749,9 @@ class TestOverviewViewSet:
assert data[0]["attributes"]["new_failed_findings"] == 5
assert data[0]["attributes"]["resources_count"] == 10
@pytest.mark.usefixtures("tenant_compliance_summary_fixture")
def test_compliance_watchlist_no_filters_uses_tenant_summary(
self, authenticated_client, tenant_compliance_summary_fixture
self, authenticated_client
):
response = authenticated_client.get(reverse("overview-compliance-watchlist"))
assert response.status_code == status.HTTP_200_OK
@@ -12684,10 +12771,10 @@ class TestOverviewViewSet:
assert by_id["gdpr_aws"]["requirements_failed"] == 0
assert by_id["gdpr_aws"]["total_requirements"] == 7
@pytest.mark.usefixtures("provider_compliance_scores_fixture")
def test_compliance_watchlist_with_provider_filter_uses_provider_scores(
self,
authenticated_client,
provider_compliance_scores_fixture,
providers_fixture,
):
provider1 = providers_fixture[0]
@@ -12704,9 +12791,8 @@ class TestOverviewViewSet:
assert by_id["aws_cis_2.0"]["requirements_manual"] == 1
assert by_id["aws_cis_2.0"]["total_requirements"] == 3
def test_compliance_watchlist_fail_dominant_logic(
self, authenticated_client, provider_compliance_scores_fixture
):
@pytest.mark.usefixtures("provider_compliance_scores_fixture")
def test_compliance_watchlist_fail_dominant_logic(self, authenticated_client):
response = authenticated_client.get(
f"{reverse('overview-compliance-watchlist')}?filter[provider_type]=aws"
)
@@ -12721,10 +12807,10 @@ class TestOverviewViewSet:
assert aws_cis["requirements_manual"] == 1
assert aws_cis["total_requirements"] == 3
@pytest.mark.usefixtures("provider_compliance_scores_fixture")
def test_compliance_watchlist_provider_id_in_filter(
self,
authenticated_client,
provider_compliance_scores_fixture,
providers_fixture,
):
provider1, provider2, *_ = providers_fixture
@@ -12737,10 +12823,10 @@ class TestOverviewViewSet:
data = response.json()["data"]
assert len(data) >= 1
@pytest.mark.usefixtures("provider_compliance_scores_fixture")
def test_compliance_watchlist_provider_groups_filter(
self,
authenticated_client,
provider_compliance_scores_fixture,
providers_fixture,
provider_groups_fixture,
tenants_fixture,
@@ -13640,6 +13726,26 @@ class TestSAMLTokenValidation:
assert response2.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.django_db
class TestCustomSAMLLoginView:
def test_dispatch_clears_stale_callback_url_when_request_has_none(self):
request = RequestFactory().get("/api/v1/saml/login/testtenant/")
request.session = {
"saml_callback_url": "/invitation/accept?invitation_token=old-token"
}
with patch(
"allauth.socialaccount.providers.saml.views.LoginView.dispatch",
return_value=JsonResponse({}),
):
response = CustomSAMLLoginView.as_view()(
request, organization_slug="testtenant"
)
assert response.status_code == status.HTTP_200_OK
assert "saml_callback_url" not in request.session
@pytest.mark.django_db
class TestSAMLInitiateAPIView:
def test_valid_email_domain_and_certificates(
@@ -13651,7 +13757,7 @@ class TestSAMLInitiateAPIView:
url = reverse("api_saml_initiate")
payload = {"email_domain": saml_setup["email"]}
response = authenticated_client.post(url, data=payload, format="json")
response = authenticated_client.post(url, data=payload, format="vnd.api+json")
assert response.status_code == status.HTTP_302_FOUND
assert (
@@ -13660,11 +13766,42 @@ class TestSAMLInitiateAPIView:
)
assert "SAMLRequest" not in response.url
def test_valid_email_domain_preserves_safe_callback_url(
self, authenticated_client, saml_setup
):
url = reverse("api_saml_initiate")
callback_url = "/invitation/accept?invitation_token=test-token"
payload = {
"email_domain": saml_setup["email"],
"callback_url": callback_url,
}
response = authenticated_client.post(url, data=payload, format="vnd.api+json")
assert response.status_code == status.HTTP_302_FOUND
query_params = parse_qs(urlparse(response.url).query)
assert query_params["callback_url"] == [callback_url]
def test_valid_email_domain_rejects_external_callback_url(
self, authenticated_client, saml_setup
):
url = reverse("api_saml_initiate")
payload = {
"email_domain": saml_setup["email"],
"callback_url": "https://attacker.example/invitation",
}
response = authenticated_client.post(url, data=payload, format="vnd.api+json")
assert response.status_code == status.HTTP_302_FOUND
query_params = parse_qs(urlparse(response.url).query)
assert "callback_url" not in query_params
def test_invalid_email_domain(self, authenticated_client):
url = reverse("api_saml_initiate")
payload = {"email_domain": "user@unauthorized.com"}
response = authenticated_client.post(url, data=payload, format="json")
response = authenticated_client.post(url, data=payload, format="vnd.api+json")
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json()["errors"]["detail"] == "Unauthorized domain."
@@ -13847,7 +13984,8 @@ class TestTenantFinishACSView:
)
)
request.user = user
request.session = {}
callback_url = "/invitation/accept?invitation_token=test-token"
request.session = {"saml_callback_url": callback_url}
with (
patch(
@@ -13889,6 +14027,7 @@ class TestTenantFinishACSView:
assert parsed_url.netloc == expected_callback_host
query_params = parse_qs(parsed_url.query)
assert "id" in query_params
assert query_params["callbackUrl"] == [callback_url]
token_id = query_params["id"][0]
token_obj = SAMLToken.objects.get(id=token_id)
@@ -18292,10 +18431,10 @@ class TestFindingGroupViewSet:
],
ids=["summary_path", "finding_level_path"],
)
@pytest.mark.usefixtures("finding_groups_title_variants_fixture")
def test_check_title_icontains_includes_all_title_variants(
self,
authenticated_client,
finding_groups_title_variants_fixture,
extra_filters,
):
"""
+40 -1
View File
@@ -7,9 +7,19 @@ from allauth.socialaccount.providers.oauth2.client import OAuth2Client
from api.db_router import MainRouter
from api.db_utils import rls_transaction
from api.exceptions import InvitationTokenExpiredException
from api.models import Integration, Invitation, Processor, Provider, Resource
from api.models import (
Integration,
Invitation,
Membership,
Processor,
Provider,
Resource,
Role,
UserRoleRelationship,
)
from api.v1.serializers import FindingMetadataSerializer
from django.contrib.postgres.aggregates import ArrayAgg
from django.db import transaction
from django.db.models import Subquery
from prowler.lib.outputs.jira.jira import Jira, JiraBasicAuthError
from prowler.providers.aws.lib.s3.s3 import S3
@@ -538,6 +548,35 @@ def validate_invitation(
return invitation
def accept_invitation_for_user(
*, user, invitation_token: str, raise_not_found: bool = False
):
with transaction.atomic(using=MainRouter.admin_db):
invitation = validate_invitation(
invitation_token, user.email, raise_not_found=raise_not_found
)
with rls_transaction(str(invitation.tenant_id), using=MainRouter.admin_db):
membership, _ = Membership.objects.using(MainRouter.admin_db).get_or_create(
user=user,
tenant=invitation.tenant,
defaults={"role": Membership.RoleChoices.MEMBER},
)
invitation_roles = Role.objects.using(MainRouter.admin_db).filter(
invitations=invitation
)
for role in invitation_roles:
UserRoleRelationship.objects.using(MainRouter.admin_db).get_or_create(
user=user,
role=role,
defaults={"tenant": invitation.tenant},
)
invitation.state = Invitation.State.ACCEPTED
invitation.save(using=MainRouter.admin_db)
return invitation, membership
# ToRemove after removing the fallback mechanism in /findings/metadata
def get_findings_metadata_no_aggregations(tenant_id: str, filtered_queryset):
filtered_ids = filtered_queryset.order_by().values("id")
+3
View File
@@ -3147,6 +3147,9 @@ class ProcessorUpdateSerializer(BaseWriteSerializer):
class SamlInitiateSerializer(BaseSerializerV1):
email_domain = serializers.CharField()
callback_url = serializers.CharField(
required=False, allow_blank=True, max_length=2048
)
class JSONAPIMeta:
resource_name = "saml-initiate"
+91 -22
View File
@@ -9,7 +9,7 @@ from collections import defaultdict
from copy import deepcopy
from datetime import UTC, datetime, timedelta
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from urllib.parse import urljoin
from urllib.parse import urlencode, urljoin
import sentry_sdk
from allauth.socialaccount.models import SocialAccount, SocialApp
@@ -129,6 +129,7 @@ from api.renderers import APIJSONRenderer, PlainTextRenderer
from api.rls import Tenant
from api.utils import (
CustomOAuth2Client,
accept_invitation_for_user,
get_findings_metadata_no_aggregations,
initialize_prowler_integration,
initialize_prowler_provider,
@@ -542,6 +543,46 @@ class SchemaView(SpectacularAPIView):
return super().get(request, *args, **kwargs)
SAML_CALLBACK_SESSION_KEY = "saml_callback_url"
def _safe_callback_path(value):
if not value or not isinstance(value, str):
return None
if not value.startswith("/") or value.startswith("//"):
return None
return value
def _get_request_invitation_token(request):
for source_name in ("data", "POST"):
data = getattr(request, source_name, None) or {}
if not hasattr(data, "get"):
continue
invitation_token = data.get("invitation_token")
if invitation_token:
return invitation_token
wrapped_request = getattr(request, "_request", None)
if wrapped_request and wrapped_request is not request:
return _get_request_invitation_token(wrapped_request)
return None
def _accept_social_invitation(request, user):
invitation_token = _get_request_invitation_token(request)
tenant_id = getattr(request, "prowler_invitation_tenant_id", None)
if invitation_token and not tenant_id:
invitation, _ = accept_invitation_for_user(
user=user,
invitation_token=invitation_token,
raise_not_found=True,
)
tenant_id = str(invitation.tenant_id)
return tenant_id
@extend_schema(exclude=True)
class GoogleSocialLoginView(SocialLoginView):
adapter_class = GoogleOAuth2Adapter
@@ -552,7 +593,11 @@ class GoogleSocialLoginView(SocialLoginView):
original_response = super().get_response()
if self.user and self.user.is_authenticated:
serializer = TokenSocialLoginSerializer(data={"email": self.user.email})
tenant_id = _accept_social_invitation(self.request, self.user)
serializer_data = {"email": self.user.email}
if tenant_id:
serializer_data["tenant_id"] = tenant_id
serializer = TokenSocialLoginSerializer(data=serializer_data)
try:
serializer.is_valid(raise_exception=True)
except TokenError as e:
@@ -577,7 +622,11 @@ class GithubSocialLoginView(SocialLoginView):
original_response = super().get_response()
if self.user and self.user.is_authenticated:
serializer = TokenSocialLoginSerializer(data={"email": self.user.email})
tenant_id = _accept_social_invitation(self.request, self.user)
serializer_data = {"email": self.user.email}
if tenant_id:
serializer_data["tenant_id"] = tenant_id
serializer = TokenSocialLoginSerializer(data=serializer_data)
try:
serializer.is_valid(raise_exception=True)
@@ -637,6 +686,10 @@ class CustomSAMLLoginView(LoginView):
This approach maintains security while providing better UX.
"""
callback_url = _safe_callback_path(request.GET.get("callback_url"))
request.session.pop(SAML_CALLBACK_SESSION_KEY, None)
if callback_url:
request.session[SAML_CALLBACK_SESSION_KEY] = callback_url
if request.method == "GET":
# Convert GET to POST while preserving parameters
request.method = "POST"
@@ -681,6 +734,11 @@ class SAMLInitiateAPIView(GenericAPIView):
"saml_login", kwargs={"organization_slug": config.email_domain}
)
login_url = urljoin(api_host, login_path)
callback_url = _safe_callback_path(
serializer.validated_data.get("callback_url")
)
if callback_url:
login_url = f"{login_url}?{urlencode({'callback_url': callback_url})}"
return redirect(login_url)
@@ -896,7 +954,13 @@ class TenantFinishACSView(FinishACSView):
token=token_data, user=user
)
callback_url = env.str("SAML_SSO_CALLBACK_URL")
redirect_url = f"{callback_url}?id={saml_token.id}"
redirect_params = {"id": str(saml_token.id)}
saml_callback_url = _safe_callback_path(
request.session.pop(SAML_CALLBACK_SESSION_KEY, None)
)
if saml_callback_url:
redirect_params["callbackUrl"] = saml_callback_url
redirect_url = f"{callback_url}?{urlencode(redirect_params)}"
request.session.pop("saml_user_created", None)
return redirect(redirect_url)
@@ -948,8 +1012,8 @@ class UserViewSet(BaseUserViewset):
"""
Returns the required permissions based on the request method.
"""
if self.action == "me":
# No permissions required for me request
if self.action in ["me", "partial_update"]:
# No permissions required for me and partial_update requests
self.required_permissions = []
else:
# Require permission for the rest of the requests
@@ -1003,6 +1067,24 @@ class UserViewSet(BaseUserViewset):
status=status.HTTP_200_OK,
)
def partial_update(self, request, *args, **kwargs):
user = self.get_object()
if user.id != self.request.user.id:
role = get_role(self.request.user, self.request.tenant_id)
if not getattr(role, Permissions.MANAGE_USERS.value, False):
raise ValidationError(
"Only users with manage users permission can update other users."
)
serializer = self.get_serializer(user, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
self.perform_update(serializer)
if getattr(user, "_prefetched_objects_cache", None):
user._prefetched_objects_cache = {}
return Response(serializer.data)
def destroy(self, request, *args, **kwargs):
if kwargs["pk"] != str(self.request.user.id):
raise ValidationError("Only the current user can be deleted.")
@@ -4371,25 +4453,12 @@ class InvitationAcceptViewSet(BaseRLSViewSet):
invitation_token = serializer.validated_data["invitation_token"]
user_email = request.user.email
invitation = validate_invitation(
invitation_token, user_email, raise_not_found=True
)
# Proceed with accepting the invitation
user = User.objects.using(MainRouter.admin_db).get(email=user_email)
membership = Membership.objects.using(MainRouter.admin_db).create(
invitation, membership = accept_invitation_for_user(
user=user,
tenant=invitation.tenant,
invitation_token=invitation_token,
raise_not_found=True,
)
user_role = []
for role in invitation.roles.all():
user_role.append(
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
user=user, role=role, tenant=invitation.tenant
)
)
invitation.state = Invitation.State.ACCEPTED
invitation.save(using=MainRouter.admin_db)
self.response_serializer_class = MembershipSerializer
membership_serializer = self.get_serializer(membership)
@@ -2,8 +2,10 @@ from contextlib import nullcontext
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import MagicMock, call, patch
from uuid import uuid4
import pytest
from api.db_utils import rls_transaction
from api.models import (
AttackPathsScan,
Finding,
@@ -15,6 +17,7 @@ from api.models import (
StatusChoices,
Task,
)
from django.db import DEFAULT_DB_ALIAS
from django_celery_results.models import TaskResult
from prowler.lib.check.models import Severity
from tasks.jobs.attack_paths import findings as findings_module
@@ -2244,6 +2247,58 @@ class TestInternetAnalysis:
class TestAttackPathsDbUtilsGraphDataReady:
"""Tests for db_utils functions related to graph_data_ready lifecycle."""
def test_database_defaults_allow_legacy_insert_without_cutover_columns(
self, tenants_fixture, providers_fixture, scans_fixture
):
tenant = tenants_fixture[0]
provider = providers_fixture[0]
provider.provider = Provider.ProviderChoices.AWS
provider.save()
scan = scans_fixture[0]
scan.provider = provider
scan.save()
attack_paths_scan_id = uuid4()
now = datetime.now(tz=UTC)
with rls_transaction(str(tenant.id), using=DEFAULT_DB_ALIAS) as cursor:
cursor.execute(
"""
INSERT INTO attack_paths_scans (
id,
inserted_at,
updated_at,
state,
progress,
graph_data_ready,
started_at,
tenant_id,
provider_id,
scan_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
[
attack_paths_scan_id,
now,
now,
StateChoices.SCHEDULED,
0,
False,
now,
tenant.id,
provider.id,
scan.id,
],
)
attack_paths_scan = AttackPathsScan.objects.get(id=attack_paths_scan_id)
assert attack_paths_scan.is_migrated is False
assert (
attack_paths_scan.sink_backend == AttackPathsScan.SinkBackendChoices.NEO4J
)
def test_create_attack_paths_scan_first_scan_defaults_to_false(
self, tenants_fixture, providers_fixture, scans_fixture
):
Generated
+3 -3
View File
@@ -4674,7 +4674,7 @@ wheels = [
[[package]]
name = "prowler"
version = "5.32.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#5dac8a0a53272e4db68c476fb969dc03e88beb68" }
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.32#4ae7c67d3fc6ccf06296517c9d05824905718ede" }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },
{ name = "alibabacloud-credentials" },
@@ -4762,7 +4762,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.33.0"
version = "1.33.1"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
@@ -4862,7 +4862,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.32" },
{ name = "psycopg2-binary", specifier = "==2.9.9" },
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
{ name = "reportlab", specifier = "==4.4.10" },
@@ -40,6 +40,11 @@ Follow these steps to edit a user of your account:
<img src="/images/prowler-app/rbac/user_edit_details.png" alt="Edit User Details" width="700" />
<Note>
Users can edit their own account details. Editing another user's account details requires the **Invite and Manage Users** or **admin** permission.
</Note>
#### Removing a User
Follow these steps to remove a user of your account:
+8
View File
@@ -2,6 +2,14 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [5.32.1] (Prowler v5.32.1)
### 🐞 Fixed
- `KeyError: 'MANUAL'` crash while rendering the compliance summary table (e.g. CIS Microsoft 365) when a framework has manual, checks-less requirements with a Level 1/Level 2 profile; `MANUAL` findings are now skipped in the PASS/FAIL section tally instead of raising [(#11822)](https://github.com/prowler-cloud/prowler/issues/11822)
---
## [5.32.0] (Prowler v5.32.0)
### 🚀 Added
+1 -1
View File
@@ -49,7 +49,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
prowler_version = "5.32.0"
prowler_version = "5.32.1"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
@@ -395,6 +395,12 @@ def accumulate_group_status(
) -> None:
"""Count a finding once per group, upgrading a counted PASS to FAIL on conflict (mutates ``counts``/``seen``)."""
previous = seen.get(index)
if status == "MANUAL":
# MANUAL findings come from manual, checks-less requirements and are
# informational only: they have no PASS/FAIL/Muted column in the section
# tally, so counting them would raise KeyError on counts[status] += 1.
# Skip them (an unexpected status still raises loudly below).
return
if previous is None:
seen[index] = status
counts[status] += 1
+1 -1
View File
@@ -125,7 +125,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
name = "prowler"
readme = "README.md"
requires-python = ">=3.10,<3.14"
version = "5.32.0"
version = "5.32.1"
[project.scripts]
prowler = "prowler.__main__:prowler"
@@ -351,6 +351,37 @@ class Test_accumulate_group_status:
with pytest.raises(KeyError):
accumulate_group_status(0, "Muted", counts, {})
def test_manual_status_is_ignored_not_counted(self):
# A MANUAL finding (from a manual, checks-less requirement) has no
# PASS/FAIL/Muted column: it must be skipped, not raise KeyError, and
# not appear in the tally. Regression test for the M365 CIS compliance
# crash "KeyError: 'MANUAL'" (issue #11822).
counts = {"FAIL": 0, "PASS": 0}
seen = {}
accumulate_group_status(0, "MANUAL", counts, seen)
assert counts == {"FAIL": 0, "PASS": 0}
assert seen == {}
def test_manual_mixed_with_pass_and_fail(self):
# MANUAL findings interleaved with real PASS/FAIL ones only skip
# themselves; the PASS/FAIL tally is unaffected.
counts = {"FAIL": 0, "PASS": 0}
seen = {}
accumulate_group_status(0, "MANUAL", counts, seen)
accumulate_group_status(1, "PASS", counts, seen)
accumulate_group_status(2, "FAIL", counts, seen)
accumulate_group_status(3, "MANUAL", counts, seen)
assert counts == {"FAIL": 1, "PASS": 1}
def test_manual_ignored_on_counts_with_muted_key(self):
# MANUAL is skipped regardless of the counts shape (e.g. the universal
# table's PASS/FAIL/Muted buckets), never creating a "MANUAL" key.
counts = {"FAIL": 0, "PASS": 0, "Muted": 0}
seen = {}
accumulate_group_status(0, "MANUAL", counts, seen)
assert counts == {"FAIL": 0, "PASS": 0, "Muted": 0}
assert "MANUAL" not in counts
class Test_apply_config_status:
def test_none_config_status_keeps_finding(self):
+8
View File
@@ -2,6 +2,14 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.32.1] (Prowler v5.32.1)
### 🐞 Fixed
- Invitation callback paths are now preserved when invited users continue with Google, GitHub, or SAML authentication [(#11752)](https://github.com/prowler-cloud/prowler/pull/11752)
---
## [1.32.0] (Prowler v5.32.0)
### 🚀 Added
+7 -4
View File
@@ -166,8 +166,13 @@ export const deleteSamlConfig = async (id: string) => {
}
};
export const initiateSamlAuth = async (email: string) => {
export const initiateSamlAuth = async (email: string, callbackUrl = "/") => {
try {
const attributes = {
email_domain: email,
...(callbackUrl !== "/" && { callback_url: callbackUrl }),
};
const response = await fetch(`${apiBaseUrl}/auth/saml/initiate/`, {
method: "POST",
headers: {
@@ -176,9 +181,7 @@ export const initiateSamlAuth = async (email: string) => {
body: JSON.stringify({
data: {
type: "saml-initiate",
attributes: {
email_domain: email,
},
attributes,
},
}),
redirect: "manual",
@@ -13,6 +13,7 @@ const SignUp = async ({
typeof resolvedSearchParams?.invitation_token === "string"
? resolvedSearchParams.invitation_token
: null;
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const GOOGLE_AUTH_URL = getAuthUrl("google");
const GITHUB_AUTH_URL = getAuthUrl("github");
@@ -21,6 +22,7 @@ const SignUp = async ({
<AuthForm
type="sign-up"
invitationToken={invitationToken}
isCloudEnv={isCloudEnv}
googleAuthUrl={GOOGLE_AUTH_URL}
githubAuthUrl={GITHUB_AUTH_URL}
isGoogleOAuthEnabled={isGoogleOAuthEnabled}
@@ -7,7 +7,7 @@ import { ColumnLatestFindings } from "@/components/overview/new-findings-table/t
import { CardTitle } from "@/components/shadcn";
import { DataTable } from "@/components/ui/table";
import { FINDINGS_FILTERED_SORT, MUTED_FILTER } from "@/lib";
import { createDict } from "@/lib/helper";
import { createDict } from "@/lib/utils";
import { FindingProps, SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../_lib/filter-params";
@@ -45,7 +45,8 @@ export async function FindingsViewSSR({ searchParams }: FindingsViewSSRProps) {
const scan = scanDict[finding.relationships?.scan?.data?.id];
const resource =
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
const provider = providerDict[scan?.relationships?.provider?.data?.id];
const provider =
providerDict[scan?.relationships?.provider?.data?.id ?? ""];
return {
...finding,
@@ -594,22 +594,31 @@ const AlertFormModalContent = ({
{errors.root && (
<div className="text-text-error-primary text-sm">{errors.root}</div>
)}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
{/* mt-4 lifts the gap-4 container spacing to 32px so the distance to
the footer matches the launch scan and triage note modals. */}
<div className="mt-4 flex w-full justify-between gap-4">
<Button
variant="outline"
size="lg"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
{editingAlert && (
<Button
variant="outline"
onClick={handlePreview}
disabled={previewLoading || saving}
>
{previewLoading ? "Running..." : "Test"}
<div className="flex gap-4">
{editingAlert && (
<Button
variant="outline"
size="lg"
onClick={handlePreview}
disabled={previewLoading || saving}
>
{previewLoading ? "Running..." : "Test"}
</Button>
)}
<Button size="lg" onClick={handleSubmit} disabled={saving}>
{submitLabel}
</Button>
)}
<Button onClick={handleSubmit} disabled={saving}>
{submitLabel}
</Button>
</div>
</div>
</div>
</Modal>
+15 -5
View File
@@ -3,15 +3,24 @@
import { NextResponse } from "next/server";
import { signIn } from "@/auth.config";
import {
getInvitationTokenFromCallbackPath,
getSafeCallbackPath,
} from "@/lib/auth-callback-url";
import { apiBaseUrl, baseUrl } from "@/lib/helper";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const code = searchParams.get("code");
const callbackPath = getSafeCallbackPath(searchParams);
const invitationToken = getInvitationTokenFromCallbackPath(callbackPath);
const params = new URLSearchParams();
params.append("code", code || "");
if (invitationToken) {
params.append("invitation_token", invitationToken);
}
if (!code) {
return NextResponse.json(
@@ -37,18 +46,20 @@ export async function GET(req: Request) {
const { access, refresh } = data.data.attributes;
try {
// Invitation tokens are accepted during the social token exchange.
const redirectPath = invitationToken ? "/" : callbackPath;
const result = await signIn("social-oauth", {
accessToken: access,
refreshToken: refresh,
redirect: false,
callbackUrl: `${baseUrl}/`,
callbackUrl: new URL(redirectPath, baseUrl).toString(),
});
if (result?.error) {
throw new Error(result.error);
}
return NextResponse.redirect(new URL("/", baseUrl));
return NextResponse.redirect(new URL(redirectPath, baseUrl));
} catch (error) {
console.error("SignIn error:", error);
return NextResponse.redirect(
@@ -57,9 +68,8 @@ export async function GET(req: Request) {
}
} catch (error) {
console.error("Error in Github callback:", error);
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 },
return NextResponse.redirect(
new URL("/sign-in?error=AuthenticationFailed", baseUrl),
);
}
}
+15 -5
View File
@@ -3,15 +3,24 @@
import { NextResponse } from "next/server";
import { signIn } from "@/auth.config";
import {
getInvitationTokenFromCallbackPath,
getSafeCallbackPath,
} from "@/lib/auth-callback-url";
import { apiBaseUrl, baseUrl } from "@/lib/helper";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const code = searchParams.get("code");
const callbackPath = getSafeCallbackPath(searchParams);
const invitationToken = getInvitationTokenFromCallbackPath(callbackPath);
const params = new URLSearchParams();
params.append("code", code || "");
if (invitationToken) {
params.append("invitation_token", invitationToken);
}
if (!code) {
return NextResponse.json(
@@ -37,18 +46,20 @@ export async function GET(req: Request) {
const { access, refresh } = data.data.attributes;
try {
// Invitation tokens are accepted during the social token exchange.
const redirectPath = invitationToken ? "/" : callbackPath;
const result = await signIn("social-oauth", {
accessToken: access,
refreshToken: refresh,
redirect: false,
callbackUrl: `${baseUrl}/`,
callbackUrl: new URL(redirectPath, baseUrl).toString(),
});
if (result?.error) {
throw new Error(result.error);
}
return NextResponse.redirect(new URL("/", baseUrl));
return NextResponse.redirect(new URL(redirectPath, baseUrl));
} catch (error) {
console.error("SignIn error:", error);
return NextResponse.redirect(
@@ -57,9 +68,8 @@ export async function GET(req: Request) {
}
} catch (error) {
console.error("Error in Google callback:", error);
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 },
return NextResponse.redirect(
new URL("/sign-in?error=AuthenticationFailed", baseUrl),
);
}
}
+4 -2
View File
@@ -3,11 +3,13 @@
import { NextResponse } from "next/server";
import { signIn } from "@/auth.config";
import { getSafeCallbackPath } from "@/lib/auth-callback-url";
import { apiBaseUrl, baseUrl } from "@/lib/helper";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
const callbackPath = getSafeCallbackPath(searchParams, "callbackUrl");
if (!id) {
return NextResponse.json(
@@ -40,14 +42,14 @@ export async function GET(req: Request) {
accessToken: access,
refreshToken: refresh,
redirect: false,
callbackUrl: `${baseUrl}/`,
callbackUrl: new URL(callbackPath, baseUrl).toString(),
});
if (result?.error) {
throw new Error(result.error);
}
return NextResponse.redirect(new URL("/", baseUrl));
return NextResponse.redirect(new URL(callbackPath, baseUrl));
} catch (error) {
console.error("SAML authentication failed:", error);
return NextResponse.redirect(new URL("/sign-in", baseUrl));
+3
View File
@@ -4,6 +4,7 @@ import { SignUpForm } from "@/components/auth/oss/sign-up-form";
export const AuthForm = ({
type,
invitationToken,
isCloudEnv,
googleAuthUrl,
githubAuthUrl,
isGoogleOAuthEnabled,
@@ -11,6 +12,7 @@ export const AuthForm = ({
}: {
type: string;
invitationToken?: string | null;
isCloudEnv?: boolean;
googleAuthUrl?: string;
githubAuthUrl?: string;
isGoogleOAuthEnabled?: boolean;
@@ -30,6 +32,7 @@ export const AuthForm = ({
return (
<SignUpForm
invitationToken={invitationToken}
isCloudEnv={isCloudEnv}
googleAuthUrl={googleAuthUrl}
githubAuthUrl={githubAuthUrl}
isGoogleOAuthEnabled={isGoogleOAuthEnabled}
+4 -2
View File
@@ -16,6 +16,7 @@ import { Button } from "@/components/shadcn";
import { useToast } from "@/components/ui";
import { CustomInput } from "@/components/ui/custom";
import { Form } from "@/components/ui/form";
import { getSafeCallbackPath } from "@/lib/auth-callback-url";
import { SignInFormData, signInSchema } from "@/types";
export const SignInForm = ({
@@ -32,7 +33,7 @@ export const SignInForm = ({
const router = useRouter();
const searchParams = useSearchParams();
const { toast } = useToast();
const callbackUrl = searchParams.get("callbackUrl") || "/";
const callbackUrl = getSafeCallbackPath(searchParams, "callbackUrl");
useEffect(() => {
const samlError = searchParams.get("sso_saml_failed");
@@ -102,7 +103,7 @@ export const SignInForm = ({
form.setValue("password", "");
}
const result = await initiateSamlAuth(email);
const result = await initiateSamlAuth(email, callbackUrl);
if (result.success && result.redirectUrl) {
window.location.href = result.redirectUrl;
@@ -181,6 +182,7 @@ export const SignInForm = ({
<SocialButtons
googleAuthUrl={googleAuthUrl}
githubAuthUrl={githubAuthUrl}
callbackUrl={callbackUrl}
isGoogleOAuthEnabled={isGoogleOAuthEnabled}
isGithubOAuthEnabled={isGithubOAuthEnabled}
/>
+17 -3
View File
@@ -41,12 +41,14 @@ const FORM_ERROR_TYPE = {
export const SignUpForm = ({
invitationToken,
isCloudEnv,
googleAuthUrl,
githubAuthUrl,
isGoogleOAuthEnabled,
isGithubOAuthEnabled,
}: {
invitationToken?: string | null;
isCloudEnv?: boolean;
googleAuthUrl?: string;
githubAuthUrl?: string;
isGoogleOAuthEnabled?: boolean;
@@ -54,6 +56,9 @@ export const SignUpForm = ({
}) => {
const router = useRouter();
const { toast } = useToast();
const callbackUrl = invitationToken
? `/invitation/accept?invitation_token=${encodeURIComponent(invitationToken)}`
: "/";
const form = useForm<SignUpFormData>({
resolver: zodResolver(signUpSchema),
@@ -75,8 +80,14 @@ export const SignUpForm = ({
name: "password",
defaultValue: "",
});
const termsAccepted = useWatch({
control: form.control,
name: "termsAndConditions",
defaultValue: false,
});
const isLoading = form.formState.isSubmitting;
const isSocialAuthDisabled = Boolean(isCloudEnv && !termsAccepted);
const onSubmit = async (data: SignUpFormData) => {
const newUser = await createNewUser(data);
@@ -88,7 +99,7 @@ export const SignUpForm = ({
});
form.reset();
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
if (isCloudEnv) {
router.push("/email-verification");
} else {
router.push("/sign-in");
@@ -200,7 +211,7 @@ export const SignUpForm = ({
/>
)}
{process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true" && (
{isCloudEnv && (
<FormField
control={form.control}
name="termsAndConditions"
@@ -243,15 +254,18 @@ export const SignUpForm = ({
</form>
</Form>
{!invitationToken && (
{(!invitationToken || isCloudEnv) && (
<>
<AuthDivider />
<div className="flex flex-col gap-2">
<SocialButtons
googleAuthUrl={googleAuthUrl}
githubAuthUrl={githubAuthUrl}
callbackUrl={callbackUrl}
isGoogleOAuthEnabled={isGoogleOAuthEnabled}
isGithubOAuthEnabled={isGithubOAuthEnabled}
isDisabled={isSocialAuthDisabled}
disabledTooltipContent="Accept the Terms of Service to continue."
/>
</div>
</>
+139 -69
View File
@@ -1,83 +1,153 @@
import { Tooltip } from "@heroui/tooltip";
import { Icon } from "@iconify/react";
import type { ReactNode } from "react";
import { Button } from "@/components/shadcn";
import {
Button,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { appendCallbackState } from "@/lib/auth-callback-url";
type SocialProvider = {
key: string;
label: string;
url?: string;
isOAuthEnabled?: boolean;
enabledIcon: string;
disabledIcon: string;
disabledDocs: {
message: string;
href: string;
};
};
const SocialButton = ({
provider,
isDisabled,
disabledTooltipContent,
}: {
provider: SocialProvider;
isDisabled: boolean;
disabledTooltipContent: ReactNode;
}) => {
const button = (
<Button
variant="outline"
className="w-full"
asChild={!isDisabled}
disabled={isDisabled}
>
{isDisabled ? (
<span className="flex items-center gap-2">
<Icon
icon={
provider.isOAuthEnabled
? provider.enabledIcon
: provider.disabledIcon
}
width={24}
/>
{provider.label}
</span>
) : (
<a href={provider.url} className="flex items-center gap-2">
<Icon icon={provider.enabledIcon} width={24} />
{provider.label}
</a>
)}
</Button>
);
if (!isDisabled) {
return button;
}
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex w-full">{button}</span>
</TooltipTrigger>
<TooltipContent side="top" className="w-96">
{provider.isOAuthEnabled ? (
disabledTooltipContent
) : (
<div className="flex-inline text-small">
{provider.disabledDocs.message}{" "}
<CustomLink href={provider.disabledDocs.href}>
Read the docs
</CustomLink>
</div>
)}
</TooltipContent>
</Tooltip>
);
};
export const SocialButtons = ({
googleAuthUrl,
githubAuthUrl,
callbackUrl = "/",
isGoogleOAuthEnabled,
isGithubOAuthEnabled,
isDisabled = false,
disabledTooltipContent,
}: {
googleAuthUrl?: string;
githubAuthUrl?: string;
callbackUrl?: string;
isGoogleOAuthEnabled?: boolean;
isGithubOAuthEnabled?: boolean;
}) => (
<>
<Tooltip
content={
<div className="flex-inline text-small">
Social Login with Google is not enabled.{" "}
<CustomLink href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-social-login/#google-oauth-configuration">
Read the docs
</CustomLink>
</div>
}
placement="top"
shadow="sm"
isDisabled={isGoogleOAuthEnabled}
className="w-96"
>
<span>
<Button
variant="outline"
className="w-full"
asChild={isGoogleOAuthEnabled}
disabled={!isGoogleOAuthEnabled}
>
<a href={googleAuthUrl} className="flex items-center gap-2">
<Icon
icon={
isGoogleOAuthEnabled
? "flat-color-icons:google"
: "simple-icons:google"
}
width={24}
/>
Continue with Google
</a>
</Button>
</span>
</Tooltip>
<Tooltip
content={
<div className="flex-inline text-small">
Social Login with Github is not enabled.{" "}
<CustomLink href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-social-login/#github-oauth-configuration">
Read the docs
</CustomLink>
</div>
}
placement="top"
shadow="sm"
isDisabled={isGithubOAuthEnabled}
className="w-96"
>
<span>
<Button
variant="outline"
className="w-full"
asChild={isGithubOAuthEnabled}
disabled={!isGithubOAuthEnabled}
>
<a href={githubAuthUrl} className="flex items-center gap-2">
<Icon icon="simple-icons:github" width={24} />
Continue with Github
</a>
</Button>
</span>
</Tooltip>
</>
);
isDisabled?: boolean;
disabledTooltipContent?: ReactNode;
}) => {
const googleUrl = googleAuthUrl
? appendCallbackState(googleAuthUrl, callbackUrl)
: undefined;
const githubUrl = githubAuthUrl
? appendCallbackState(githubAuthUrl, callbackUrl)
: undefined;
const socialDisabledTooltip =
disabledTooltipContent || "Social login is currently unavailable.";
const providers: SocialProvider[] = [
{
key: "google",
label: "Continue with Google",
url: googleUrl,
isOAuthEnabled: isGoogleOAuthEnabled,
enabledIcon: "flat-color-icons:google",
disabledIcon: "simple-icons:google",
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",
},
},
{
key: "github",
label: "Continue with Github",
url: githubUrl,
isOAuthEnabled: isGithubOAuthEnabled,
enabledIcon: "simple-icons:github",
disabledIcon: "simple-icons:github",
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",
},
},
];
return (
<>
{providers.map((provider) => (
<SocialButton
key={provider.key}
provider={provider}
isDisabled={isDisabled || !provider.isOAuthEnabled || !provider.url}
disabledTooltipContent={socialDisabledTooltip}
/>
))}
</>
);
};
@@ -13,4 +13,30 @@ describe("client accordion content", () => {
expect(source).toContain("getStandaloneFindingColumns");
expect(source).not.toContain("getColumnFindings");
});
it("wires triage update and note loading actions into compliance findings", () => {
expect(source).toContain("updateFindingTriage");
expect(source).toContain("loadLatestFindingTriageNote");
expect(source).toContain("onTriageUpdateAction");
expect(source).toContain("onTriageNoteLoadAction");
});
it("refetches findings after mutelist-shortcut triage updates like the resource drawer", () => {
expect(source).toContain("shouldRefreshAfterTriageUpdate");
expect(source).toContain("reload()");
});
it("delegates data fetching to the hook instead of effect/ref choreography", () => {
expect(source).toContain("useRequirementFindings");
expect(source).not.toContain("useEffect");
expect(source).not.toContain("useRef");
});
it("gates the skeleton on the hook loading state and surfaces fetch errors", () => {
// A disabled fetch (e.g. "No findings" status) must not skeleton forever,
// and a failed fetch must offer a retry instead of hanging.
expect(source).toContain("isLoading && requirement.status");
expect(source).not.toContain("findings === null");
expect(source).toContain("Try again");
});
});
@@ -2,21 +2,26 @@
import { AlertTriangle } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { getFindings } from "@/actions/findings/findings";
import {
loadLatestFindingTriageNote,
updateFindingTriage,
} from "@/actions/findings";
import {
getStandaloneFindingColumns,
SkeletonTableFindings,
} from "@/components/findings/table";
import { Alert, AlertDescription } from "@/components/shadcn";
import { Alert, AlertDescription, Button } from "@/components/shadcn";
import { Accordion } from "@/components/ui/accordion/Accordion";
import { DataTable } from "@/components/ui/table";
import { createDict, FINDINGS_DEFAULT_SORT, MUTED_FILTER } from "@/lib";
import { FINDINGS_DEFAULT_SORT, MUTED_FILTER } from "@/lib";
import { INVALID_CONFIG_NOTE } from "@/lib/compliance/commons";
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
import { shouldRefreshAfterTriageUpdate } from "@/lib/finding-triage";
import { Requirement } from "@/types/compliance";
import { FindingProps, FindingsResponse } from "@/types/components";
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
import { useRequirementFindings } from "./use-requirement-findings";
interface ClientAccordionContentProps {
requirement: Requirement;
@@ -31,100 +36,53 @@ export const ClientAccordionContent = ({
scanId,
disableFindings = false,
}: ClientAccordionContentProps) => {
const [findings, setFindings] = useState<FindingsResponse | null>(null);
const [expandedFindings, setExpandedFindings] = useState<FindingProps[]>([]);
const searchParams = useSearchParams();
const pageNumber = searchParams.get("page") || "1";
const pageSize = searchParams.get("pageSize") || "10";
const complianceId = searchParams.get("complianceId");
const openFindingId = searchParams.get("id");
const sort = searchParams.get("sort") || FINDINGS_DEFAULT_SORT;
const loadedPageRef = useRef<string | null>(null);
const loadedPageSizeRef = useRef<string | null>(null);
const loadedSortRef = useRef<string | null>(null);
const loadedMutedRef = useRef<string | null>(null);
const isExpandedRef = useRef(false);
const region = searchParams.get("filter[region__in]") || "";
// Respect the user's muted preference from the URL; default to EXCLUDE
// so the requirement view stays consistent with every other findings
// surface in the app (findings page, resource drawer, overview widgets).
const mutedFilter = searchParams.get("filter[muted]") || MUTED_FILTER.EXCLUDE;
useEffect(() => {
async function loadFindings() {
if (
!disableFindings &&
requirement.check_ids?.length > 0 &&
requirement.status !== "No findings" &&
(loadedPageRef.current !== pageNumber ||
loadedPageSizeRef.current !== pageSize ||
loadedSortRef.current !== sort ||
loadedMutedRef.current !== mutedFilter ||
!isExpandedRef.current)
) {
loadedPageRef.current = pageNumber;
loadedPageSizeRef.current = pageSize;
loadedSortRef.current = sort;
loadedMutedRef.current = mutedFilter;
isExpandedRef.current = true;
const checks = requirement.check_ids || [];
try {
const checkIds = requirement.check_ids;
const encodedSort = sort.replace(/^\+/, "");
const findingsData = await getFindings({
filters: {
"filter[check_id__in]": checkIds.join(","),
"filter[scan]": scanId,
"filter[muted]": mutedFilter,
...(region && { "filter[region__in]": region }),
},
page: parseInt(pageNumber, 10),
pageSize: parseInt(pageSize, 10),
sort: encodedSort,
});
setFindings(findingsData);
if (findingsData?.data) {
// Create dictionaries for resources, scans, and providers
const resourceDict = createDict("resources", findingsData);
const scanDict = createDict("scans", findingsData);
const providerDict = createDict("providers", findingsData);
// Expand each finding with its corresponding resource, scan, and provider
const expandedData = findingsData.data.map(
(finding: FindingProps) => {
const scan = scanDict[finding.relationships?.scan?.data?.id];
const resource =
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
const provider =
providerDict[scan?.relationships?.provider?.data?.id];
return {
...finding,
relationships: { scan, resource, provider },
};
},
);
setExpandedFindings(expandedData);
}
} catch (error) {
console.error("Error loading findings:", error);
}
}
}
loadFindings();
}, [
requirement,
const {
findings,
expandedFindings,
isLoading,
error,
patchTriageUpdate,
reload,
} = useRequirementFindings({
enabled:
!disableFindings &&
checks.length > 0 &&
requirement.status !== "No findings",
checkIds: checks,
scanId,
pageNumber,
pageSize,
sort,
region,
mutedFilter,
disableFindings,
]);
});
const handleTriageUpdate = async (input: UpdateFindingTriageInput) => {
await updateFindingTriage(input);
// Mutelist-shortcut statuses mute the finding server-side; refetch so the
// list honors the muted filter, matching the resource drawer behavior.
if (shouldRefreshAfterTriageUpdate(input)) {
reload();
return;
}
patchTriageUpdate(input);
};
const renderDetails = () => {
if (!complianceId) {
@@ -148,7 +106,6 @@ export const ClientAccordionContent = ({
);
}
const checks = requirement.check_ids || [];
const checksList = (
<div className="flex items-center px-2 text-sm">
<div className="w-full flex-col">
@@ -174,7 +131,26 @@ export const ClientAccordionContent = ({
];
const renderFindingsTable = () => {
if (findings === null && requirement.status !== "MANUAL") {
if (error) {
return (
<Alert variant="error" className="mt-3">
<AlertTriangle />
<AlertDescription className="flex flex-wrap items-center gap-2">
<span>{error}</span>
<Button
variant="link"
size="link-sm"
className="h-auto p-0"
onClick={reload}
>
Try again
</Button>
</AlertDescription>
</Alert>
);
}
if (isLoading && requirement.status !== "MANUAL") {
return <SkeletonTableFindings />;
}
@@ -184,8 +160,12 @@ export const ClientAccordionContent = ({
<h4 className="mb-2 text-sm font-medium">Findings</h4>
<DataTable
columns={getStandaloneFindingColumns({ openFindingId })}
data={expandedFindings || []}
columns={getStandaloneFindingColumns({
openFindingId,
onTriageUpdateAction: handleTriageUpdate,
onTriageNoteLoadAction: loadLatestFindingTriageNote,
})}
data={expandedFindings}
metadata={findings?.meta}
disableScroll={true}
/>
@@ -0,0 +1,325 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FINDING_TRIAGE_STATUS } from "@/types/findings-triage";
import { useRequirementFindings } from "./use-requirement-findings";
const findingsActionsMock = vi.hoisted(() => ({
getFindings: vi.fn(),
}));
vi.mock("@/actions/findings", () => findingsActionsMock);
function makeFindingsResponse() {
return {
data: [
{
id: "finding-1",
attributes: { muted: false, status: "FAIL" },
triage: {
findingId: "finding-1",
findingUid: "uid-1",
triageId: "triage-1",
notesCount: 0,
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
label: "Under Review",
hasVisibleNote: false,
isMuted: false,
canEdit: true,
billingHref: "https://prowler.com/pricing",
},
relationships: {
scan: { data: { id: "scan-1" } },
resources: { data: [{ id: "resource-1" }] },
},
},
],
included: [
{
type: "scans",
id: "scan-1",
relationships: { provider: { data: { id: "provider-1" } } },
},
{ type: "resources", id: "resource-1" },
{ type: "providers", id: "provider-1" },
],
meta: { pagination: { count: 1, pages: 1 } },
};
}
function defaultOptions(overrides?: Record<string, unknown>) {
return {
enabled: true,
checkIds: ["check_1", "check_2"],
scanId: "scan-1",
pageNumber: "1",
pageSize: "10",
sort: "+severity",
region: "",
mutedFilter: "false",
...overrides,
};
}
async function flushAsync() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
describe("useRequirementFindings", () => {
beforeEach(() => {
findingsActionsMock.getFindings.mockReset();
findingsActionsMock.getFindings.mockResolvedValue(makeFindingsResponse());
});
it("should fetch findings with the requirement filters and strip the sort plus sign", async () => {
// Given / When
renderHook(() => useRequirementFindings(defaultOptions()));
await flushAsync();
// Then
expect(findingsActionsMock.getFindings).toHaveBeenCalledTimes(1);
expect(findingsActionsMock.getFindings).toHaveBeenCalledWith({
filters: {
"filter[check_id__in]": "check_1,check_2",
"filter[scan]": "scan-1",
"filter[muted]": "false",
},
page: 1,
pageSize: 10,
sort: "severity",
});
});
it("should expand findings with their included scan, resource, and provider", async () => {
// Given / When
const { result } = renderHook(() =>
useRequirementFindings(defaultOptions()),
);
await flushAsync();
// Then
const [expanded] = result.current.expandedFindings;
expect(expanded.relationships).toEqual({
scan: expect.objectContaining({ id: "scan-1" }),
resource: expect.objectContaining({ id: "resource-1" }),
provider: expect.objectContaining({ id: "provider-1" }),
});
expect(result.current.findings?.meta?.pagination?.count).toBe(1);
});
it("should not fetch when disabled or without check ids", async () => {
// Given / When
renderHook(() =>
useRequirementFindings(defaultOptions({ enabled: false })),
);
renderHook(() => useRequirementFindings(defaultOptions({ checkIds: [] })));
await flushAsync();
// Then
expect(findingsActionsMock.getFindings).not.toHaveBeenCalled();
});
it("should not report loading when the fetch is disabled", async () => {
// Given / When
const disabled = renderHook(() =>
useRequirementFindings(defaultOptions({ enabled: false })),
);
const withoutChecks = renderHook(() =>
useRequirementFindings(defaultOptions({ checkIds: [] })),
);
await flushAsync();
// Then — a skipped fetch must not look like a pending one.
expect(disabled.result.current.isLoading).toBe(false);
expect(withoutChecks.result.current.isLoading).toBe(false);
});
it("should report loading until the fetch settles", async () => {
// Given
let resolveFetch: (value: unknown) => void = () => {};
findingsActionsMock.getFindings.mockImplementationOnce(
() => new Promise((resolve) => (resolveFetch = resolve)),
);
// When
const { result } = renderHook(() =>
useRequirementFindings(defaultOptions()),
);
// Then
expect(result.current.isLoading).toBe(true);
// When
act(() => {
resolveFetch(makeFindingsResponse());
});
await flushAsync();
// Then
expect(result.current.isLoading).toBe(false);
});
it("should expose an error and stop loading when the fetch fails", async () => {
// Given
const consoleErrorSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
findingsActionsMock.getFindings.mockRejectedValue(
new Error("network down"),
);
// When
const { result } = renderHook(() =>
useRequirementFindings(defaultOptions()),
);
await flushAsync();
// Then — the caller can render an error state instead of a skeleton.
expect(result.current.error).toBe("Could not load findings.");
expect(result.current.isLoading).toBe(false);
expect(result.current.findings).toBeNull();
consoleErrorSpy.mockRestore();
});
it("should clear the error and recover on reload", async () => {
// Given: first fetch fails, retry succeeds
const consoleErrorSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
findingsActionsMock.getFindings
.mockRejectedValueOnce(new Error("network down"))
.mockResolvedValueOnce(makeFindingsResponse());
const { result } = renderHook(() =>
useRequirementFindings(defaultOptions()),
);
await flushAsync();
expect(result.current.error).toBe("Could not load findings.");
// When
act(() => {
result.current.reload();
});
await flushAsync();
// Then
expect(result.current.error).toBeNull();
expect(result.current.findings).not.toBeNull();
consoleErrorSpy.mockRestore();
});
it("should not refetch when only the checkIds array identity changes", async () => {
// Given
const { rerender } = renderHook((props) => useRequirementFindings(props), {
initialProps: defaultOptions(),
});
await flushAsync();
// When: same values, fresh array identity (parent re-render)
rerender(defaultOptions());
await flushAsync();
// Then
expect(findingsActionsMock.getFindings).toHaveBeenCalledTimes(1);
});
it("should refetch when a query parameter changes", async () => {
// Given
const { rerender } = renderHook((props) => useRequirementFindings(props), {
initialProps: defaultOptions(),
});
await flushAsync();
// When
rerender(defaultOptions({ pageNumber: "2" }));
await flushAsync();
// Then
expect(findingsActionsMock.getFindings).toHaveBeenCalledTimes(2);
expect(findingsActionsMock.getFindings).toHaveBeenLastCalledWith(
expect.objectContaining({ page: 2 }),
);
});
it("should refetch on reload keeping previous data visible meanwhile", async () => {
// Given
const { result } = renderHook(() =>
useRequirementFindings(defaultOptions()),
);
await flushAsync();
// When
act(() => {
result.current.reload();
});
// Then: previous data is not cleared while the refetch is in flight
expect(result.current.findings).not.toBeNull();
await flushAsync();
expect(findingsActionsMock.getFindings).toHaveBeenCalledTimes(2);
});
it("should patch the matching row triage optimistically", async () => {
// Given
const { result } = renderHook(() =>
useRequirementFindings(defaultOptions()),
);
await flushAsync();
// When
act(() => {
result.current.patchTriageUpdate({
findingId: "finding-1",
findingUid: "uid-1",
triageId: "triage-1",
notesCount: 0,
status: FINDING_TRIAGE_STATUS.REMEDIATING,
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
isMuted: false,
});
});
// Then
expect(result.current.expandedFindings[0]?.triage).toEqual(
expect.objectContaining({
status: FINDING_TRIAGE_STATUS.REMEDIATING,
label: "Remediating",
}),
);
});
it("should ignore stale responses after the query changes", async () => {
// Given: first request resolves late, second resolves immediately
let resolveFirst: (value: unknown) => void = () => {};
const staleResponse = {
...makeFindingsResponse(),
meta: { pagination: { count: 99, pages: 9 } },
};
findingsActionsMock.getFindings
.mockImplementationOnce(
() => new Promise((resolve) => (resolveFirst = resolve)),
)
.mockResolvedValueOnce(makeFindingsResponse());
const { result, rerender } = renderHook(
(props) => useRequirementFindings(props),
{ initialProps: defaultOptions() },
);
// When: the query changes before the first request settles
rerender(defaultOptions({ pageNumber: "2" }));
await flushAsync();
act(() => {
resolveFirst(staleResponse);
});
await flushAsync();
// Then: the stale response never overwrites the fresh one
expect(result.current.findings?.meta?.pagination?.count).toBe(1);
});
});
@@ -0,0 +1,146 @@
"use client";
import { useEffect, useState } from "react";
import { getFindings } from "@/actions/findings";
import { applyOptimisticFindingTriageRowsUpdate } from "@/lib/finding-triage";
import { createDict } from "@/lib/utils";
import { FindingProps, FindingsResponse } from "@/types/components";
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
interface UseRequirementFindingsOptions {
enabled: boolean;
checkIds: string[];
scanId: string;
pageNumber: string;
pageSize: string;
sort: string;
region: string;
mutedFilter: string;
}
interface UseRequirementFindingsReturn {
findings: FindingsResponse | null;
expandedFindings: FindingProps[];
isLoading: boolean;
error: string | null;
patchTriageUpdate: (input: UpdateFindingTriageInput) => void;
reload: () => void;
}
const FINDINGS_LOAD_ERROR = "Could not load findings.";
export function useRequirementFindings({
enabled,
checkIds,
scanId,
pageNumber,
pageSize,
sort,
region,
mutedFilter,
}: UseRequirementFindingsOptions): UseRequirementFindingsReturn {
const [findings, setFindings] = useState<FindingsResponse | null>(null);
const [expandedFindings, setExpandedFindings] = useState<FindingProps[]>([]);
const [error, setError] = useState<string | null>(null);
const [reloadNonce, setReloadNonce] = useState(0);
// Depend on the joined value, not the array: the requirement prop gets a
// fresh identity on every parent render and must not retrigger the fetch.
const checkIdsKey = checkIds.join(",");
const isFetchEnabled = enabled && checkIdsKey.length > 0;
// A skipped fetch is not a pending one; without this the caller would show
// a skeleton forever for requirements whose fetch never runs.
const isLoading = isFetchEnabled && findings === null && error === null;
useEffect(() => {
if (!isFetchEnabled) {
return;
}
let cancelled = false;
const loadFindings = async () => {
setError(null);
try {
const findingsData = await getFindings({
filters: {
"filter[check_id__in]": checkIdsKey,
"filter[scan]": scanId,
"filter[muted]": mutedFilter,
...(region && { "filter[region__in]": region }),
},
page: parseInt(pageNumber, 10),
pageSize: parseInt(pageSize, 10),
sort: sort.replace(/^\+/, ""),
});
if (cancelled) return;
setFindings(findingsData);
if (findingsData?.data) {
const resourceDict = createDict("resources", findingsData);
const scanDict = createDict("scans", findingsData);
const providerDict = createDict("providers", findingsData);
const expandedData = findingsData.data.map(
(finding: FindingProps) => {
const scan = scanDict[finding.relationships?.scan?.data?.id];
const resource =
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
const provider =
providerDict[scan?.relationships?.provider?.data?.id ?? ""];
return {
...finding,
relationships: { scan, resource, provider },
};
},
);
setExpandedFindings(expandedData);
}
} catch (error) {
if (!cancelled) {
console.error("Error loading findings:", error);
setError(FINDINGS_LOAD_ERROR);
}
}
};
loadFindings();
return () => {
cancelled = true;
};
}, [
isFetchEnabled,
checkIdsKey,
scanId,
pageNumber,
pageSize,
sort,
region,
mutedFilter,
reloadNonce,
]);
const patchTriageUpdate = (input: UpdateFindingTriageInput) => {
setExpandedFindings((currentFindings) =>
applyOptimisticFindingTriageRowsUpdate(currentFindings, input),
);
};
const reload = () => {
setReloadNonce((value) => value + 1);
};
return {
findings,
expandedFindings,
isLoading,
error,
patchTriageUpdate,
reload,
};
}
@@ -49,6 +49,7 @@ beforeAll(() => {
});
});
import { DOCS_URLS } from "@/lib/external-urls";
import {
FINDING_TRIAGE_DISABLED_REASON,
FINDING_TRIAGE_STATUS,
@@ -147,6 +148,21 @@ describe("FindingNoteModal", () => {
).toBeVisible();
});
it("should render a documentation link without requiring Remediating status", () => {
// Given / When
renderNoteModal();
// Then
const docsLink = screen.getByRole("link", {
name: /triage documentation/i,
});
expect(docsLink).toHaveAttribute("href", DOCS_URLS.FINDINGS_TRIAGE);
expect(docsLink).toHaveAttribute("target", "_blank");
expect(
screen.queryByText(/automatically changed to Resolved/i),
).not.toBeInTheDocument();
});
it("should send existing note changes with noteId and without duplicate-note status payload", async () => {
// Given
const user = userEvent.setup();
@@ -157,7 +173,7 @@ describe("FindingNoteModal", () => {
const textarea = screen.getByLabelText("Note text");
await user.clear(textarea);
await user.type(textarea, "Documented owner follow-up.");
await user.click(screen.getByRole("button", { name: "Save changes" }));
await user.click(screen.getByRole("button", { name: "Save" }));
// Then
expect(onTriageUpdateAction).toHaveBeenCalledWith({
@@ -189,7 +205,7 @@ describe("FindingNoteModal", () => {
// When
const textarea = screen.getByLabelText("Note text");
await user.type(textarea, " Initial triage note. ");
await user.click(screen.getByRole("button", { name: "Save changes" }));
await user.click(screen.getByRole("button", { name: "Save" }));
// Then
expect(onTriageUpdateAction).toHaveBeenCalledWith({
@@ -214,7 +230,7 @@ describe("FindingNoteModal", () => {
// When
await user.clear(screen.getByLabelText("Note text"));
await user.click(screen.getByRole("button", { name: "Save changes" }));
await user.click(screen.getByRole("button", { name: "Save" }));
// Then
expect(onTriageUpdateAction).toHaveBeenCalledWith({
@@ -239,7 +255,7 @@ describe("FindingNoteModal", () => {
// When
await user.clear(screen.getByLabelText("Note text"));
await user.type(screen.getByLabelText("Note text"), "Changed note");
await user.click(screen.getByRole("button", { name: "Save changes" }));
await user.click(screen.getByRole("button", { name: "Save" }));
// Then
expect(
@@ -278,7 +294,7 @@ describe("FindingNoteModal", () => {
const textarea = screen.getByLabelText("Note text");
await user.clear(textarea);
await user.type(textarea, "Documenting the resolution.");
await user.click(screen.getByRole("button", { name: "Save changes" }));
await user.click(screen.getByRole("button", { name: "Save" }));
// Then
expect(onTriageUpdateAction).toHaveBeenCalledWith(
@@ -306,9 +322,7 @@ describe("FindingNoteModal", () => {
).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(
screen.getByRole("button", { name: "Save changes" }),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
});
it("should disable controls and show the Cloud upsell badge for non-paying users", () => {
@@ -325,7 +339,7 @@ describe("FindingNoteModal", () => {
screen.getByRole("combobox", { name: "Triage status" }),
).toHaveAttribute("data-disabled", "");
expect(screen.getByLabelText("Note text")).toBeDisabled();
expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
expect(
screen.getByRole("link", { name: "Available in Prowler Cloud" }),
).toHaveAttribute("href", "https://prowler.com/pricing");
@@ -360,7 +374,7 @@ describe("FindingNoteModal", () => {
);
// When
await user.click(screen.getByRole("button", { name: "Save changes" }));
await user.click(screen.getByRole("button", { name: "Save" }));
// Then
await waitFor(() =>
@@ -1,5 +1,6 @@
"use client";
import { ExternalLink, Info } from "lucide-react";
import { type FormEvent, useRef, useState } from "react";
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
@@ -11,7 +12,6 @@ import {
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { DOCS_URLS } from "@/lib/external-urls";
import {
FINDING_TRIAGE_DISABLED_REASON,
@@ -131,10 +131,25 @@ export function FindingNoteModal({
unbreakable content (e.g. resource UIDs) widens the grid track past
the modal instead of truncating. */}
<form className="flex min-w-0 flex-col gap-5" onSubmit={handleSubmit}>
<div className="flex items-center gap-4">
<div className="bg-bg-neutral-tertiary flex size-9 shrink-0 items-center justify-center rounded-lg">
<div className="text-text-neutral-secondary flex flex-wrap items-center gap-2 text-sm">
<Info className="size-4 shrink-0" />
<span>Learn how triage states work in the</span>
<Button variant="link" size="link-sm" className="h-auto p-0" asChild>
<a
href={DOCS_URLS.FINDINGS_TRIAGE}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="size-3.5 shrink-0" />
<span>Triage documentation</span>
</a>
</Button>
</div>
<div className="border-border-input-primary flex items-center gap-4 rounded-lg border p-3">
<div className="bg-bg-neutral-tertiary flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-lg">
{findingContext.providerType ? (
<ProviderTypeIcon type={findingContext.providerType} size={22} />
<ProviderTypeIcon type={findingContext.providerType} size={36} />
) : (
<span className="text-text-neutral-secondary text-xs font-semibold">
{findingContext.provider?.slice(0, 3).toUpperCase() ?? "—"}
@@ -192,12 +207,7 @@ export function FindingNoteModal({
{shouldShowRemediatingInfo && (
<Alert variant="info">
<AlertDescription>
{REMEDIATING_INFO_COPY}.{" "}
<CustomLink href={DOCS_URLS.FINDINGS_TRIAGE} size="sm">
Learn more
</CustomLink>
</AlertDescription>
<AlertDescription>{REMEDIATING_INFO_COPY}.</AlertDescription>
</Alert>
)}
@@ -225,10 +235,12 @@ export function FindingNoteModal({
</div>
</div>
<div className="flex w-full justify-end gap-3">
{/* mt-3 lifts the gap-5 form spacing to 32px so the distance to the
footer matches the launch scan and alert modals. */}
<div className="mt-3 flex w-full justify-between gap-4">
<Button
type="button"
variant="ghost"
variant="outline"
size="lg"
onClick={() => onOpenChange(false)}
>
@@ -248,7 +260,7 @@ export function FindingNoteModal({
{isSubmitting
? "Saving..."
: canSubmit || isCloudOnly
? "Save changes"
? "Save"
: "Unavailable"}
</Button>
</span>
@@ -373,7 +373,7 @@ describe("finding triage cells", () => {
screen.getByRole("dialog", { name: "Add Triage Note" }),
).toBeVisible();
expect(screen.getByLabelText("Note text")).toBeDisabled();
expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
expect(
screen.getByRole("link", { name: "Available in Prowler Cloud" }),
).toHaveAttribute("href", "https://prowler.com/pricing");
@@ -3,11 +3,7 @@
import { useEffect, useState } from "react";
import { getResourceDrawerData } from "@/actions/resources";
import {
applyOptimisticTriageSummaryUpdate,
getOptimisticTriageMutedReason,
shouldMarkFindingMutedForTriageUpdate,
} from "@/lib/finding-triage";
import { applyOptimisticFindingTriageRowsUpdate } from "@/lib/finding-triage";
import { MetaDataProps } from "@/types";
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
import { OrganizationResource } from "@/types/organizations";
@@ -57,26 +53,7 @@ export function useResourceDrawerBootstrap({
const patchTriageUpdate = (input: UpdateFindingTriageInput) => {
setFindingsData((findings) =>
findings.map((finding) => {
if (!finding.triage || finding.triage.findingId !== input.findingId) {
return finding;
}
const shouldMarkMuted = shouldMarkFindingMutedForTriageUpdate(input);
return {
...finding,
triage: applyOptimisticTriageSummaryUpdate(finding.triage, input),
attributes: {
...finding.attributes,
muted: shouldMarkMuted ? true : finding.attributes.muted,
muted_reason:
shouldMarkMuted && input.isMuted !== true && input.status
? getOptimisticTriageMutedReason(input.status)
: finding.attributes.muted_reason,
},
};
}),
applyOptimisticFindingTriageRowsUpdate(findings, input),
);
};
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import {
appendCallbackState,
getInvitationTokenFromCallbackPath,
getSafeCallbackPath,
} from "@/lib/auth-callback-url";
describe("auth callback URL helpers", () => {
describe("when appending OAuth state", () => {
it("should add a relative callback path as provider state", () => {
const authUrl = "https://accounts.example.com/oauth?client_id=client";
const callbackPath = "/invitation/accept?invitation_token=test-token";
const result = appendCallbackState(authUrl, callbackPath);
expect(new URL(result).searchParams.get("state")).toBe(callbackPath);
});
it("should not add state for the default callback path", () => {
const authUrl = "https://accounts.example.com/oauth?client_id=client";
const result = appendCallbackState(authUrl, "/");
expect(new URL(result).searchParams.has("state")).toBe(false);
});
});
describe("when reading callback paths", () => {
it("should return relative callback paths", () => {
const params = new URLSearchParams({
state: "/invitation/accept?invitation_token=test-token",
});
const result = getSafeCallbackPath(params);
expect(result).toBe("/invitation/accept?invitation_token=test-token");
});
it("should reject external callback URLs", () => {
const params = new URLSearchParams({
state: "https://attacker.example/phishing",
});
const result = getSafeCallbackPath(params);
expect(result).toBe("/");
});
it("should reject protocol-relative callback URLs", () => {
const params = new URLSearchParams({
state: "//attacker.example/phishing",
});
const result = getSafeCallbackPath(params);
expect(result).toBe("/");
});
it("should reject backslash-normalized callback URLs", () => {
const params = new URLSearchParams({ state: "/\\attacker.example" });
const result = getSafeCallbackPath(params);
expect(result).toBe("/");
});
it("should reject callback URLs with control characters before the host", () => {
const params = new URLSearchParams({ state: "/\t/attacker.example" });
const result = getSafeCallbackPath(params);
expect(result).toBe("/");
});
it("should preserve the query string of relative callback paths", () => {
const params = new URLSearchParams({
state: "/invitation/accept?invitation_token=test-token&foo=bar",
});
const result = getSafeCallbackPath(params);
expect(result).toBe(
"/invitation/accept?invitation_token=test-token&foo=bar",
);
});
});
describe("when appending OAuth state for unsafe paths", () => {
it("should not add a backslash-normalized path as provider state", () => {
const authUrl = "https://accounts.example.com/oauth?client_id=client";
const result = appendCallbackState(authUrl, "/\\attacker.example");
expect(new URL(result).searchParams.has("state")).toBe(false);
});
});
describe("when reading invitation tokens", () => {
it("should return invitation tokens from safe callback paths", () => {
const callbackPath = "/invitation/accept?invitation_token=test-token";
const result = getInvitationTokenFromCallbackPath(callbackPath);
expect(result).toBe("test-token");
});
});
});
+65
View File
@@ -0,0 +1,65 @@
const DEFAULT_CALLBACK_PATH = "/";
const INVITATION_TOKEN_PARAM = "invitation_token";
// Origin used only to resolve relative paths; never part of the returned value.
const INTERNAL_ORIGIN = "http://localhost";
type CallbackSearchParams = {
get(name: string): string | null;
};
export const getSafeCallbackPathFromValue = (
value: string | null | undefined,
) => {
if (!value || !value.startsWith("/") || value.startsWith("//")) {
return DEFAULT_CALLBACK_PATH;
}
// A prefix check is not enough: the URL parser normalizes backslashes and
// control characters, so "/\evil.com" or "/\t/evil.com" pass the check above
// yet resolve to an external origin. Resolve against a fixed origin and
// confirm it stayed internal before trusting the path.
try {
const url = new URL(value, INTERNAL_ORIGIN);
if (url.origin !== INTERNAL_ORIGIN) {
return DEFAULT_CALLBACK_PATH;
}
return `${url.pathname}${url.search}${url.hash}`;
} catch (_error) {
return DEFAULT_CALLBACK_PATH;
}
};
export const getSafeCallbackPath = (
searchParams: CallbackSearchParams,
key = "state",
) => getSafeCallbackPathFromValue(searchParams.get(key));
export const appendCallbackState = (authUrl: string, callbackPath: string) => {
const safeCallbackPath = getSafeCallbackPathFromValue(callbackPath);
if (safeCallbackPath === DEFAULT_CALLBACK_PATH) {
return authUrl;
}
try {
const url = new URL(authUrl);
url.searchParams.set("state", safeCallbackPath);
return url.toString();
} catch (_error) {
return authUrl;
}
};
export const getInvitationTokenFromCallbackPath = (callbackPath: string) => {
const safeCallbackPath = getSafeCallbackPathFromValue(callbackPath);
if (safeCallbackPath === DEFAULT_CALLBACK_PATH) {
return null;
}
try {
const url = new URL(safeCallbackPath, "http://localhost");
return url.searchParams.get(INVITATION_TOKEN_PARAM);
} catch (_error) {
return null;
}
};
+2 -2
View File
@@ -23,12 +23,12 @@ export function expandFindingWithRelationships(
const scan = scanDict[finding.relationships?.scan?.data?.id];
const resource =
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
const provider = providerDict[scan?.relationships?.provider?.data?.id];
const provider = providerDict[scan?.relationships?.provider?.data?.id ?? ""];
return {
...finding,
relationships: { ...finding.relationships, scan, resource, provider },
} as FindingProps;
} as unknown as FindingProps;
}
export function findingToFindingResourceRow(
+222
View File
@@ -0,0 +1,222 @@
import { describe, expect, it } from "vitest";
import {
FINDING_TRIAGE_STATUS,
type FindingTriageSummary,
} from "@/types/findings-triage";
import {
applyOptimisticFindingTriageRowsUpdate,
applyOptimisticFindingTriageRowUpdate,
} from "./finding-triage";
interface TestFindingRowAttributes {
muted: boolean;
muted_reason?: string;
status: string;
}
interface TestFindingRow {
id: string;
triage?: FindingTriageSummary;
attributes: TestFindingRowAttributes;
}
function makeTriageSummary(
overrides?: Partial<FindingTriageSummary>,
): FindingTriageSummary {
return {
findingId: "finding-1",
findingUid: "uid-1",
triageId: "triage-1",
notesCount: 0,
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
label: "Under Review",
hasVisibleNote: false,
isMuted: false,
canEdit: true,
billingHref: "https://prowler.com/pricing",
...overrides,
};
}
function makeFindingRow(overrides?: Partial<TestFindingRow>): TestFindingRow {
return {
id: "finding-1",
triage: makeTriageSummary(),
attributes: {
muted: false,
muted_reason: undefined,
status: "FAIL",
},
...overrides,
};
}
describe("finding triage optimistic row updates", () => {
it("should patch matching finding row triage and muted attributes", () => {
// Given
const finding = makeFindingRow();
// When
const result = applyOptimisticFindingTriageRowUpdate(finding, {
findingId: "finding-1",
findingUid: "uid-1",
triageId: "triage-1",
notesCount: 0,
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
isMuted: false,
note: "Accepted by owner.",
});
// Then
expect(result).not.toBe(finding);
expect(result.triage).toEqual(
expect.objectContaining({
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
label: "Risk Accepted",
hasVisibleNote: true,
notesCount: 1,
isMuted: true,
}),
);
expect(result.attributes).toEqual(
expect.objectContaining({
muted: true,
muted_reason: "Finding triage status changed to Risk Accepted.",
status: "FAIL",
}),
);
});
it("should leave non-matching rows unchanged when patching a list", () => {
// Given
const matchingFinding = makeFindingRow();
const otherFinding = makeFindingRow({
id: "finding-2",
triage: makeTriageSummary({
findingId: "finding-2",
findingUid: "uid-2",
triageId: "triage-2",
}),
});
// When
const result = applyOptimisticFindingTriageRowsUpdate(
[matchingFinding, otherFinding],
{
findingId: "finding-1",
findingUid: "uid-1",
triageId: "triage-1",
notesCount: 0,
status: FINDING_TRIAGE_STATUS.REMEDIATING,
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
isMuted: false,
},
);
// Then
expect(result[0]?.triage).toEqual(
expect.objectContaining({
status: FINDING_TRIAGE_STATUS.REMEDIATING,
label: "Remediating",
}),
);
expect(result[1]).toBe(otherFinding);
});
it("should preserve muted attributes when leaving a mutelist-shortcut status", () => {
// Given: a finding muted by a previous shortcut transition. The server
// never removes the mute rule when the status moves on, so the optimistic
// update must not unmute the row either.
const finding = makeFindingRow({
triage: makeTriageSummary({
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
label: "Risk Accepted",
isMuted: true,
}),
attributes: {
muted: true,
muted_reason: "Finding triage status changed to Risk Accepted.",
status: "FAIL",
},
});
// When
const result = applyOptimisticFindingTriageRowUpdate(finding, {
findingId: "finding-1",
findingUid: "uid-1",
triageId: "triage-1",
notesCount: 0,
status: FINDING_TRIAGE_STATUS.REMEDIATING,
previousStatus: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
isMuted: true,
});
// Then
expect(result.triage).toEqual(
expect.objectContaining({
status: FINDING_TRIAGE_STATUS.REMEDIATING,
label: "Remediating",
isMuted: true,
}),
);
expect(result.attributes).toEqual(
expect.objectContaining({
muted: true,
muted_reason: "Finding triage status changed to Risk Accepted.",
}),
);
});
it("should not overwrite muted_reason when an already muted finding enters a shortcut status", () => {
// Given: muted through some other channel (e.g. a mutelist rule).
const finding = makeFindingRow({
triage: makeTriageSummary({ isMuted: true }),
attributes: {
muted: true,
muted_reason: "Muted by mutelist rule.",
status: "FAIL",
},
});
// When: no new mute rule is created for already muted findings, so the
// optimistic reason must keep the original one.
const result = applyOptimisticFindingTriageRowUpdate(finding, {
findingId: "finding-1",
findingUid: "uid-1",
triageId: "triage-1",
notesCount: 0,
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
isMuted: true,
});
// Then
expect(result.attributes).toEqual(
expect.objectContaining({
muted: true,
muted_reason: "Muted by mutelist rule.",
}),
);
});
it("should leave rows without triage unchanged", () => {
// Given
const finding = makeFindingRow({ triage: undefined });
// When
const result = applyOptimisticFindingTriageRowUpdate(finding, {
findingId: "finding-1",
findingUid: "uid-1",
triageId: null,
notesCount: 0,
note: "No triage payload on this row.",
isMuted: false,
});
// Then
expect(result).toBe(finding);
});
});
+46
View File
@@ -5,6 +5,16 @@ import {
type UpdateFindingTriageInput,
} from "@/types/findings-triage";
interface FindingTriageRowAttributes {
muted?: boolean;
muted_reason?: string;
}
export interface FindingTriageRow {
triage?: FindingTriageSummary;
attributes: FindingTriageRowAttributes;
}
export const shouldMarkFindingMutedForTriageUpdate = (
input: UpdateFindingTriageInput,
): boolean => Boolean(input.status && isMutelistShortcutStatus(input.status));
@@ -45,3 +55,39 @@ export const applyOptimisticTriageSummaryUpdate = (
: {}),
};
};
export const applyOptimisticFindingTriageRowUpdate = <
TRow extends FindingTriageRow,
>(
finding: TRow,
input: UpdateFindingTriageInput,
): TRow => {
if (!finding.triage || finding.triage.findingId !== input.findingId) {
return finding;
}
const shouldMarkMuted = shouldMarkFindingMutedForTriageUpdate(input);
return {
...finding,
triage: applyOptimisticTriageSummaryUpdate(finding.triage, input),
attributes: {
...finding.attributes,
muted: shouldMarkMuted ? true : finding.attributes.muted,
muted_reason:
shouldMarkMuted && input.isMuted !== true && input.status
? getOptimisticTriageMutedReason(input.status)
: finding.attributes.muted_reason,
},
};
};
export const applyOptimisticFindingTriageRowsUpdate = <
TRow extends FindingTriageRow,
>(
findings: TRow[],
input: UpdateFindingTriageInput,
): TRow[] =>
findings.map((finding) =>
applyOptimisticFindingTriageRowUpdate(finding, input),
);
-15
View File
@@ -383,21 +383,6 @@ export const checkTaskStatus = async (
export const wait = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
// Helper function to create dictionaries by type
export function createDict(type: string, data: any) {
const includedField = data?.included?.filter(
(item: { type: string }) => item.type === type,
);
if (!includedField || includedField.length === 0) {
return {};
}
return Object.fromEntries(
includedField.map((item: { id: string }) => [item.id, item]),
);
}
export const parseStringify = (value: any) => JSON.parse(JSON.stringify(value));
export const convertFileToUrl = (file: File) => URL.createObjectURL(file);
+36
View File
@@ -28,3 +28,39 @@ export function getOptionalText(value: unknown): string | undefined {
? value
: undefined;
}
interface IncludedApiItemRelationshipRef {
id: string;
}
interface IncludedApiItemRelationship {
data?: IncludedApiItemRelationshipRef;
}
interface IncludedApiItem {
id: string;
type: string;
attributes?: Record<string, unknown>;
relationships?: Record<string, IncludedApiItemRelationship>;
}
// Indexes a JSON:API `included` array by id for the requested resource type.
export function createDict<T extends IncludedApiItem = IncludedApiItem>(
type: string,
data: { included?: unknown[] } | null | undefined,
): Record<string, T> {
const includedField = data?.included?.filter(
(item): item is T =>
typeof item === "object" &&
item !== null &&
(item as IncludedApiItem).type === type,
);
if (!includedField || includedField.length === 0) {
return {};
}
return Object.fromEntries(
includedField.map((item): [string, T] => [item.id, item]),
);
}
Generated
+1 -1
View File
@@ -3553,7 +3553,7 @@ wheels = [
[[package]]
name = "prowler"
version = "5.32.0"
version = "5.32.1"
source = { editable = "." }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },