chore: merge master
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.33.0
|
||||
version: 1.34.0
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -4762,7 +4762,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler-api"
|
||||
version = "1.33.0"
|
||||
version = "1.34.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "cartography" },
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 316 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 304 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 477 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 575 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 324 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 423 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 610 KiB |
|
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.
|
||||
|
||||

|
||||
|
||||
## 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. |
|
||||
|
||||

|
||||
|
||||
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>
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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]),
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||