chore: merge master

This commit is contained in:
pedrooot committed 2026-07-06 12:29:41 +02:00
commit f5976ec7eb
68 files changed
+2065 -817

No files matched your search

+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.33.0
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
-140
View File
@@ -1,140 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
# v5
# - package-ecosystem: "pip"
# directory: "/"
# schedule:
# interval: "monthly"
# open-pull-requests-limit: 25
# target-branch: master
# labels:
# - "dependencies"
# - "pip"
# cooldown:
# default-days: 7
# Dependabot Updates are temporary disabled - 2025/03/19
# - package-ecosystem: "pip"
# directory: "/api"
# schedule:
# interval: "daily"
# open-pull-requests-limit: 10
# target-branch: master
# labels:
# - "dependencies"
# - "pip"
# - "component/api"
# Dependabot version updates disabled - migrated to Renovate - 2026/07/02
# - package-ecosystem: "github-actions"
# directory: "/"
# schedule:
# interval: "monthly"
# open-pull-requests-limit: 25
# target-branch: master
# labels:
# - "dependencies"
# - "github_actions"
# cooldown:
# default-days: 7
# Dependabot Updates are temporary disabled - 2025/03/19
# - package-ecosystem: "npm"
# directory: "/ui"
# schedule:
# interval: "daily"
# open-pull-requests-limit: 10
# target-branch: master
# labels:
# - "dependencies"
# - "npm"
# - "component/ui"
# Dependabot version updates disabled - migrated to Renovate - 2026/07/02
# - package-ecosystem: "docker"
# directory: "/"
# schedule:
# interval: "monthly"
# open-pull-requests-limit: 25
# target-branch: master
# labels:
# - "dependencies"
# - "docker"
# cooldown:
# default-days: 7
# - package-ecosystem: "pre-commit"
# directory: "/"
# schedule:
# interval: "monthly"
# open-pull-requests-limit: 25
# target-branch: master
# labels:
# - "dependencies"
# - "pre-commit"
# cooldown:
# default-days: 7
# Dependabot Updates are temporary disabled - 2025/04/15
# v4.6
# - package-ecosystem: "pip"
# directory: "/"
# schedule:
# interval: "weekly"
# open-pull-requests-limit: 10
# target-branch: v4.6
# labels:
# - "dependencies"
# - "pip"
# - "v4"
# - package-ecosystem: "github-actions"
# directory: "/"
# schedule:
# interval: "weekly"
# open-pull-requests-limit: 10
# target-branch: v4.6
# labels:
# - "dependencies"
# - "github_actions"
# - "v4"
# - package-ecosystem: "docker"
# directory: "/"
# schedule:
# interval: "weekly"
# open-pull-requests-limit: 10
# target-branch: v4.6
# labels:
# - "dependencies"
# - "docker"
# - "v4"
# Dependabot Updates are temporary disabled - 2025/03/19
# v3
# - package-ecosystem: "pip"
# directory: "/"
# schedule:
# interval: "monthly"
# open-pull-requests-limit: 10
# target-branch: v3
# labels:
# - "dependencies"
# - "pip"
# - "v3"
# - package-ecosystem: "github-actions"
# directory: "/"
# schedule:
# interval: "monthly"
# open-pull-requests-limit: 10
# target-branch: v3
# labels:
# - "dependencies"
# - "github_actions"
# - "v3"
+14 -11
View File
@@ -2,11 +2,23 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.33.0] (Prowler UNRELEASED)
## [1.33.1] (Prowler UNRELEASED)
### 🐞 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)
### 🔐 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
- Added timestamp precision support for `/api/v1/findings` `inserted_at` and `updated_at` filters [(#11754)](https://github.com/prowler-cloud/prowler/pull/11754)
- Timestamp precision support for `/api/v1/findings` `inserted_at` and `updated_at` filters [(#11754)](https://github.com/prowler-cloud/prowler/pull/11754)
### 🔄 Changed
@@ -17,17 +29,8 @@ All notable changes to the **Prowler API** are documented in this file.
### 🐞 Fixed
- 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)
---
## [1.32.2] (Prowler UNRELEASED)
### 🐞 Fixed
- `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
+1 -1
View File
@@ -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.34.0"
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
# target-version tracks this project's lowest supported Python.
@@ -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.34.0
description: |-
Prowler API specification.
+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
+103 -46
View File
@@ -244,6 +244,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 +1548,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 +3599,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 +3648,7 @@ class TestScanViewSet:
mock_task_get,
authenticated_client,
scan_json_payload,
expected_scanner_args,
_expected_scanner_args,
providers_fixture,
tasks_fixture,
):
@@ -4070,7 +4127,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 +4233,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 +4291,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 +4302,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 +4334,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 +4342,7 @@ class TestScanViewSet:
class FakeS3Client:
def list_objects_v2(self, Bucket, Prefix):
del Prefix
return {
"Contents": [
{
@@ -4357,11 +4416,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 +4607,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 +6339,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 +6360,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 +7856,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 +7880,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 +8068,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 +8077,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"),
)
@@ -10723,9 +10781,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 +10793,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 +10847,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 +11316,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 +11350,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 +11999,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 +12068,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 +12080,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 +12720,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 +12742,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 +12762,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 +12778,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 +12794,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,
@@ -18292,10 +18349,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,
):
"""
+20 -2
View File
@@ -948,8 +948,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 +1003,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.")
@@ -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
+1 -1
View File
@@ -4762,7 +4762,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.33.0"
version = "1.34.0"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
+1
View File
@@ -128,6 +128,7 @@
"user-guide/tutorials/prowler-scan-scheduling",
"user-guide/tutorials/prowler-alerts",
"user-guide/tutorials/prowler-app-scan-configuration",
"user-guide/tutorials/prowler-app-findings-triage",
{
"group": "Mutelist",
"expanded": true,
@@ -128,8 +128,8 @@ To update the environment file:
Edit the `.env` file and change version values:
```env
PROWLER_UI_VERSION="5.31.0"
PROWLER_API_VERSION="5.31.0"
PROWLER_UI_VERSION="5.32.0"
PROWLER_API_VERSION="5.32.0"
```
<Note>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 304 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 477 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 575 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 324 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 423 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 KiB

@@ -21,13 +21,13 @@ By mapping these relationships as a graph, Attack Paths reveals risks that indiv
The following prerequisites are required for Attack Paths:
- **An AWS provider is configured** with valid credentials in Prowler App. For setup instructions, see [Getting Started with AWS](/user-guide/providers/aws/getting-started-aws).
- **At least one scan has completed** on the configured AWS provider. Attack Paths scans run automatically alongside regular security scans, no separate configuration is required.
- **At least one scan has completed** on the configured AWS provider and produced graph data. Attack Paths scans run automatically alongside regular security scans, no separate configuration is required.
## How Attack Paths Scans Work
Attack Paths scans are generated automatically when a security scan runs on an AWS provider. Each completed scan produces graph data that maps relationships between IAM principals, policies, trust configurations, and other resources.
Once the scan finishes and the graph data is ready, the scan appears in the Attack Paths scan table with a **Completed** status. Scans that are still processing display as **Executing** or **Scheduled**.
Once the scan finishes and graph data is ready, the scan appears in the Attack Paths scan table with a **Completed** status and a check in the **Graph** column. Scans that are still queued or running remain visible, but they cannot be selected until graph data is ready.
<Note>
Since Prowler scans all configured providers every **24 hours** by default,
@@ -41,25 +41,28 @@ To open Attack Paths, click **Attack Paths** in the left navigation menu.
<img
src="/images/prowler-app/attack-paths/navigation.png"
alt="Attack Paths navigation menu entry"
width="700"
width="320"
/>
The main interface is divided into two areas:
The Attack Paths page guides you through the workflow on one page:
- **Left panel:** A table listing all available Attack Paths scans
- **Right panel:** The query selector, parameter form, and execute controls
- Select a scan with graph data.
- Choose a built-in query or a custom openCypher query.
- Add parameters when the selected query requires them.
- Execute the query and explore the resulting graph.
## Selecting a Scan
The scans table displays all Attack Paths scans with the following columns:
- **Provider / Account:** The AWS provider alias and account identifier
- **Last scan date:** When the scan completed
- **Status:** Current state of the scan (Completed, Executing, Scheduled, or Failed)
- **Progress:** Completion percentage for in-progress scans
- **Duration:** Total scan time
- **Select:** A radio button used to choose a scan. The radio button is disabled when graph data is not available.
- **Provider:** The AWS provider alias and account identifier.
- **Last Scan Date:** When the scan completed.
- **Status:** Current state of the scan, such as **Completed**, **Executing**, **Scheduled**, or **Failed**.
- **Graph:** Whether Attack Paths graph data is available for the scan.
- **Duration:** Total scan time.
To select a scan for analysis, click **Select** on any row with a **Completed** status.
To select a scan for analysis, click the radio button on any row with a **Completed** status and available graph data.
<img
src="/images/prowler-app/attack-paths/scan-list-table.png"
@@ -68,19 +71,18 @@ To select a scan for analysis, click **Select** on any row with a **Completed**
/>
<Note>
Only scans with a **Completed** status and ready graph data can be selected.
Scans that are still executing or have failed appear with disabled action
buttons.
Only scans with graph data can be selected. Disabled rows include a tooltip
that explains why the graph is not available yet.
</Note>
## Choosing a Query
After selecting a scan, the right panel activates a query dropdown. Each query targets a specific type of privilege escalation or misconfiguration pattern.
After selecting a scan, the query selector becomes available. Each query targets a specific privilege escalation, exposure, inventory, or misconfiguration pattern.
To choose a query, click the dropdown and select from the available options. Each option displays:
- **Query name:** A descriptive title (e.g., "IAM Privilege Escalation via AssumeRole")
- **Short description:** A brief summary of what the query detects
- **Query name:** A descriptive title, such as **Internet-Exposed EC2 with Sensitive S3 Access**.
- **Short description:** A brief summary of what the query detects.
<img
src="/images/prowler-app/attack-paths/query-selector.png"
@@ -88,16 +90,17 @@ To choose a query, click the dropdown and select from the available options. Eac
width="700"
/>
Once selected, a description card appears below the dropdown with additional context about the query, including attribution links to external references when available.
Once selected, a description panel appears below the dropdown with more context about the query.
## Configuring Query Parameters
Some queries accept optional or required parameters to narrow the scope of the analysis. When a query has parameters, a dynamic form appears below the query description.
Some queries accept optional or required parameters to narrow the scope of the analysis. When a query has parameters, a form appears below the query description.
- **Required fields** are marked with an asterisk (\*) and must be filled before executing
- **Optional fields** refine the query results but are not mandatory
- **Required fields** are marked with an asterisk (\*) and must be filled before executing.
- **Optional fields** refine the query results but are not mandatory.
- Queries without parameters show no parameter form.
If a query requires no parameters, the form displays a message confirming that the query is ready to execute.
For example, **Internet-Exposed EC2 with Sensitive S3 Access** uses **Tag key** and **Tag value** fields to identify sensitive S3 buckets.
<img
src="/images/prowler-app/attack-paths/query-parameters.png"
@@ -117,7 +120,7 @@ Custom queries are sandboxed to keep the graph database safe and responsive:
- **Read-only:** Only read operations are allowed. Statements that mutate the graph (`CREATE`, `MERGE`, `SET`, `DELETE`, `REMOVE`, `DROP`, `LOAD CSV`, `CALL { ... }` writes, etc.) are rejected before execution.
- **Length limit:** Each query is capped at **10,000 characters**.
- **Scoped to the selected scan:** Results are automatically scoped to the provider and scan selected on the left panel. There is no need to filter by tenant or scan identifier in the query body.
- **Scoped to the selected scan:** Results are automatically scoped to the provider and scan selected in the scan table. There is no need to filter by tenant or scan identifier in the query body.
### Example Queries
@@ -284,53 +287,75 @@ In addition to the upstream schema, Prowler enriches the graph with:
## Executing a Query
To run the selected query against the scan data, click **Execute Query**. The button displays a loading state while the query processes.
To run the selected query against the scan data, click **Execute Query**. The button is disabled until a query is selected and all required parameters are valid.
The button displays a loading state while the query runs. After the query completes, the graph appears below the query builder.
If the query returns no results, an informational message appears. Common reasons include:
- **No matching patterns found:** The scanned environment does not contain the privilege escalation chain the query targets
- **Insufficient permissions:** The scan credentials may not have captured all the data the query needs
- **No matching patterns found:** The scanned environment does not contain the pattern the query targets.
- **Not enough permissions:** The scan credentials may not have captured all the data the query needs.
- **Server unavailable:** The graph service may be temporarily unavailable.
<img
src="/images/prowler-app/attack-paths/execute-query.png"
alt="Attack Paths right panel with query selected and execute button"
alt="Attack Paths query builder with query selected and execute button"
width="700"
/>
## Exploring the Graph
After a successful execution, the graph visualization renders below the query builder in a full-width panel. The graph maps relationships between cloud resources, IAM entities, and security findings.
After a successful execution, the graph visualization renders below the query builder. The graph maps relationships between cloud resources, IAM entities, public exposure, and security findings.
### Node Types
- **Resource nodes** (rounded pills): Represent cloud resources such as IAM roles, policies, EC2 instances, and S3 buckets. Each resource type has a distinct color.
- **Finding nodes** (hexagons): Represent Prowler security findings linked to resources in the graph. Colors indicate severity level (critical, high, medium, low).
- **Provider root nodes:** Represent the AWS account or provider root for the selected scan.
- **Resource nodes:** Represent cloud resources such as IAM roles, policies, EC2 instances, security groups, and S3 buckets.
- **Internet nodes:** Represent exposure from the public internet.
- **Finding nodes:** Represent Prowler findings linked to resources. Finding colors indicate risk level, such as critical, high, medium, or low.
### Edge Types
- **Solid lines:** Direct relationships between resources (e.g., a role attached to a policy)
- **Dashed lines:** Connections between resources and their associated findings
- **Normal edges:** Direct relationships between graph nodes, such as role-to-policy or resource-to-security-group relationships.
- **Finding edges:** Dashed relationships between resources and their associated findings.
- **Highlighted paths:** Green edges that show the active path when you hover a node or focus a finding.
A **legend** at the bottom of the graph lists all node types and edge types present in the current view.
The standard graph view includes a minimap and a legend below the canvas. The legend shows the provider roots, visible node types, finding risk levels, node states, and edge types present in the current view.
<img
src="/images/prowler-app/attack-paths/graph-visualization.png"
alt="Attack Paths graph showing nodes, edges, and legend"
alt="Attack Paths graph showing nodes and edges"
width="700"
/>
## Interacting with the Graph
### Filtering by Node
The graph banner describes the main interactions:
Click any node in the graph to filter the view and display only paths that pass through that node. When a filter is active:
- Click a finding to focus its connected path.
- Click a resource with findings to show or hide its related findings.
- Hover a node to highlight its connected path.
- An information banner shows which node is selected
- Click **Back to Full View** to restore the complete graph
### Showing Related Findings
Resource nodes with related findings are clickable. Click one of these resources to show its finding nodes. Click the resource again to hide them.
The graph automatically fits the selected resource and its related findings when the findings are shown.
### Focusing a Finding Path
Click a finding node to focus the graph on the path connected to that finding. When the graph is focused:
- The graph shows **Back to Full View**.
- The status banner shows the selected finding.
- The graph keeps only the connected path in view.
- The finding detail drawer opens.
After you close the drawer, the graph remains focused on the selected path.
<img
src="/images/prowler-app/attack-paths/graph-filtered.png"
alt="Attack Paths graph filtered to show paths through a selected node"
alt="Attack Paths graph focused on a selected finding path"
width="700"
/>
@@ -339,29 +364,29 @@ Click any node in the graph to filter the view and display only paths that pass
The toolbar in the top-right corner of the graph provides:
- **Zoom in / Zoom out:** Adjust the zoom level
- **Fit to screen:** Reset the view to fit all nodes
- **Export:** Download the current graph as an SVG file
- **Fullscreen:** Open the graph in a full-screen modal with a side-by-side node detail panel
- **Fit graph to view:** Reset the view to fit the visible graph
- **Export graph:** Download the current graph as a PNG file
- **Fullscreen:** Open the graph in a full-size modal
<Note>
Use **Ctrl + Scroll** (or **Cmd + Scroll** on macOS) to zoom directly within
the graph area.
</Note>
## Viewing Node Details
## Viewing Finding Details
Click any node to open the **Node Details** panel below the graph. This panel displays:
Click a finding node to open the finding detail drawer. The drawer uses the same finding detail layout as the Findings page and includes:
- **Node type:** The resource category (e.g., "IAM Role," "EC2 Instance")
- **Properties:** All attributes of the selected node, including identifiers, timestamps, and configuration details
- **Related findings** (for resource nodes): A list of Prowler findings linked to the resource, with severity, title, and status
- **Affected resources** (for finding nodes): A list of resources associated with the finding
- The finding title, status, and severity.
- The affected resource summary.
- Overview, remediation, evidence, related findings, scans, and events tabs when data is available.
- A Lighthouse AI action when the account has access to Lighthouse AI.
For finding nodes, a "View Finding" button links directly to the finding detail page for further investigation.
Resource nodes do not open a node detail panel. When a resource has related findings, clicking it expands or collapses those finding nodes in the graph.
<img
src="/images/prowler-app/attack-paths/node-details.png"
alt="Attack Paths node detail panel showing properties and related findings"
alt="Attack Paths finding detail drawer"
width="700"
/>
@@ -369,16 +394,28 @@ For finding nodes, a "View Finding" button links directly to the finding detail
To expand the graph for detailed exploration, click the fullscreen icon in the graph toolbar. The fullscreen modal provides:
- The full graph visualization with all zoom and export controls
- A side panel for node details that appears when a node is selected
- All filtering and interaction capabilities available in the standard view
- The graph in a full-size modal.
- The same zoom, fit, and export controls.
- The same node expansion, finding focus, hover highlight, and minimap interactions available in the standard view.
<img
src="/images/prowler-app/attack-paths/fullscreen-mode.png"
alt="Attack Paths fullscreen mode with graph and node detail side panel"
alt="Attack Paths fullscreen graph mode"
width="700"
/>
## Available Queries
The query selector includes custom openCypher and built-in AWS queries for common security investigation workflows. Available queries are loaded from the selected scan and may change as new query packs are added.
Available queries include:
- **Custom openCypher query:** Write and run a read-only graph query.
- **Exposure queries:** Find internet-exposed EC2 instances, load balancers, open security groups, and resources by public IP.
- **Inventory queries:** List resources such as RDS instances.
- **Misconfiguration queries:** Find unencrypted RDS instances, public S3 buckets, and wildcard IAM statements.
- **Privilege escalation queries:** Detect IAM and AWS service paths based on known attack techniques, including queries based on [pathfinding.cloud](https://pathfinding.cloud) research by Datadog.
## Using Attack Paths with the MCP Server and Lighthouse AI
Attack Paths capabilities are also available through the [Prowler MCP Server](/getting-started/products/prowler-mcp), enabling interaction with Attack Paths data via AI assistants like Claude Desktop, Cursor, and other MCP clients.
@@ -387,10 +424,10 @@ Attack Paths capabilities are also available through the [Prowler MCP Server](/g
The following MCP tools are available for Attack Paths:
- **`prowler_app_list_attack_paths_scans`** - List and filter Attack Paths scans
- **`prowler_app_list_attack_paths_queries`** - Discover available queries for a completed scan
- **`prowler_app_run_attack_paths_query`** - Execute a query and retrieve graph results with nodes and relationships
- **`prowler_app_get_attack_paths_cartography_schema`** - Retrieve the Cartography graph schema for custom openCypher queries
- **`prowler_app_list_attack_paths_scans`** - List and filter Attack Paths scans.
- **`prowler_app_list_attack_paths_queries`** - Discover available queries for a completed scan.
- **`prowler_app_run_attack_paths_query`** - Execute a query and retrieve graph results with nodes and relationships.
- **`prowler_app_get_attack_paths_cartography_schema`** - Retrieve the Cartography graph schema for custom openCypher queries.
### Example Questions
@@ -405,110 +442,6 @@ Ask through the MCP Server or Lighthouse AI:
- "Are there any CloudFormation stacks that could be hijacked for privilege escalation?"
- "Show me all roles that can be assumed for lateral movement"
### Supported Queries
Attack Paths currently supports the following built-in queries for AWS:
#### Custom Attack Path Queries
| Query | Description |
| ------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| **Internet-Exposed EC2 with Sensitive S3 Access** | Find SSH-exposed EC2 instances that can assume roles to read tagged sensitive S3 buckets |
#### Basic Resource Queries
| Query | Description |
| ------------------------------------------- | ------------------------------------------------------------------- |
| **RDS Instances Inventory** | List all provisioned RDS database instances in the account |
| **Unencrypted RDS Instances** | Find RDS instances with storage encryption disabled |
| **S3 Buckets with Anonymous Access** | Find S3 buckets that allow anonymous access |
| **IAM Statements Allowing All Actions** | Find IAM policy statements that allow all actions via wildcard (\*) |
| **IAM Statements Allowing Policy Deletion** | Find IAM policy statements that allow iam:DeletePolicy |
| **IAM Statements Allowing Create Actions** | Find IAM policy statements that allow any create action |
#### Network Exposure Queries
| Query | Description |
| ----------------------------------------------------- | ----------------------------------------------------------------------------------- |
| **Internet-Exposed EC2 Instances** | Find EC2 instances flagged as exposed to the internet |
| **Open Security Groups on Internet-Facing Resources** | Find internet-facing resources with security groups allowing inbound from 0.0.0.0/0 |
| **Internet-Exposed Classic Load Balancers** | Find Classic Load Balancers exposed to the internet with their listeners |
| **Internet-Exposed ALB/NLB Load Balancers** | Find ELBv2 (ALB/NLB) load balancers exposed to the internet with their listeners |
| **Resource Lookup by Public IP** | Find the AWS resource associated with a given public IP address |
#### Privilege Escalation Queries
These queries are based on research from [pathfinding.cloud](https://pathfinding.cloud) by Datadog.
| Query | Description |
| -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **App Runner Service Creation with Privileged Role (APPRUNNER-001)** | Create an App Runner service with a privileged IAM role to gain its permissions |
| **App Runner Service Update for Role Access (APPRUNNER-002)** | Update an existing App Runner service to leverage its already-attached privileged role |
| **Bedrock Code Interpreter with Privileged Role (BEDROCK-001)** | Create a Bedrock AgentCore Code Interpreter with a privileged role attached |
| **Bedrock Code Interpreter Session Hijacking (BEDROCK-002)** | Start a session on an existing Bedrock code interpreter to exfiltrate its privileged role credentials |
| **CloudFormation Stack Creation with Privileged Role (CLOUDFORMATION-001)** | Create a CloudFormation stack with a privileged role to provision arbitrary AWS resources |
| **CloudFormation Stack Update for Role Access (CLOUDFORMATION-002)** | Update an existing CloudFormation stack to leverage its already-attached privileged service role |
| **CloudFormation StackSet Creation with Privileged Role (CLOUDFORMATION-003)** | Create a CloudFormation StackSet with a privileged execution role to provision arbitrary resources across accounts |
| **CloudFormation StackSet Update with Privileged Role (CLOUDFORMATION-004)** | Update an existing CloudFormation StackSet to inject malicious resources using a privileged execution role |
| **CloudFormation Change Set Privilege Escalation (CLOUDFORMATION-005)** | Create and execute a change set on an existing stack to leverage its privileged service role |
| **CodeBuild Project Creation with Privileged Role (CODEBUILD-001)** | Create a CodeBuild project with a privileged role to execute arbitrary code via a malicious buildspec |
| **CodeBuild Buildspec Override for Role Access (CODEBUILD-002)** | Start a build on an existing CodeBuild project with a buildspec override to execute code with its privileged role |
| **CodeBuild Batch Buildspec Override for Role Access (CODEBUILD-003)** | Start a batch build on an existing CodeBuild project with a buildspec override to execute code with its privileged role |
| **CodeBuild Batch Project Creation with Privileged Role (CODEBUILD-004)** | Create a CodeBuild project configured for batch builds with a privileged role to execute arbitrary code via a malicious buildspec |
| **Data Pipeline Creation with Privileged Role (DATAPIPELINE-001)** | Create a Data Pipeline with a privileged role to execute arbitrary commands on provisioned infrastructure |
| **EC2 Instance Launch with Privileged Role (EC2-001)** | Launch EC2 instances with privileged IAM roles to gain their permissions via IMDS |
| **EC2 Role Hijacking via UserData Injection (EC2-002)** | Inject malicious scripts into EC2 instance userData to gain the attached role's permissions |
| **Spot Instance Launch with Privileged Role (EC2-003)** | Launch EC2 Spot Instances with privileged IAM roles to gain their permissions via IMDS |
| **Launch Template Poisoning for Role Access (EC2-004)** | Inject malicious userData into launch templates that reference privileged roles, no PassRole needed |
| **EC2 Instance Connect SSH Access for Role Credentials (EC2INSTANCECONNECT-003)** | Push a temporary SSH key to an EC2 instance via Instance Connect to access its attached role credentials through IMDS |
| **ECS Service Creation with Privileged Role (ECS-001 - New Cluster)** | Create an ECS cluster and service with a privileged Fargate task role to execute arbitrary code |
| **ECS Task Execution with Privileged Role (ECS-002 - New Cluster)** | Create an ECS cluster and run a one-off Fargate task with a privileged role to execute arbitrary code |
| **ECS Service Creation with Privileged Role (ECS-003 - Existing Cluster)** | Deploy a Fargate service with a privileged role on an existing ECS cluster |
| **ECS Task Execution with Privileged Role (ECS-004 - Existing Cluster)** | Run a one-off Fargate task with a privileged role on an existing ECS cluster |
| **ECS Task Start with Privileged Role on EC2 (ECS-005 - Existing Cluster)** | Register a task definition with a privileged role and start it on an EC2 container instance to execute arbitrary code |
| **ECS Exec Container Hijacking for Role Credentials (ECS-006)** | Shell into a running ECS container via ECS Exec to steal the attached task role's credentials |
| **Glue Dev Endpoint with Privileged Role (GLUE-001)** | Create a Glue development endpoint with a privileged role attached to gain its permissions |
| **Glue Dev Endpoint SSH Hijacking via Update (GLUE-002)** | Update an existing Glue development endpoint to inject an SSH public key and access its attached role credentials |
| **Glue Job Creation with Privileged Role (GLUE-003)** | Create a Glue job with a privileged role and start it to execute arbitrary code with that role's permissions |
| **Glue Job Creation with Scheduled Trigger and Privileged Role (GLUE-004)** | Create a Glue job with a privileged role and a scheduled trigger to persistently execute arbitrary code |
| **Glue Job Hijacking via Update with Privileged Role (GLUE-005)** | Update an existing Glue job to attach a privileged role and inject malicious code, then start it to gain that role's permissions |
| **Glue Job Hijacking with Scheduled Trigger and Privileged Role (GLUE-006)** | Update an existing Glue job to attach a privileged role and inject malicious code, then create a scheduled trigger for persistent automated execution |
| **Policy Version Override for Self-Escalation (IAM-001)** | Create a new version of an attached policy with administrative permissions, instantly escalating the principal's own privileges |
| **Access Key Creation for Lateral Movement (IAM-002)** | Create access keys for other IAM users to gain their permissions and move laterally across the account |
| **Access Key Rotation Attack for Lateral Movement (IAM-003)** | Delete and recreate access keys for other IAM users to bypass the two-key limit and gain their permissions |
| **Console Login Profile Creation for Lateral Movement (IAM-004)** | Create console login profiles for other IAM users to access the AWS Console with their permissions |
| **Inline Policy Injection for Self-Escalation (IAM-005)** | Attach an inline policy with administrative permissions to your own role, instantly escalating privileges |
| **Console Password Override for Lateral Movement (IAM-006)** | Change the console password of other IAM users to log in as them and gain their permissions |
| **Inline Policy Injection on User for Self-Escalation (IAM-007)** | Attach an inline policy with administrative permissions to your own IAM user, instantly escalating privileges |
| **Managed Policy Attachment on User for Self-Escalation (IAM-008)** | Attach existing managed policies with administrative permissions to your own IAM user, instantly escalating privileges |
| **Managed Policy Attachment on Role for Self-Escalation (IAM-009)** | Attach existing managed policies with administrative permissions to your own IAM role, instantly escalating privileges |
| **Managed Policy Attachment on Group for Self-Escalation (IAM-010)** | Attach existing managed policies with administrative permissions to a group you belong to, escalating privileges for all group members |
| **Inline Policy Injection on Group for Self-Escalation (IAM-011)** | Attach an inline policy with administrative permissions to a group you belong to, escalating privileges for all group members |
| **Trust Policy Hijacking for Role Assumption (IAM-012)** | Modify a role's trust policy to allow yourself to assume it, gaining the role's permissions |
| **Group Membership Hijacking for Privilege Escalation (IAM-013)** | Add yourself to a privileged IAM group to inherit its permissions, gaining access to all policies attached to the group |
| **Managed Policy Attachment with Role Assumption for Lateral Movement (IAM-014)** | Attach administrative managed policies to another role you can assume, then assume it to gain elevated privileges |
| **Managed Policy Attachment with Access Key Creation for Lateral Movement (IAM-015)** | Attach administrative managed policies to another IAM user and create access keys for them to gain programmatic access with elevated privileges |
| **Policy Version Override with Role Assumption for Lateral Movement (IAM-016)** | Create a new version of a customer-managed policy attached to another role with administrative permissions, then assume that role to gain elevated access |
| **Inline Policy Injection with Role Assumption for Lateral Movement (IAM-017)** | Attach an inline policy with administrative permissions to another role you can assume, then assume it to gain elevated privileges |
| **Inline Policy Injection with Access Key Creation for Lateral Movement (IAM-018)** | Attach an inline policy with administrative permissions to another IAM user and create access keys for them to gain programmatic access with elevated privileges |
| **Managed Policy Attachment with Trust Policy Hijacking for Privilege Escalation (IAM-019)** | Attach administrative managed policies to a role and modify its trust policy to allow yourself to assume it, gaining elevated privileges without prior assume-role access |
| **Policy Version Override with Trust Policy Hijacking for Privilege Escalation (IAM-020)** | Create a new version of a customer-managed policy attached to a role with administrative permissions and modify its trust policy to assume it, without prior assume-role access |
| **Inline Policy Injection with Trust Policy Hijacking for Privilege Escalation (IAM-021)** | Add an inline policy with administrative permissions to a role and modify its trust policy to allow yourself to assume it, gaining elevated privileges without prior assume-role access |
| **Lambda Function Creation with Privileged Role (LAMBDA-001)** | Create a Lambda function with a privileged IAM role and invoke it to execute code with that role's permissions |
| **Lambda Function Creation with Event Source Trigger (LAMBDA-002)** | Create a Lambda function with a privileged IAM role and an event source mapping to trigger it automatically, executing code with the role's permissions |
| **Lambda Function Code Injection (LAMBDA-003)** | Modify the code of an existing Lambda function to execute arbitrary commands with the function's execution role permissions |
| **Lambda Function Code Injection with Direct Invocation (LAMBDA-004)** | Modify the code of an existing Lambda function and invoke it directly to execute arbitrary commands with the function's execution role permissions |
| **Lambda Function Code Injection with Resource Policy Grant (LAMBDA-005)** | Modify the code of an existing Lambda function and grant yourself invocation permission via its resource-based policy to execute code with the function's execution role |
| **Lambda Function Creation with Resource Policy Invocation (LAMBDA-006)** | Create a Lambda function with a privileged IAM role and grant yourself invocation permission via its resource-based policy to execute code with the role's permissions |
| **SageMaker Notebook Creation with Privileged Role (SAGEMAKER-001)** | Create a SageMaker notebook instance with a privileged IAM role to execute arbitrary code with the role's permissions via the Jupyter environment |
| **SageMaker Training Job Creation with Privileged Role (SAGEMAKER-002)** | Create a SageMaker training job with a privileged IAM role to execute arbitrary container code with the role's permissions |
| **SageMaker Processing Job Creation with Privileged Role (SAGEMAKER-003)** | Create a SageMaker processing job with a privileged IAM role to execute arbitrary container code with the role's permissions |
| **SageMaker Presigned Notebook URL for Privilege Escalation (SAGEMAKER-004)** | Generate a presigned URL to access an existing SageMaker notebook instance and execute code with its execution role's permissions |
| **SageMaker Notebook Lifecycle Config Injection (SAGEMAKER-005)** | Inject a malicious lifecycle configuration into an existing SageMaker notebook to execute code with the notebook's execution role during startup |
| **SSM Session Access for EC2 Role Credentials (SSM-001)** | Start an SSM session on an EC2 instance to access its attached role credentials through IMDS |
| **SSM Send Command for EC2 Role Credentials (SSM-002)** | Execute commands on an EC2 instance via SSM Run Command to access its attached role credentials through IMDS |
| **Role Assumption for Privilege Escalation (STS-001)** | Assume IAM roles with elevated permissions by exploiting bidirectional trust between the starting principal and the target role |
These tools enable workflows such as:
- Asking an AI assistant to identify privilege escalation paths in a specific AWS account
@@ -0,0 +1,124 @@
---
title: "Findings Triage"
description: "Track finding review status and team notes in Prowler Cloud."
---
import { VersionBadge } from "/snippets/version-badge.mdx"
import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
<VersionBadge version="5.32.0" />
Findings Triage lets teams track review status and notes for individual findings in Prowler Cloud. Use it to record investigation state, remediation work, accepted risk, or false positive decisions without leaving the Findings workflow.
<SubscriptionBanner />
## What Is Findings Triage?
Findings Triage adds a **Triage** status and team note workflow to individual finding rows. It is available from:
- Expanded rows in **Finding Groups**
- Standalone finding tables
- Finding and resource detail drawers, including related findings tables
Finding Groups rows do not show triage controls because a group row represents several findings. Expand a group to work with each affected resource.
![Findings Triage Table](/images/prowler-app/findings-triage/findings-triage-table.png)
## Required Permissions
To update triage statuses and notes, the user role must have the **Manage Scans** permission. For more information, see [Role-Based Access Control (RBAC)](/user-guide/tutorials/prowler-app-rbac).
Users without this permission can still see existing triage context when it is available, but cannot change statuses or save notes.
## Triage Statuses
The status selector includes manual statuses. Prowler also sets automatic statuses after scans.
| Status | Type | Use It When |
| --- | --- | --- |
| **Open** | Manual | A failed finding has not been reviewed yet. A failed finding with no saved triage state also appears as **Open**. |
| **Under Review** | Manual | A team is investigating the finding. |
| **Remediating** | Manual | Work is in progress to fix the finding. |
| **Risk Accepted** | Manual | The team accepts the risk and wants to mute the finding. |
| **False Positive** | Manual | The finding does not apply and should be muted. |
| **Resolved** | Automatic | A finding changed from `FAIL` to `PASS` in a later scan. A passed finding with no saved triage state also appears as **Resolved**. |
| **Reopened** | Automatic | A finding changed from `PASS` to `FAIL` in a later scan. |
![Findings Triage Status Selector](/images/prowler-app/findings-triage/findings-triage-status-dropdown.png)
Resolved and Reopened are not manual selector options.
These automatic states keep triage tied to the finding UID across scans, even when each scan creates a new finding snapshot.
## Change a Triage Status
<Steps>
<Step title="Open Findings">
Go to **Findings** in Prowler Cloud.
</Step>
<Step title="Select an individual finding">
Expand a Finding Group, open a resource findings table, or use a standalone finding row.
</Step>
<Step title="Open the triage selector">
In the **Triage** column, click the current status.
</Step>
<Step title="Choose a status">
Select **Open**, **Under Review**, **Remediating**, **Risk Accepted**, or **False Positive**.
</Step>
</Steps>
Changing a finding to **Risk Accepted** or **False Positive** will mute the finding. Prowler asks for confirmation and creates a mute rule for the finding.
## Add or Edit a Triage Note
Triage notes are visible only to the team in the current organization. Each note supports up to 500 characters.
<Steps>
<Step title="Open the finding actions menu">
On an individual finding row, click the actions menu.
</Step>
<Step title="Open the note modal">
Click **Add Triage Note**. If a note already exists, click **Open note**.
</Step>
<Step title="Set status and note text">
Optionally change the status, then write the note.
</Step>
<Step title="Save changes">
Click **Save changes**.
</Step>
</Steps>
![Findings Triage Note Modal](/images/prowler-app/findings-triage/findings-triage-note-modal.png)
To remove an existing note, clear the note text and save the change.
## Mutelist Behavior
Findings Triage uses Mutelist when a status means the finding should be muted:
- **Risk Accepted** creates a mute rule because the team accepts the finding as a known risk.
- **False Positive** creates a mute rule because the finding should not count as an active issue.
Use [Simple Mutelist](/user-guide/tutorials/prowler-app-simple-mutelist) to review, disable, or delete mute rules created through this workflow. For pattern-based muting, use [Advanced Mutelist](/user-guide/tutorials/prowler-app-mute-findings).
<Warning>
Muting a finding does not fix the underlying configuration. Review the finding before using **Risk Accepted** or **False Positive**.
</Warning>
## Troubleshooting
### Triage controls do not appear
Make sure the row is an individual finding row. Finding Groups rows do not show triage controls. Expand a group to see affected resources and their triage controls.
### Changes cannot be saved
Confirm that the user role has **Manage Scans** permission. Self-hosted Prowler App does not support Findings Triage writes.
### Resolved or Reopened is missing from the selector
This is expected. Prowler sets **Resolved** and **Reopened** automatically from scan result changes.
### Risk Accepted or False Positive muted a finding
This is expected. Those statuses create a mute rule through Mutelist.
@@ -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:
+9 -1
View File
@@ -2,7 +2,15 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [5.32.0] (Prowler UNRELEASED)
## [5.32.1] (Prowler UNRELEASED)
### 🐞 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.33.0"
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.33.0"
[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):
+1 -1
View File
@@ -2,7 +2,7 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.32.0] (Prowler UNRELEASED)
## [1.32.0] (Prowler v5.32.0)
### 🚀 Added
@@ -400,7 +400,6 @@ describe("adaptFindingTriageDetailResponse", () => {
canEdit: true,
noteBody: "Current note visible only inside the modal.",
maxNoteLength: 500,
privacyCopy: "This note is only visible to your team.",
}),
);
});
@@ -1,7 +1,6 @@
import {
FINDING_TRIAGE_BILLING_HREF,
FINDING_TRIAGE_NOTE_MAX_LENGTH,
FINDING_TRIAGE_NOTE_PRIVACY_COPY,
FINDING_TRIAGE_STATUS,
FINDING_TRIAGE_STATUS_LABELS,
type FindingTriageDetail,
@@ -215,6 +214,5 @@ export function adaptFindingTriageDetailResponse(
noteId: attributes.note_id || null,
noteBody,
maxNoteLength: FINDING_TRIAGE_NOTE_MAX_LENGTH,
privacyCopy: FINDING_TRIAGE_NOTE_PRIVACY_COPY,
};
}
@@ -6,8 +6,8 @@ import { LinkToFindings } from "@/components/overview";
import { ColumnLatestFindings } from "@/components/overview/new-findings-table/table";
import { CardTitle } from "@/components/shadcn";
import { DataTable } from "@/components/ui/table";
import { FINDINGS_FILTERED_SORT } from "@/lib";
import { createDict } from "@/lib/helper";
import { FINDINGS_FILTERED_SORT, MUTED_FILTER } from "@/lib";
import { createDict } from "@/lib/utils";
import { FindingProps, SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../_lib/filter-params";
@@ -23,6 +23,7 @@ export async function FindingsViewSSR({ searchParams }: FindingsViewSSRProps) {
const defaultFilters = {
"filter[status]": "FAIL",
"filter[delta]": "new",
"filter[muted]": MUTED_FILTER.EXCLUDE,
};
const filters = pickFilterParams(searchParams);
@@ -44,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>
@@ -14,103 +14,46 @@ describe("client accordion content", () => {
expect(source).not.toContain("getColumnFindings");
});
it("activates a cross-provider branch when scan_ids_by_provider is present", () => {
// The cross-provider mode is detected by the presence of the
// ``scan_ids_by_provider`` augmentation on the requirement. Guarding
// this contract in source keeps regressions visible without spinning a
// full DOM render. The reads happen through a single ``xprov`` cast
// (see ``CrossProviderRequirement``) so we look for both spellings.
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", () => {
// The fetch (per-scan and cross-provider fan-out) lives in
// ``useRequirementFindings`` — the component must not reintroduce inline
// 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");
});
it("detects cross-provider requirements from the scan/check augmentation maps", () => {
// Cross-provider mode is detected by the presence of the
// ``scan_ids_by_provider`` augmentation on the requirement. The reads
// happen through a single ``xprov`` cast (see ``CrossProviderRequirement``)
// and the maps are forwarded to the hook to drive the fan-out fetch.
expect(source).toMatch(/scan_ids_by_provider/);
expect(source).toMatch(/check_ids_by_provider/);
expect(source).toContain("CrossProviderRequirement");
});
it("fetches findings per contributing scan in parallel when in cross-provider mode", () => {
expect(source).toContain("Promise.all");
// Each parallel request scopes to a single scan and the
// requirement's per-provider check IDs.
expect(source).toMatch(/filter\[scan\]/);
expect(source).toMatch(/filter\[check_id__in\]/);
});
it("renders a per-provider breakdown table when providers are exposed", () => {
expect(source).toContain("Per-Provider Breakdown");
});
describe("scope-aware cache invalidation", () => {
// Regression coverage for a real bug: switching the provider-type /
// provider-id / region filters on the cross-provider compliance page
// re-renders an already-expanded requirement with a *different* scan
// scope, but page/pageSize/sort/mutedFilter can stay identical — so a
// guard keyed only on those would wrongly treat the new scope as
// "already loaded" and keep showing findings from the filtered-out
// provider/region.
it("derives the fetch scope from scan/check identity AND region, not just pagination/sort/mute", () => {
expect(source).toMatch(
/const scopeSignature = isCrossProvider\s*\n\s*\?\s*JSON\.stringify\(\{\s*scanIdsByProvider,\s*checkIdsByProvider,\s*region\s*\}\)/,
);
// The per-scan branch's signature must also fold in ``region`` — a
// requirement with the same scanId/check_ids but a different region
// filter is a different fetch.
expect(source).toMatch(
/:\s*`\$\{scanId\}\|\$\{\(requirement\.check_ids \|\| \[\]\)\.join\(","\)\}\|\$\{region\}`/,
);
});
it("includes the scope signature in the already-loaded skip guard", () => {
const guardMatch = source.match(
/if \(\s*\n([\s\S]*?)\n\s*\) \{\s*\n\s*return;/,
);
expect(guardMatch, "expected to find the skip-fetch guard").not.toBe(
null,
);
const guardBody = guardMatch![1];
expect(guardBody).toContain("loadedScopeRef.current === scopeSignature");
// The pre-existing guard members must still be present — this test
// should fail if a refactor drops one of them, not just if scope is
// missing.
expect(guardBody).toContain("loadedPageRef.current === pageNumber");
expect(guardBody).toContain("loadedMutedRef.current === mutedFilter");
});
it("only marks the scope as loaded once the fetch actually commits, for both branches", () => {
// Marking it eagerly (before the fetch resolves) would let a
// superseded in-flight request's cleanup permanently strand the
// component at ``findings === null`` — see the neighboring comment
// in source for the full race explanation.
const commitPattern = /loadedScopeRef\.current = scopeSignature;/g;
const commitIndices = Array.from(source.matchAll(commitPattern)).map(
(match) => match.index!,
);
expect(commitIndices).toHaveLength(2); // cross-provider branch + per-scan branch
for (const index of commitIndices) {
// A cancellation check must precede the commit nearby, and nothing
// awaits in between — i.e. the ref is only written synchronously
// after the fetch has already resolved and the in-flight request
// wasn't superseded by a newer one.
const window = source.slice(Math.max(0, index - 250), index);
const guardIndex = window.search(/if \(cancelled\) return;/);
expect(
guardIndex,
"expected a nearby cancellation check",
).toBeGreaterThanOrEqual(0);
expect(window.slice(guardIndex)).not.toContain("await ");
}
});
it("re-runs the effect when the scope signature changes", () => {
// scopeSignature is derived from requirement/scanIdsByProvider/
// checkIdsByProvider/region, which are already effect dependencies,
// so this mostly guards against someone deleting the dependency
// outright under the (wrong) assumption it's redundant.
const depsMatch = source.match(/\}, \[([\s\S]*?)\]\);/);
expect(
depsMatch,
"expected to find the effect's dependency array",
).not.toBe(null);
expect(depsMatch![1]).toContain("scopeSignature");
});
});
});
@@ -2,28 +2,34 @@
import { AlertTriangle } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMemo } from "react";
import { getFindings } from "@/actions/findings/findings";
import {
loadLatestFindingTriageNote,
updateFindingTriage,
} from "@/actions/findings";
import {
getStandaloneFindingColumns,
SkeletonTableFindings,
} from "@/components/findings/table";
import { ProviderBadgeIcon } from "@/components/icons/providers-badge/provider-badge-icon";
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 { StatusFindingBadge } from "@/components/ui/table/status-finding-badge";
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 { getProviderLabel } from "@/lib/providers/provider-display";
import {
CrossProviderRequirement,
Requirement,
RequirementStatus,
} 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;
@@ -32,14 +38,6 @@ interface ClientAccordionContentProps {
disableFindings?: boolean;
}
// ``included`` is part of the JSON:API envelope but the ``FindingsResponse``
// interface only models ``data`` + ``meta``. Carry it locally so ``createDict``
// (which inspects ``data.included`` at runtime) can resolve the
// provider/scan/resource relationships per row.
type FindingsResponseLike = FindingsResponse & {
included?: { type: string; id: string }[];
};
const toFindingStatus = (status: RequirementStatus) => {
// FindingStatus shares the same wire values for PASS/FAIL/MANUAL.
return status === "No findings" ? "MANUAL" : status;
@@ -51,19 +49,12 @@ export const ClientAccordionContent = ({
scanId,
disableFindings = false,
}: ClientAccordionContentProps) => {
const [findings, setFindings] = useState<FindingsResponse | null>(null);
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 loadedScopeRef = 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
@@ -79,6 +70,9 @@ export const ClientAccordionContent = ({
const providersBreakdown = xprov.providers;
const isCrossProvider =
!!scanIdsByProvider && Object.keys(scanIdsByProvider).length > 0;
const checks = requirement.check_ids || [];
// Identifies *what* findings this requirement should show — which scans,
// checks and region it's scoped to. Provider-type/account/region filters
// on the page narrow the fetch server-side without necessarily changing
@@ -87,190 +81,45 @@ export const ClientAccordionContent = ({
// findings from providers or regions the user just filtered out.
const scopeSignature = isCrossProvider
? JSON.stringify({ scanIdsByProvider, checkIdsByProvider, region })
: `${scanId}|${(requirement.check_ids || []).join(",")}|${region}`;
: `${scanId}|${checks.join(",")}|${region}`;
useEffect(() => {
// Guard against a slower earlier request resolving after a newer one and
// clobbering the table (race on fast page/sort/filter changes).
let cancelled = false;
async function loadFindings() {
if (disableFindings || requirement.status === "No findings") return;
if (
loadedPageRef.current === pageNumber &&
loadedPageSizeRef.current === pageSize &&
loadedSortRef.current === sort &&
loadedMutedRef.current === mutedFilter &&
loadedScopeRef.current === scopeSignature &&
isExpandedRef.current
) {
return;
}
// Mark "loaded" for these exact params only once the fetch actually
// commits (below, right before each ``setFindings``) — not here. If a
// dependency changes (e.g. a sibling re-render gives ``requirement`` a
// new identity) while this fetch is in flight, the effect cleanup
// flips ``cancelled`` and the commit is skipped; marking the refs
// upfront would make the *next* effect run see "already loaded" and
// skip re-fetching too, permanently stranding the component with
// ``findings`` stuck at ``null``.
try {
const encodedSort = sort.replace(/^\+/, "");
if (isCrossProvider) {
// Fetch findings scoped to each contributing scan in parallel
// and merge the JSON:API ``data`` + ``included`` arrays so
// the unified table can resolve the provider/scan/resource
// relationships per row. Server-side filters apply per scan
// (the API enforces RLS on each query individually).
//
// ``scanIdsByProvider[providerKey]`` is a list because a
// tenant can have N accounts of the same type — fan out one
// ``filter[scan]`` request per account, all under the same
// provider key.
const entries = Object.entries(scanIdsByProvider!);
const jobs = entries.flatMap(([providerKey, scanIds]) => {
const checks = (checkIdsByProvider?.[providerKey] ?? []).join(",");
if (!checks || !Array.isArray(scanIds) || scanIds.length === 0) {
return [];
}
return scanIds.map((scanIdForAccount) => ({
providerKey,
scanIdForAccount,
checks,
}));
});
const responses = await Promise.all(
jobs.map(({ scanIdForAccount, checks }) =>
getFindings({
filters: {
"filter[check_id__in]": checks,
"filter[scan]": scanIdForAccount,
"filter[muted]": mutedFilter,
...(region && { "filter[region__in]": region }),
},
page: parseInt(pageNumber, 10),
sort: encodedSort,
}),
),
);
const allData: FindingProps[] = [];
const allIncluded: { type: string; id: string }[] = [];
let totalCount = 0;
for (const r of responses) {
if (!r || !("data" in r)) continue;
const typedResponse = r as FindingsResponseLike;
allData.push(...(typedResponse.data || []));
allIncluded.push(...(typedResponse.included || []));
totalCount += typedResponse?.meta?.pagination?.count || 0;
}
// Each scan response includes its provider/scan record;
// across N responses the same provider object appears N
// times. Dedupe by ``(type, id)`` so the subsequent
// ``createDict`` passes stop allocating duplicate entries.
const dedupedIncluded: typeof allIncluded = [];
const seenIncluded = new Set<string>();
for (const entry of allIncluded) {
const key = `${entry.type}|${entry.id}`;
if (seenIncluded.has(key)) continue;
seenIncluded.add(key);
dedupedIncluded.push(entry);
}
const merged: FindingsResponseLike = {
data: allData,
included: dedupedIncluded,
meta: {
pagination: {
page: parseInt(pageNumber, 10),
pages: 1,
count: totalCount,
},
version: "",
},
};
if (cancelled) return;
loadedPageRef.current = pageNumber;
loadedPageSizeRef.current = pageSize;
loadedSortRef.current = sort;
loadedMutedRef.current = mutedFilter;
loadedScopeRef.current = scopeSignature;
isExpandedRef.current = true;
setFindings(merged);
return;
}
// Per-scan branch (existing behaviour).
if (!requirement.check_ids?.length) return;
const checkIds = requirement.check_ids;
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,
});
if (cancelled) return;
loadedPageRef.current = pageNumber;
loadedPageSizeRef.current = pageSize;
loadedSortRef.current = sort;
loadedMutedRef.current = mutedFilter;
loadedScopeRef.current = scopeSignature;
isExpandedRef.current = true;
setFindings(findingsData);
} catch (error) {
console.error("Error loading findings:", error);
}
}
loadFindings();
return () => {
cancelled = true;
};
}, [
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,
scopeSignature,
disableFindings,
isCrossProvider,
scanIdsByProvider,
checkIdsByProvider,
]);
scopeSignature,
});
// Expand each finding with its resource/scan/provider. Derived from
// ``findings`` rather than stored as separate state so the table can never
// drift out of sync with the fetched rows.
const expandedFindings = useMemo<FindingProps[]>(() => {
if (!findings?.data) return [];
const resourceDict = createDict("resources", findings);
const scanDict = createDict("scans", findings);
const providerDict = createDict("providers", findings);
return findings.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 },
};
}) as unknown as FindingProps[];
}, [findings]);
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);
};
// Per-provider finding tallies for the cross-provider breakdown. Derived
// from the merged ``findings`` (mapping each row to its provider via
@@ -419,7 +268,6 @@ export const ClientAccordionContent = ({
);
}
const checks = requirement.check_ids || [];
// In cross-provider mode the universal framework declares the same
// requirement against multiple providers, often with disjoint check
// sets. Show a per-provider grouping so the user can audit which checks
@@ -503,7 +351,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 />;
}
@@ -513,8 +380,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,398 @@
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("fans out one fetch per contributing scan and merges the counts in cross-provider mode", async () => {
// Given: two providers, one scan each, with disjoint check sets.
findingsActionsMock.getFindings.mockReset();
findingsActionsMock.getFindings
.mockResolvedValueOnce({
data: [
{
id: "finding-a",
attributes: { status: "FAIL" },
relationships: { scan: { data: { id: "scan-a" } } },
},
],
included: [{ type: "scans", id: "scan-a" }],
meta: { pagination: { count: 1, pages: 1 } },
})
.mockResolvedValueOnce({
data: [
{
id: "finding-b",
attributes: { status: "PASS" },
relationships: { scan: { data: { id: "scan-b" } } },
},
],
included: [{ type: "scans", id: "scan-b" }],
meta: { pagination: { count: 2, pages: 1 } },
});
// When
const { result } = renderHook(() =>
useRequirementFindings(
defaultOptions({
isCrossProvider: true,
scanIdsByProvider: { aws: ["scan-a"], azure: ["scan-b"] },
checkIdsByProvider: { aws: ["check_a"], azure: ["check_b"] },
scopeSignature: "scope-1",
}),
),
);
await flushAsync();
// Then: one request per scan, each scoped to that provider's checks.
expect(findingsActionsMock.getFindings).toHaveBeenCalledTimes(2);
expect(findingsActionsMock.getFindings).toHaveBeenCalledWith(
expect.objectContaining({
filters: expect.objectContaining({
"filter[scan]": "scan-a",
"filter[check_id__in]": "check_a",
}),
}),
);
// And the responses are merged: rows concatenated, counts summed.
expect(result.current.findings?.data).toHaveLength(2);
expect(result.current.findings?.meta?.pagination?.count).toBe(3);
});
it("should not fetch in cross-provider mode when no scans contribute", async () => {
// Given / When
renderHook(() =>
useRequirementFindings(
defaultOptions({
isCrossProvider: true,
scanIdsByProvider: {},
checkIdsByProvider: {},
scopeSignature: "empty",
}),
),
);
await flushAsync();
// Then
expect(findingsActionsMock.getFindings).not.toHaveBeenCalled();
});
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,281 @@
"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";
// ``included`` is part of the JSON:API envelope but ``FindingsResponse`` only
// models ``data`` + ``meta``. Carry it locally so ``createDict`` (which reads
// ``data.included`` at runtime) can resolve the provider/scan/resource
// relationships per row.
type FindingsResponseLike = FindingsResponse & {
included?: { type: string; id: string }[];
};
interface UseRequirementFindingsOptions {
enabled: boolean;
checkIds: string[];
scanId: string;
pageNumber: string;
pageSize: string;
sort: string;
region: string;
mutedFilter: string;
// Cross-provider augmentation (universal frameworks). When
// ``isCrossProvider`` is true the hook fans out one fetch per contributing
// scan and merges the JSON:API ``data`` + ``included`` arrays instead of
// issuing a single per-scan request.
isCrossProvider?: boolean;
scanIdsByProvider?: Record<string, string[]>;
checkIdsByProvider?: Record<string, string[]>;
// Identifies *what* findings the fetch is scoped to (scans, checks,
// region). Provider/account/region filters narrow the fetch server-side
// without changing page/sort/mute, so this is the canonical trigger for a
// fresh cross-provider fetch — it serializes ``scanIdsByProvider`` /
// ``checkIdsByProvider`` (which get a fresh identity on every parent render
// and so can't be effect dependencies themselves).
scopeSignature?: 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.";
// Merge the per-scan responses of a cross-provider fan-out into a single
// JSON:API-shaped envelope: concatenate every ``data`` row, dedupe the
// ``included`` records by ``(type, id)`` (the same provider/scan object
// repeats across responses), and sum the per-scan counts.
const mergeCrossProviderResponses = (
responses: unknown[],
page: number,
): FindingsResponseLike => {
const allData: FindingProps[] = [];
const allIncluded: { type: string; id: string }[] = [];
let totalCount = 0;
for (const r of responses) {
if (!r || typeof r !== "object" || !("data" in r)) continue;
const typedResponse = r as FindingsResponseLike;
allData.push(...(typedResponse.data || []));
allIncluded.push(...(typedResponse.included || []));
totalCount += typedResponse?.meta?.pagination?.count || 0;
}
const dedupedIncluded: typeof allIncluded = [];
const seenIncluded = new Set<string>();
for (const entry of allIncluded) {
const key = `${entry.type}|${entry.id}`;
if (seenIncluded.has(key)) continue;
seenIncluded.add(key);
dedupedIncluded.push(entry);
}
return {
data: allData,
included: dedupedIncluded,
meta: {
pagination: { page, pages: 1, count: totalCount },
version: "",
},
};
};
export function useRequirementFindings({
enabled,
checkIds,
scanId,
pageNumber,
pageSize,
sort,
region,
mutedFilter,
isCrossProvider = false,
scanIdsByProvider,
checkIdsByProvider,
scopeSignature,
}: 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 hasCrossProviderScans = Boolean(
scanIdsByProvider && Object.keys(scanIdsByProvider).length > 0,
);
const isFetchEnabled =
enabled &&
(isCrossProvider ? hasCrossProviderScans : 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;
// Derive the table rows from a fetched response, resolving each finding's
// scan/resource/provider from the ``included`` envelope. Kept as state
// (not a render-time memo) so ``patchTriageUpdate`` can optimistically
// mutate a single row without a refetch.
const expandFindings = (findingsData: FindingsResponseLike) => {
if (!findingsData?.data) return;
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 },
};
// The resolved scan/resource/provider come from ``createDict`` as
// ``IncludedApiItem`` records, which don't line up with the strict
// ``FindingProps.relationships`` shape; the table consumes them by
// the same keys, so narrow back to ``FindingProps`` at the seam.
}) as unknown as FindingProps[];
setExpandedFindings(expandedData);
};
const loadFindings = async () => {
setError(null);
try {
const encodedSort = sort.replace(/^\+/, "");
if (isCrossProvider) {
// Fetch findings scoped to each contributing scan in parallel and
// merge them. ``scanIdsByProvider[providerKey]`` is a list because a
// tenant can have N accounts of the same type — fan out one
// ``filter[scan]`` request per account, all under the same provider
// key, scoped to that provider's declared checks. Server-side
// filters (RLS, muted, region) apply per query individually.
const jobs = Object.entries(scanIdsByProvider ?? {}).flatMap(
([providerKey, scanIds]) => {
const checks = (checkIdsByProvider?.[providerKey] ?? []).join(
",",
);
if (!checks || !Array.isArray(scanIds) || scanIds.length === 0) {
return [];
}
return scanIds.map((scanIdForAccount) => ({
scanIdForAccount,
checks,
}));
},
);
const responses = await Promise.all(
jobs.map(({ scanIdForAccount, checks }) =>
getFindings({
filters: {
"filter[check_id__in]": checks,
"filter[scan]": scanIdForAccount,
"filter[muted]": mutedFilter,
...(region && { "filter[region__in]": region }),
},
page: parseInt(pageNumber, 10),
sort: encodedSort,
}),
),
);
if (cancelled) return;
const merged = mergeCrossProviderResponses(
responses,
parseInt(pageNumber, 10),
);
setFindings(merged);
expandFindings(merged);
return;
}
// Per-scan branch (single request).
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: encodedSort,
});
if (cancelled) return;
setFindings(findingsData);
expandFindings(findingsData as FindingsResponseLike);
} catch (error) {
if (!cancelled) {
console.error("Error loading findings:", error);
setError(FINDINGS_LOAD_ERROR);
}
}
};
loadFindings();
return () => {
cancelled = true;
};
// ``scanIdsByProvider`` / ``checkIdsByProvider`` are intentionally read
// via ``scopeSignature`` rather than listed directly: they get a fresh
// identity on every parent render and would trigger a refetch storm.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
isFetchEnabled,
isCrossProvider,
checkIdsKey,
scanId,
pageNumber,
pageSize,
sort,
region,
mutedFilter,
scopeSignature,
reloadNonce,
]);
const patchTriageUpdate = (input: UpdateFindingTriageInput) => {
setExpandedFindings((currentFindings) =>
applyOptimisticFindingTriageRowsUpdate(currentFindings, input),
);
};
const reload = () => {
setReloadNonce((value) => value + 1);
};
return {
findings,
expandedFindings,
isLoading,
error,
patchTriageUpdate,
reload,
};
}
@@ -7,6 +7,13 @@ import type {
} from "react";
import { describe, expect, it, vi } from "vitest";
// CustomLink pulls the "@/lib" barrel (and next-auth with it) into the unit env.
vi.mock("@/components/ui/custom/custom-link", () => ({
CustomLink: ({ href, children }: { href: string; children: ReactNode }) => (
<a href={href}>{children}</a>
),
}));
vi.mock("@/components/shadcn", () => ({
Button: ({ children, ...props }: ButtonHTMLAttributes<HTMLButtonElement>) => (
<button {...props}>{children}</button>
@@ -394,6 +401,38 @@ describe("column-finding-resources", () => {
expect(screen.getByText(EDITING_UNAVAILABLE_COPY)).toBeInTheDocument();
});
it("should keep the compact Triage label on resource cells for headerless nested rows", () => {
// Given
const columns = getColumnFindingResources({
rowSelection: {},
selectableRowCount: 1,
});
const triageColumn = columns.find(
(col) => (col as { id?: string }).id === "triage",
);
if (!triageColumn?.cell) {
throw new Error("triage column not found");
}
const CellComponent = triageColumn.cell as (props: {
row: { original: FindingResourceRow };
}) => ReactNode;
// When
render(
<div>
{CellComponent({
row: {
original: makeResource({ triage: makeTriageSummary() }),
},
})}
</div>,
);
// Then — expanded finding-group rows render without a header row, so the
// cell itself must carry the label, like Service/Region/Last seen do.
expect(screen.getByText("Triage")).toBeInTheDocument();
});
it("should disable non-paying Cloud triage control with only-in-Cloud tooltip copy", () => {
// Given
const columns = getColumnFindingResources({
@@ -354,17 +354,20 @@ export function getColumnFindingResources({
},
enableSorting: false,
},
// Triage
// Triage — keep the compact label: these cells also render inside
// expanded finding-group rows, which have no header row of their own.
{
id: "triage",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Triage" />
),
cell: ({ row }) => (
<FindingTriageStatusCell
triage={row.original.triage}
onTriageUpdateAction={onTriageUpdateAction}
/>
<InfoField label="Triage" variant="compact">
<FindingTriageStatusCell
triage={row.original.triage}
onTriageUpdateAction={onTriageUpdateAction}
/>
</InfoField>
),
enableSorting: false,
},
@@ -5,6 +5,13 @@ vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: vi.fn() }),
}));
// CustomLink pulls the "@/lib" barrel (and next-auth with it) into the unit env.
vi.mock("@/components/ui/custom/custom-link", () => ({
CustomLink: ({ href, children }: { href: string; children: ReactNode }) => (
<a href={href}>{children}</a>
),
}));
vi.mock("@/components/findings/mute-findings-modal", () => ({
MuteFindingsModal: () => null,
}));
@@ -67,7 +67,7 @@ function FindingTitleCell({
finding={finding}
defaultOpen={defaultOpen}
trigger={
<div className="max-w-[500px]">
<div className="max-w-[500px] min-w-[160px]">
<p className="text-text-neutral-primary hover:text-button-tertiary cursor-pointer text-left text-sm break-words whitespace-normal hover:underline">
{finding.attributes.check_metadata.checktitle}
</p>
@@ -9,6 +9,13 @@ vi.mock("@/components/icons/providers-badge/provider-type-icon", () => ({
),
}));
// CustomLink pulls the "@/lib" barrel (and next-auth with it) into the unit env.
vi.mock("@/components/ui/custom/custom-link", () => ({
CustomLink: ({ href, children }: { href: string; children: ReactNode }) => (
<a href={href}>{children}</a>
),
}));
vi.mock("@/components/shadcn/modal", () => ({
Modal: ({
children,
@@ -42,9 +49,9 @@ beforeAll(() => {
});
});
import { DOCS_URLS } from "@/lib/external-urls";
import {
FINDING_TRIAGE_DISABLED_REASON,
FINDING_TRIAGE_NOTE_PRIVACY_COPY,
FINDING_TRIAGE_STATUS,
type FindingTriageDetail,
type UpdateFindingTriageInput,
@@ -72,7 +79,6 @@ function makeTriageDetail(
noteId: "note-1",
noteBody: "Existing investigation note",
maxNoteLength: 500,
privacyCopy: FINDING_TRIAGE_NOTE_PRIVACY_COPY,
...overrides,
};
}
@@ -142,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();
@@ -152,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({
@@ -184,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({
@@ -209,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({
@@ -234,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(
@@ -246,7 +267,45 @@ describe("FindingNoteModal", () => {
expect(onOpenChange).not.toHaveBeenCalledWith(false);
});
it("should render counter, privacy copy, and cancel/update actions", async () => {
it("should lock the status picker for resolved findings while keeping the note editable", async () => {
// Given
const user = userEvent.setup();
const onTriageUpdateAction = vi.fn();
renderNoteModal({
triage: makeTriageDetail({
status: FINDING_TRIAGE_STATUS.RESOLVED,
label: "Resolved",
}),
onTriageUpdateAction,
});
// Then — automation owns the transition out of Resolved.
expect(
screen.getByRole("combobox", { name: "Triage status" }),
).toBeDisabled();
expect(
screen.getByText(
"Triage status is managed automatically once the finding is resolved.",
),
).toBeVisible();
expect(screen.getByLabelText("Note text")).toBeEnabled();
// When — the note itself can still be updated.
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" }));
// Then
expect(onTriageUpdateAction).toHaveBeenCalledWith(
expect.objectContaining({ note: "Documenting the resolution." }),
);
expect(onTriageUpdateAction).toHaveBeenCalledWith(
expect.not.objectContaining({ status: expect.anything() }),
);
});
it("should render counter and cancel/update actions without privacy copy", async () => {
// Given
const user = userEvent.setup();
const onOpenChange = vi.fn();
@@ -258,12 +317,12 @@ describe("FindingNoteModal", () => {
// Then
expect(screen.getByText("3/500")).toBeInTheDocument();
expect(screen.getByText(FINDING_TRIAGE_NOTE_PRIVACY_COPY)).toBeVisible();
expect(
screen.queryByText("This note is only visible to your team."),
).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", () => {
@@ -280,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");
@@ -305,13 +364,17 @@ describe("FindingNoteModal", () => {
await user.click(screen.getByRole("option", { name: "Risk Accepted" }));
// Then
expect(screen.getByText(/will be muted/i)).toBeVisible();
expect(
screen.getByText(
"Changing triage to Risk Accepted will mute the finding",
),
).toBeVisible();
await waitFor(() =>
expect(screen.queryByRole("listbox")).not.toBeInTheDocument(),
);
// When
await user.click(screen.getByRole("button", { name: "Save changes" }));
await user.click(screen.getByRole("button", { name: "Save" }));
// Then
await waitFor(() =>
@@ -1,18 +1,28 @@
"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";
import { Alert, AlertDescription, Button, Textarea } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge";
import { DOCS_URLS } from "@/lib/external-urls";
import {
FINDING_TRIAGE_DISABLED_REASON,
FINDING_TRIAGE_ORIGIN,
FINDING_TRIAGE_RESOLVED_LOCKED_COPY,
FINDING_TRIAGE_STATUS,
type FindingTriageDetail,
type FindingTriageStatus,
getFindingTriageMuteInfoCopy,
isMutelistShortcutStatus,
isTriageStatusLocked,
} from "@/types/findings-triage";
import type { ProviderType } from "@/types/providers";
@@ -37,10 +47,8 @@ interface FindingNoteModalProps {
onTriageUpdateAction?: FindingTriageUpdateHandler;
}
const MUTELIST_INFO_COPY =
"This finding will be muted through the existing Mutelist flow.";
const REMEDIATING_INFO_COPY =
"Once this finding is fixed and passes in the next scan, it will be automatically changed to Resolved.";
"Once this finding is remediated, if in the following scan its status changes to Pass, it will be automatically changed to Resolved";
export function FindingNoteModal({
open,
@@ -68,6 +76,7 @@ export function FindingNoteModal({
isMutelistShortcutStatus(selectedStatus);
const shouldShowRemediatingInfo =
selectedStatus === FINDING_TRIAGE_STATUS.REMEDIATING;
const isStatusLocked = isTriageStatusLocked(triage.status);
// Opened from a dropdown item: move focus into the dialog on mount so Radix's
// aria-hidden is not applied to the still-focused dropdown that opened it.
const handleOpenAutoFocus = (event: Event) => {
@@ -118,23 +127,46 @@ export function FindingNoteModal({
title="Add Triage Note"
size="lg"
>
<form className="flex 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">
{/* min-w-0: the form is a grid item of DialogContent; without it, long
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="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() ?? "—"}
</span>
)}
</div>
<div>
<p className="text-text-neutral-primary text-sm font-semibold">
{findingContext.title}
</p>
<div className="min-w-0">
<Tooltip>
<TooltipTrigger asChild>
<p className="text-text-neutral-primary truncate text-sm font-semibold">
{findingContext.title}
</p>
</TooltipTrigger>
<TooltipContent>{findingContext.title}</TooltipContent>
</Tooltip>
{(findingContext.resource || findingContext.provider) && (
<p className="text-text-neutral-secondary mt-1 text-xs">
<p className="text-text-neutral-secondary mt-1 truncate text-xs">
{[findingContext.resource, findingContext.provider]
.filter(Boolean)
.join(" · ")}
@@ -143,27 +175,39 @@ export function FindingNoteModal({
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center justify-end gap-3">
<span className="text-text-neutral-primary text-sm font-semibold">
Status:
</span>
<FindingTriageStatusControl
origin={FINDING_TRIAGE_ORIGIN.MODAL}
triage={triage}
value={selectedStatus}
onValueChange={setSelectedStatus}
/>
<div className="w-1/2 min-w-44">
<FindingTriageStatusControl
origin={FINDING_TRIAGE_ORIGIN.MODAL}
triage={triage}
value={selectedStatus}
onValueChange={setSelectedStatus}
/>
</div>
</div>
{isStatusLocked && (
<Alert variant="info">
<AlertDescription>
{FINDING_TRIAGE_RESOLVED_LOCKED_COPY}
</AlertDescription>
</Alert>
)}
{shouldShowMutelistInfo && (
<Alert variant="warning">
<AlertDescription>{MUTELIST_INFO_COPY}</AlertDescription>
<AlertDescription>
{getFindingTriageMuteInfoCopy(selectedStatus)}
</AlertDescription>
</Alert>
)}
{shouldShowRemediatingInfo && (
<Alert variant="info">
<AlertDescription>{REMEDIATING_INFO_COPY}</AlertDescription>
<AlertDescription>{REMEDIATING_INFO_COPY}.</AlertDescription>
</Alert>
)}
@@ -184,20 +228,19 @@ export function FindingNoteModal({
textareaSize="lg"
onChange={(event) => setNote(event.target.value)}
/>
<div className="flex items-center justify-between gap-3">
<p className="text-text-neutral-tertiary text-xs">
{triage.privacyCopy}
</p>
<div className="flex items-center justify-end">
<p className="text-text-neutral-tertiary shrink-0 text-xs">
{note.length}/{triage.maxNoteLength}
</p>
</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)}
>
@@ -217,7 +260,7 @@ export function FindingNoteModal({
{isSubmitting
? "Saving..."
: canSubmit || isCloudOnly
? "Save changes"
? "Save"
: "Unavailable"}
</Button>
</span>
@@ -8,14 +8,17 @@ vi.mock("@/components/shadcn/modal", () => ({
children,
open,
title,
description,
}: {
children: ReactNode;
open: boolean;
title?: string;
description?: string;
}) =>
open ? (
<div role="dialog" aria-label={title}>
<h2>{title}</h2>
{description && <p>{description}</p>}
{children}
</div>
) : null,
@@ -27,6 +30,13 @@ vi.mock("next/navigation", () => ({
}),
}));
// CustomLink pulls the "@/lib" barrel (and next-auth with it) into the unit env.
vi.mock("@/components/ui/custom/custom-link", () => ({
CustomLink: ({ href, children }: { href: string; children: ReactNode }) => (
<a href={href}>{children}</a>
),
}));
vi.mock("@/components/shadcn/dropdown", () => ({
ActionDropdownItem: ({
label,
@@ -66,6 +76,7 @@ import {
import {
FindingNoteActionItem,
FindingTriageStatusBadge,
FindingTriageStatusCell,
} from "./finding-triage-cells";
@@ -162,11 +173,6 @@ describe("finding triage cells", () => {
await user.click(statusControl);
// Then
expect(screen.getByText("Triage").parentElement).toHaveClass(
"text-text-neutral-secondary",
"text-[10px]",
"whitespace-nowrap",
);
expect(statusControl.parentElement).toHaveClass("w-32");
expect(statusControl).toHaveAttribute("data-size", "xs");
expect(within(statusControl).getByText("Under Review")).toHaveClass(
@@ -197,6 +203,22 @@ describe("finding triage cells", () => {
).toHaveClass("text-text-neutral-secondary");
});
it("renders a read-only triage status badge with the status color", () => {
// Given / When
render(
<FindingTriageStatusBadge
triage={makeTriageSummary({
status: FINDING_TRIAGE_STATUS.REMEDIATING,
label: "Remediating",
})}
/>,
);
// Then
expect(screen.getByText("Triage:")).toBeInTheDocument();
expect(screen.getByText("Remediating")).toHaveClass("text-bg-data-info");
});
it("should disable table status mutation when no update handler is wired", async () => {
// Given
const user = userEvent.setup();
@@ -222,6 +244,57 @@ describe("finding triage cells", () => {
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
it("should lock the table status picker for resolved findings", async () => {
// Given
const user = userEvent.setup();
const onTriageUpdateAction = vi.fn();
render(
<FindingTriageStatusCell
triage={makeTriageSummary({
status: FINDING_TRIAGE_STATUS.RESOLVED,
label: "Resolved",
})}
onTriageUpdateAction={onTriageUpdateAction}
/>,
);
const statusControl = screen.getByRole("combobox", {
name: "Triage status",
});
// When
await user.click(statusControl);
// Then — automation owns the transition out of Resolved.
expect(statusControl).toBeDisabled();
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
expect(
screen.getAllByText(
"Triage status is managed automatically once the finding is resolved.",
).length,
).toBeGreaterThan(0);
expect(onTriageUpdateAction).not.toHaveBeenCalled();
});
it("should keep the note action available for resolved findings", () => {
// Given
render(
<FindingNoteActionItem
triage={makeTriageSummary({
status: FINDING_TRIAGE_STATUS.RESOLVED,
label: "Resolved",
hasVisibleNote: false,
})}
onTriageUpdateAction={vi.fn()}
/>,
);
// Then — the lock only applies to status transitions, not notes.
expect(
screen.getByRole("button", { name: "Add Triage Note" }),
).toBeEnabled();
});
it("should not open an editable empty-note modal for an existing note without a loader", async () => {
// Given
const user = userEvent.setup();
@@ -300,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");
@@ -647,6 +720,11 @@ describe("finding triage cells", () => {
// Then: the user is warned before the server action handles muting.
expect(screen.getByRole("dialog", { name: "Mute finding?" })).toBeVisible();
expect(
screen.getByText(
"Changing triage to False Positive will mute the finding",
),
).toBeVisible();
expect(onTriageUpdateAction).not.toHaveBeenCalled();
// When
@@ -9,16 +9,18 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { cn } from "@/lib/utils";
import {
FINDING_TRIAGE_DISABLED_REASON,
FINDING_TRIAGE_NOTE_MAX_LENGTH,
FINDING_TRIAGE_NOTE_PRIVACY_COPY,
FINDING_TRIAGE_ORIGIN,
FINDING_TRIAGE_RESOLVED_LOCKED_COPY,
FINDING_TRIAGE_STATUS_LABELS,
type FindingTriageDetail,
type FindingTriageLoadedNote,
type FindingTriageStatus,
type FindingTriageSummary,
isTriageStatusLocked,
type UpdateFindingTriageInput,
} from "@/types/findings-triage";
@@ -29,6 +31,7 @@ import {
import {
FindingTriageStatusControl,
type FindingTriageUpdateHandler,
TRIAGE_STATUS_TEXT_CLASS,
} from "./finding-triage-status-control";
export const CLOUD_ONLY_TOOLTIP_COPY = "Available in Prowler Cloud";
@@ -37,14 +40,21 @@ export const EDITING_UNAVAILABLE_COPY = "Editing is currently unavailable.";
const getDisabledCopy = ({
triage,
hasUpdateHandler,
lockResolved = false,
}: {
triage: FindingTriageSummary;
hasUpdateHandler: boolean;
lockResolved?: boolean;
}): string | undefined => {
if (triage.disabledReason === FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY) {
return CLOUD_ONLY_TOOLTIP_COPY;
}
// Status-picker only: notes stay available on resolved findings.
if (lockResolved && isTriageStatusLocked(triage.status)) {
return FINDING_TRIAGE_RESOLVED_LOCKED_COPY;
}
if (triage.canEdit && !hasUpdateHandler) {
return EDITING_UNAVAILABLE_COPY;
}
@@ -60,7 +70,6 @@ const getTriageDetailFromSummary = (
noteId: loadedNote?.noteId ?? null,
noteBody: loadedNote?.noteBody ?? "",
maxNoteLength: FINDING_TRIAGE_NOTE_MAX_LENGTH,
privacyCopy: FINDING_TRIAGE_NOTE_PRIVACY_COPY,
});
export function FindingTriageStatusCell({
@@ -151,6 +160,7 @@ export function FindingTriageStatusCell({
const disabledCopy = getDisabledCopy({
triage,
hasUpdateHandler: Boolean(onTriageUpdateAction),
lockResolved: true,
});
if (!disabledCopy) {
return control;
@@ -159,8 +169,7 @@ export function FindingTriageStatusCell({
return (
<Tooltip>
<TooltipTrigger asChild>
{/* Block-level so the tooltip wrapper doesn't add inline baseline spacing
that would push the compact "Triage" label below the sibling columns. */}
{/* Block-level wrapper keeps the picker aligned with the sibling columns. */}
<span className="flex">{control}</span>
</TooltipTrigger>
<TooltipContent>{disabledCopy}</TooltipContent>
@@ -168,6 +177,28 @@ export function FindingTriageStatusCell({
);
}
// Read-only triage status indicator, e.g. for the side drawer header where the
// editable picker would be out of place among the status/severity badges.
export function FindingTriageStatusBadge({
triage,
}: {
triage: FindingTriageSummary;
}) {
return (
<div className="flex items-center gap-1">
<span className="text-text-neutral-tertiary text-xs">Triage:</span>
<span
className={cn(
"text-xs font-medium",
TRIAGE_STATUS_TEXT_CLASS[triage.status],
)}
>
{triage.label}
</span>
</div>
);
}
export function FindingNoteActionItem({
triage,
findingContext = { title: "Finding" },
@@ -3,7 +3,6 @@
import { type ComponentProps, useState } from "react";
import { Button } from "@/components/shadcn";
import { InfoField } from "@/components/shadcn/info-field/info-field";
import { Modal } from "@/components/shadcn/modal";
import {
Select,
@@ -19,8 +18,10 @@ import {
type FindingTriageManualStatus,
type FindingTriageStatus,
type FindingTriageSummary,
getFindingTriageMuteInfoCopy,
isManualStatus,
isMutelistShortcutStatus,
isTriageStatusLocked,
type UpdateFindingTriageInput,
} from "@/types/findings-triage";
@@ -32,7 +33,7 @@ type TriageStatusPickerSize = NonNullable<
ComponentProps<typeof SelectTrigger>["size"]
>;
const TRIAGE_STATUS_TEXT_CLASS = {
export const TRIAGE_STATUS_TEXT_CLASS = {
open: "text-text-error-primary",
under_review: "text-text-warning-primary",
remediating: "text-bg-data-info",
@@ -43,8 +44,6 @@ const TRIAGE_STATUS_TEXT_CLASS = {
} as const satisfies Record<FindingTriageStatus, string>;
const MUTELIST_CONFIRMATION_TITLE = "Mute finding?";
const MUTELIST_CONFIRMATION_COPY =
"Changing to this triage status will mute the finding.";
function TriageStatusPicker({
disabled,
@@ -119,7 +118,7 @@ export function FindingTriageStatusControl(
if (props.origin === FINDING_TRIAGE_ORIGIN.MODAL) {
return (
<TriageStatusPicker
disabled={!triage.canEdit}
disabled={!triage.canEdit || isTriageStatusLocked(triage.status)}
value={props.value}
onValueChange={props.onValueChange}
/>
@@ -127,7 +126,10 @@ export function FindingTriageStatusControl(
}
const canMutateFromTable =
triage.canEdit && Boolean(props.onTriageUpdateAction) && !isTableUpdating;
triage.canEdit &&
Boolean(props.onTriageUpdateAction) &&
!isTableUpdating &&
!isTriageStatusLocked(triage.status);
const applyTableStatus = async (status: FindingTriageManualStatus) => {
if (!props.onTriageUpdateAction || status === triage.status) {
@@ -174,16 +176,14 @@ export function FindingTriageStatusControl(
return (
<>
<InfoField label="Triage" variant="compact">
<div className="w-32">
<TriageStatusPicker
disabled={!canMutateFromTable}
size="xs"
value={triage.status}
onValueChange={handleTableValueChange}
/>
</div>
</InfoField>
<div className="w-32">
<TriageStatusPicker
disabled={!canMutateFromTable}
size="xs"
value={triage.status}
onValueChange={handleTableValueChange}
/>
</div>
{tableUpdateError && (
<span className="sr-only" role="alert">
{tableUpdateError}
@@ -197,7 +197,11 @@ export function FindingTriageStatusControl(
}
}}
title={MUTELIST_CONFIRMATION_TITLE}
description={MUTELIST_CONFIRMATION_COPY}
description={
pendingShortcutStatus
? getFindingTriageMuteInfoCopy(pendingShortcutStatus)
: undefined
}
size="sm"
>
<div className="flex justify-end gap-2 pt-2">
@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
import {
FINDING_TRIAGE_NOTE_MAX_LENGTH,
FINDING_TRIAGE_NOTE_PRIVACY_COPY,
FINDING_TRIAGE_STATUS,
type FindingTriageDetail,
} from "@/types/findings-triage";
@@ -26,7 +25,6 @@ function makeTriageDetail(
noteId: "note-1",
noteBody: "Existing investigation note",
maxNoteLength: FINDING_TRIAGE_NOTE_MAX_LENGTH,
privacyCopy: FINDING_TRIAGE_NOTE_PRIVACY_COPY,
...overrides,
};
}
@@ -461,6 +461,12 @@ vi.mock("../finding-triage-cells", () => ({
) : (
<span>-</span>
),
FindingTriageStatusBadge: ({ triage }: { triage: { label: string } }) => (
<div>
<span>Triage:</span>
<span>{triage.label}</span>
</div>
),
}));
vi.mock("./resource-detail-skeleton", () => ({
@@ -84,6 +84,7 @@ import { Muted } from "../../muted";
import { DeltaIndicator } from "../delta-indicator";
import {
FindingNoteActionItem,
FindingTriageStatusBadge,
FindingTriageStatusCell,
} from "../finding-triage-cells";
import { DeltaValues, NotificationIndicator } from "../notification-indicator";
@@ -560,6 +561,7 @@ export function ResourceDetailDrawerContent({
{findingIsMuted !== undefined && (
<Muted isMuted={findingIsMuted} mutedReason={findingMutedReason} />
)}
{findingTriage && <FindingTriageStatusBadge triage={findingTriage} />}
</div>
{showCheckMetaContent ? (
@@ -2,10 +2,18 @@
import { ColumnDef } from "@tanstack/react-table";
import {
loadLatestFindingTriageNote,
updateFindingTriage,
} from "@/actions/findings";
import { getStandaloneFindingColumns } from "@/components/findings/table/column-standalone-findings";
import { FindingProps } from "@/types";
export const ColumnLatestFindings: ColumnDef<FindingProps>[] =
getStandaloneFindingColumns({
includeUpdatedAt: true,
onTriageUpdateAction: async (input) => {
await updateFindingTriage(input);
},
onTriageNoteLoadAction: loadLatestFindingTriageNote,
});
@@ -93,7 +93,7 @@ describe("LaunchStep", () => {
);
// Then
expect(screen.getByText("Account Connected!")).toBeInTheDocument();
expect(screen.getByText("Provider Connected!")).toBeInTheDocument();
expect(
screen.getByRole("radio", { name: "On a schedule" }),
).toBeChecked();
@@ -296,11 +296,11 @@ export function LaunchStep({
<div className="flex items-center gap-3">
<TreeStatusIcon status={TREE_ITEM_STATUS.SUCCESS} className="size-6" />
<h3 className="text-sm font-semibold">Account Connected!</h3>
<h3 className="text-sm font-semibold">Provider Connected!</h3>
</div>
<p className="text-text-neutral-secondary text-sm">
Your account is connected to Prowler and ready to Scan!
Your provider is connected to Prowler and ready to Scan!
</p>
{!providerId && (
@@ -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),
);
};
+1 -1
View File
@@ -85,7 +85,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
className={cn("flex flex-col gap-2 text-left", className)}
{...props}
/>
);
+2
View File
@@ -6,6 +6,8 @@ export const DOCS_URLS = {
"https://docs.prowler.com/user-guide/tutorials/prowler-app#step-8:-analyze-the-findings",
FINDINGS_INGESTION:
"https://docs.prowler.com/user-guide/tutorials/prowler-app-import-findings",
FINDINGS_TRIAGE:
"https://docs.prowler.com/user-guide/tutorials/prowler-app-findings-triage",
AWS_ORGANIZATIONS:
"https://docs.prowler.com/user-guide/tutorials/prowler-cloud-aws-organizations",
ALERTS: "https://docs.prowler.com/user-guide/tutorials/prowler-app-alerts",
+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
@@ -389,21 +389,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]),
);
}
+11 -3
View File
@@ -54,6 +54,17 @@ export const isMutelistShortcutStatus = (status: unknown): boolean => {
);
};
export const getFindingTriageMuteInfoCopy = (status: FindingTriageStatus) =>
`Changing triage to ${FINDING_TRIAGE_STATUS_LABELS[status]} will mute the finding`;
// Only RESOLVED locks manual edits: automation owns the transition out of it
// (REOPENED on a failing rescan), while REOPENED invites human re-triage.
export const isTriageStatusLocked = (status: FindingTriageStatus): boolean =>
status === FINDING_TRIAGE_STATUS.RESOLVED;
export const FINDING_TRIAGE_RESOLVED_LOCKED_COPY =
"Triage status is managed automatically once the finding is resolved." as const;
export const FINDING_TRIAGE_DISABLED_REASON = {
CLOUD_ONLY: "cloud_only",
FORBIDDEN: "forbidden",
@@ -69,8 +80,6 @@ export const FINDING_TRIAGE_ORIGIN = {
} as const;
export const FINDING_TRIAGE_NOTE_MAX_LENGTH = 500 as const;
export const FINDING_TRIAGE_NOTE_PRIVACY_COPY =
"This note is only visible to your team." as const;
export const FINDING_TRIAGE_BILLING_HREF =
"https://prowler.com/pricing" as const;
@@ -92,7 +101,6 @@ export interface FindingTriageDetail extends FindingTriageSummary {
noteId: string | null;
noteBody: string;
maxNoteLength: typeof FINDING_TRIAGE_NOTE_MAX_LENGTH;
privacyCopy: typeof FINDING_TRIAGE_NOTE_PRIVACY_COPY;
}
export interface UpdateFindingTriageInput {
Generated
+1 -1
View File
@@ -3553,7 +3553,7 @@ wheels = [
[[package]]
name = "prowler"
version = "5.32.0"
version = "5.33.0"
source = { editable = "." }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },